外观
项目架构与工程基础
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 → repository,domain 不依赖外层。
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(错误处理)
三层错误:
- 领域错误:
ErrDeviceNotFound、ErrInvalidCoordinate - 服务层:包装上下文
fmt.Errorf("get device %s: %w", id, err) - 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 / pino | log/slog 结构化 |
| 错误 | throw + try/catch | 返回 error,Handler 映射 |
| 目录 | src/ 随意 | cmd/ + internal/ 约定 |
| 分层 | Controller / Service | handler / 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_URL、REDIS_ADDR、HTTP_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. 学习检查
cmd/与internal/分别放什么?- 生产配置为什么用环境变量而非改代码?
- 结构化日志相比
Println有何优势? - 领域错误在哪一层定义,在哪一层转为 HTTP 状态码?
- 为什么
main.go应该尽量薄?
12. 下一步
| 已完成 | 下一知识点 | 关系 |
|---|---|---|
| Architecture · Config · Logging · Error | Unit Test · Integration Test · Benchmark | 结构清晰 → 可测试 |
下一步学习如何为 service、handler 编写测试,并对 JSON 序列化等热点路径做 Benchmark。