Skip to content

项目架构与工程基础

Phase 07 — Engineering · 知识点 01–04:Project Architecture · Configuration · Logging · Error Handling


1. 学习目标

完成本知识点后,你应该能够:

  • 为 Go Web / WebSocket 项目设计清晰的分层目录结构(Project Architecture)
  • 使用环境变量和配置文件管理 Configuration,避免硬编码
  • 引入结构化 Logging,便于排查设备 ID、请求 ID 相关问题
  • 建立统一的 Error Handling 策略(领域错误、HTTP 错误、WS 错误)
  • 将 Phase 06 的实验代码重构为可维护的工程项目

2. 为什么需要

当 Project 02 / Project 03 从几百行 demo 增长到数千行时,若所有逻辑堆在 main.go,你将无法编写测试、无法替换数据库、无法在不同环境(本地 / Docker / 生产)切换配置。

工程化的目标:让新功能有地方放、让错误有迹可循、让部署不依赖改代码


3. 核心概念

3.1 Project Architecture(项目架构)

推荐分层(数字孪生 REST + WebSocket 服务):

project/
├── cmd/
│   └── server/
│       └── main.go          # 入口:组装依赖、启动 HTTP
├── internal/
│   ├── config/              # 配置加载
│   ├── domain/              # 领域模型与接口(Device, AGV, Repository)
│   ├── handler/             # HTTP / WebSocket Handler
│   ├── service/             # 业务逻辑
│   ├── repository/          # 数据库 / Redis 实现
│   ├── ws/                  # Hub, Room, Client
│   └── pipeline/            # 实时数据管道
├── pkg/                     # 可被外部引用的公共库(可选)
├── migrations/              # SQL 迁移
├── docker/
├── go.mod
└── go.sum

依赖方向handler → service → repositorydomain 不依赖外层。

3.2 Configuration(配置管理)

来源适用
环境变量Docker / 生产(12-Factor)
.env 文件本地开发(不提交仓库)
配置文件 config.yaml复杂静态配置

示例环境变量

APP_ENV=development
HTTP_ADDR=:8080
DATABASE_URL=postgres://user:pass@localhost:5432/twin?sslmode=disable
REDIS_ADDR=localhost:6379
JWT_SECRET=change-me-in-production
LOG_LEVEL=debug

使用 os.Getenv 或库(如 github.com/joho/godotenv 仅开发、github.com/kelseyhightower/envconfig)。

3.3 Logging(日志)

级别用途
Debug开发调试
Info正常业务流程(连接建立、请求完成)
Warn可恢复异常(重试、慢查询)
Error需关注的失败

结构化日志(推荐 log/slog,Go 1.21+):

go
slog.Info("websocket connected",
    "client_id", clientID,
    "room", roomID,
    "remote", r.RemoteAddr,
)

避免:log.Println("connected " + clientID) — 难以检索与聚合。

3.4 Error Handling(错误处理)

三层错误

  1. 领域错误ErrDeviceNotFoundErrInvalidCoordinate
  2. 服务层:包装上下文 fmt.Errorf("get device %s: %w", id, err)
  3. Handler 层:映射 HTTP 状态码或 WS close code
go
// domain/errors.go
var ErrDeviceNotFound = errors.New("device not found")

// handler/device.go
if errors.Is(err, domain.ErrDeviceNotFound) {
    http.Error(w, `{"error":"device not found"}`, http.StatusNotFound)
    return
}

统一 JSON 错误响应

go
type APIError struct {
    Error   string `json:"error"`
    Code    string `json:"code,omitempty"`
}

4. 基础语法

4.1 配置加载

go
package config

import (
    "os"
    "strconv"
)

type Config struct {
    Env         string
    HTTPAddr    string
    DatabaseURL string
    RedisAddr   string
    LogLevel    string
}

func Load() Config {
    return Config{
        Env:         getEnv("APP_ENV", "development"),
        HTTPAddr:    getEnv("HTTP_ADDR", ":8080"),
        DatabaseURL: getEnv("DATABASE_URL", ""),
        RedisAddr:   getEnv("REDIS_ADDR", "localhost:6379"),
        LogLevel:    getEnv("LOG_LEVEL", "info"),
    }
}

func getEnv(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}

4.2 slog 初始化

go
func setupLogger(level string) {
    var lvl slog.Level
    switch level {
    case "debug":
        lvl = slog.LevelDebug
    default:
        lvl = slog.LevelInfo
    }
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
    slog.SetDefault(slog.New(handler))
}

4.3 main 组装依赖

go
func main() {
    cfg := config.Load()
    setupLogger(cfg.LogLevel)

    db, err := repository.NewPostgres(cfg.DatabaseURL)
    if err != nil {
        slog.Error("database connect failed", "err", err)
        os.Exit(1)
    }
    defer db.Close()

    deviceSvc := service.NewDeviceService(db)
    hub := ws.NewHub()
    go hub.Run()

    mux := http.NewServeMux()
    handler.RegisterRoutes(mux, deviceSvc, hub)

    slog.Info("server starting", "addr", cfg.HTTPAddr)
    if err := http.ListenAndServe(cfg.HTTPAddr, mux); err != nil {
        slog.Error("server stopped", "err", err)
        os.Exit(1)
    }
}

5. 代码解析

internal/:Go 编译器 enforced,外部 module 无法 import,适合业务代码。

配置默认值:本地开发友好,但生产必填项(如 DATABASE_URL)应在启动时校验,缺失则 Fatal

slog JSON 格式:便于 ELK、Loki 等日志系统解析;开发环境可换 TextHandler

错误 errors.Is:支持哨兵错误判断,优于字符串比较。


6. JavaScript / TypeScript 对比

概念Node.js / 前端Go 工程化
配置.env + process.env环境变量 + config
日志console.log / pinolog/slog 结构化
错误throw + try/catch返回 error,Handler 映射
目录src/ 随意cmd/ + internal/ 约定
分层Controller / Servicehandler / service / repository

Go 没有异常,错误必须显式处理——这与 Vue 前端 axios interceptor 统一处理 HTTP 错误类似。


7. 常见错误

错误 1:密钥写进代码或提交 .env

使用 .env.example 模板,真实密钥仅通过环境变量注入。

错误 2:Handler 直接写 SQL

破坏分层,无法 Mock 测试。SQL 应在 repository

错误 3:日志 println 拼接敏感信息

不要记录 JWT、数据库密码、完整 Authorization header。

错误 4:每个 Handler 各自 JSON 错误格式

应统一 writeError(w, status, code, msg) helper。

错误 5:配置散落 os.Getenv 各处

集中 config.Load(),便于测试时注入。


8. 实际应用

Project 03 工程化清单

  • [ ] cmd/server/main.go 仅负责 wiring
  • [ ] internal/domain/device.go 定义 AGV、Warehouse
  • [ ] internal/ws/ Hub/Room 与 handler 分离
  • [ ] 环境变量:DATABASE_URLREDIS_ADDRHTTP_ADDR
  • [ ] 所有 WS 连接/断开、HTTP 5xx 有结构化日志
  • [ ] API 错误统一 { "error": "...", "code": "..." }

9. 深入理解

9.1 十二要素应用(12-Factor)

配置、日志、进程、并发等原则与 Docker 部署(Phase 07/08)直接相关。

9.2 接口在 domain 层定义

go
type DeviceRepository interface {
    GetByID(ctx context.Context, id string) (*Device, error)
    ListByWarehouse(ctx context.Context, warehouseID string) ([]Device, error)
}

service 依赖接口,repository/postgres 实现——便于 Integration Test 使用内存实现。

9.3 Graceful Shutdown

go
srv := &http.Server{Addr: cfg.HTTPAddr, Handler: mux}
// 监听 SIGINT/SIGTERM,srv.Shutdown(ctx)

与 WebSocket 连接 draining 配合(Phase 06 已学基础)。


10. 练习

详细练习见 exercises/phase-07-engineering/01-project-architecture.md

Level 1 — 基础

练习 1.1:将现有 demo 拆分为 cmd/ + internal/handler + internal/ws

练习 1.2:实现 config.Load(),从环境变量读取 HTTP_ADDR

Level 2 — 应用

练习 2.1:引入 slog,为 HTTP 请求和 WS 连接添加结构化日志。

练习 2.2:定义 3 个领域错误,Handler 映射为 400/404/500。

Level 3 — 综合

练习 3.1:定义 DeviceRepository 接口与内存实现,service 层单元测试不依赖 DB。

Level 4 — 项目实践

练习 4.1:Project 03 Phase 07 里程碑 — 完整分层 + 配置 + 日志 + 统一错误。


11. 学习检查

  1. cmd/internal/ 分别放什么?
  2. 生产配置为什么用环境变量而非改代码?
  3. 结构化日志相比 Println 有何优势?
  4. 领域错误在哪一层定义,在哪一层转为 HTTP 状态码?
  5. 为什么 main.go 应该尽量薄?

12. 下一步

已完成下一知识点关系
Architecture · Config · Logging · ErrorUnit Test · Integration Test · Benchmark结构清晰 → 可测试

下一步学习如何为 service、handler 编写测试,并对 JSON 序列化等热点路径做 Benchmark。


学习导航

上一篇:实时数据管道 · 对应练习 · 下一篇:测试与性能基准