《Go 语言编程入门》18.1 需求梳理与架构设计

对 TaskAPI 做一次完整复盘:从用户故事与非功能需求出发,梳理分层架构、包边界与 HTTP 契约,回顾从第 1 章到第 17 章的演进轨迹,并逐条复盘关键架构取舍——接口抽象的判据、内存到数据库的平滑迁移、错误分层与并发安全,最后列出收口清单。

本节把 TaskAPI 从「一堆功能」推进到「一个想清楚了结构的项目」:重新走一遍需求梳理、画出分层与包边界、逐条复盘全卷的关键架构决策,为 18.2 的端到端实现与 18.3 的打包上线定下骨架。
适用版本:Go 1.27(实测 go1.27.0)。

18.1 需求梳理与架构设计

前 17 章每一节都只在推进一个局部:这一节学 slice、那一节学 interface,再一节加个 HTTP handler。到了收口的时候,必须跳出细节,问三个问题:这个系统要解决什么问题?边界在哪?当初那些决定现在还站得住吗? 本节不写新功能,只做复盘与定架构。

18.1.1 需求:用户故事先行

TaskAPI 的需求用用户故事表达,比罗列功能更贴近真实:

编号用户故事验收要点
US-1作为用户,我能创建一条任务标题非空、≤120 字,返回 ID
US-2作为用户,我能查看任务列表分页、按创建时间排序
US-3作为用户,我能标记任务完成幂等,重复标记不报错
US-4作为用户,我能按 ID 查询单个任务不存在返回 404
US-5作为运维,我能看到服务健康与指标/healthz、/readyz、/metrics
US-6作为运维,我能安全地重启服务重启不丢在途请求

US-1 到 US-4 是业务需求,US-5、US-6 是非功能需求——它们不产出用户可见的功能,却决定了系统能不能上线。很多初学者只写业务需求,结果系统在「能跑」和「能运营」之间卡住。

18.1.2 非功能需求:那些容易被忽略的约束

维度约束落到哪一节
可观测结构化日志 + 指标端点第 16 章
可交付交叉编译 + 最小镜像第 17 章
可靠性优雅关闭、请求级 context第 12 章
并发安全store 用 RWMutex,-race 验证第 11 章
可维护分层、接口抽象、表驱动测试第 5、8 章
可扩展内存 store 可替换为 SQL store第 14 章

把非功能需求显式写出来,架构决策才有依据。比如「可扩展」这一条,直接决定了第 5 章必须抽象出 TaskStore 接口,而不是让 handler 直接操作一个 map。

18.1.3 分层:三层加一层

TaskAPI 采用最朴素的分层,共四层:

层包职责不做什么
入口cmd/taskapi装配、读配置、起服务不写业务逻辑
传输internal/httpapiHTTP 编解码、状态码不碰存储细节
领域internal/task模型与校验规则不依赖 HTTP、不依赖存储
存储internal/store持久化接口与实现不做业务校验

依赖方向自上而下单向:cmd → httpapi → store/task。task 包不导入任何其他内部包——它是依赖图的叶子,这样领域规则可以被任何上层复用,也最容易被测试。

18.1.4 包边界:internal 的意义

所有实现都放在 internal/ 下,这是 Go 的强制访问控制:internal 下的包只能被同一模块内导入,外部模块无法依赖。这让「哪些是公开 API、哪些是内部实现」一目了然——本卷的 TaskAPI 没有对外 API,所以全部收进 internal。

taskapi/
├── go.mod
├── cmd/
│   └── taskapi/
│       └── main.go              # 装配:logger + store + httpapi
└── internal/
    ├── task/
    │   └── task.go              # Task 模型、ErrNotFound、Validate
    ├── store/
    │   └── store.go             # TaskStore 接口 + MemStore 内存实现
    └── httpapi/
        ├── server.go            # 路由与 handler
        └── server_test.go       # 表驱动 + httptest

用 go list ./... 确认包结构:

$ go list ./...
taskapi/cmd/taskapi
taskapi/internal/httpapi
taskapi/internal/store
taskapi/internal/task

四个包,边界清晰。第 7 章讲的「拆包为 internal/task、internal/store、cmd/taskapi」到这里才真正定型。

18.1.5 API 契约:先定端点再写代码

架构定完,紧接着定对外契约。TaskAPI 的 HTTP 接口如下:

方法路径请求体成功码失败码
POST/tasks{"title":"..."}201400 / 422
GET/tasks—200—
GET/tasks/{id}—200400 / 404
PATCH/tasks/{id}{"done":true}200400 / 404
DELETE/tasks/{id}—204400 / 404
GET/healthz—200—
GET/readyz—200503
GET/metrics—200—

统一错误响应体(第 15 章定的格式):

{"error": {"code": "not_found", "message": "任务不存在"}}

code 是机器可读的稳定标识,message 是给人看的、可以随文案调整。客户端应该判 code 而不是 message——这和「用哨兵错误而不是错误字符串」是同一个原则在 API 层的体现。

18.1.6 数据模型与存储契约

Task 是全系统唯一的核心模型:

type Task struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	Done      bool      `json:"done"`
	CreatedAt time.Time `json:"created_at"`
}

存储契约由 TaskStore 接口固定(见 18.1.11)。设计上有意让 TaskStore 的方法都接收 context.Context(第 12 章):这样上层取消请求时,数据库查询能一并取消,不会留下「客户端早断开、服务端还在查」的浪费。

18.1.7 配置与装配

配置从环境变量读(第 15 章),装配在 cmd/taskapi/main.go 里手工完成:

func main() {
	cfg := loadConfig()                 // 读 PORT、LOG_LEVEL、DSN
	log := newLogger(cfg.LogLevel)      // 第 16 章的 slog
	st := store.NewMemStore()           // 第 5 章的接口实现
	srv := httpapi.New(st, log)         // 第 15 章的手工 DI
	runServer(cfg, srv, log)            // 第 12、13 章的服务骨架
}

没有用 DI 框架是刻意的:TaskAPI 的依赖图只有三层,手工装配最清晰。等依赖膨胀到十几个、嵌套多层时,再考虑引入容器也不迟——第 15 章讲过这个取舍。

18.1.8 测试策略

对应第 8 章,TaskAPI 的测试分三层:

层测什么手段
领域校验规则、错误值纯函数表驱动测试
存储CRUD 正确性、并发fake / -race
传输状态码、响应体httptest + 表驱动

httpapi 的测试用 store.NewMemStore() 作为真实依赖(它足够快),不必造 mock——能用一个快的真实实现时,别引入 mock。这也是第 8 章「test doubles」一节的结论。

18.1.9 部署拓扑

收口时的部署形态:

客户端
  │  HTTPS
  ▼
反向代理 / 负载均衡 (nginx / ALB)
  │  HTTP :8080
  ▼
TaskAPI 实例 (systemd / 容器, 非 root)
  ├── :8080  业务端点
  └── :6060  观测端点 (仅内网)
        ▲
   监控系统抓取 /metrics

观测端点和业务端点分离端口,是第 16 章安全建议的落地。反向代理后面跑多个 TaskAPI 实例,就是第 17.3 节的零停机滚动。

18.1.10 演进复盘:从第 1 章到现在

把全卷的推进轨迹拉成一条线,能看清每一步为什么发生:

阶段章节关键决定当时的问题
起步1–3go mod init、[]Task、map 索引让程序先跑起来
建模4–6方法、接口、错误分层摆脱散装函数
工程化7–9拆包、测试、泛型分页代码要能被维护
并发10–12goroutine、RWMutex、context支持后台任务与优雅关闭
服务化13–15net/http、数据库、配置与 DI变成真正的服务
可观测16slog、pprof、健康检查上线后能查问题
交付17交叉编译、镜像、部署能安全地跑在服务器上

这张表本身就是一份「架构演化史」。真实项目也是这样长出来的——不是一开始就设计成四层,而是随着需求压力逐步收敛。

18.1.11 决策复盘一:为什么要有 TaskStore 接口

第 5 章引入 TaskStore 接口,当时的理由「方便替换实现」在初学者看来像是过度设计——毕竟只有一个内存实现。到第 14 章接数据库时,这个接口的价值兑现了:新增一个 SQL 实现,httpapi 一行都不用改。

type TaskStore interface {
	Create(ctx context.Context, t task.Task) (task.Task, error)
	Get(ctx context.Context, id int64) (task.Task, error)
	List(ctx context.Context) ([]task.Task, error)
	Update(ctx context.Context, t task.Task) (task.Task, error)
	Delete(ctx context.Context, id int64) error
}

接口的判据是「有没有第二个实现」。这里内存实现和 SQL 实现并存,接口就成立;如果永远只有一种实现,抽象接口反而是负担。这个判断标准比「面向接口编程」的口号实用得多。

18.1.12 决策复盘二:错误分层与 %w

第 6 章定义了 ErrNotFound、ErrInvalidTitle 两个哨兵错误,并用 %w 包装。这套设计让每一层只处理自己认识的错误:

  • 领域层 task.Validate 返回 ErrInvalidTitle。
  • 存储层 store.Get 返回 ErrNotFound。
  • 传输层 httpapi 用 errors.Is 判断,映射成 422 / 404。
t, err := s.store.Get(r.Context(), id)
if errors.Is(err, task.ErrNotFound) {
	writeError(w, http.StatusNotFound, "not_found", "任务不存在")
	return
}

如果当初用字符串比较错误信息(err.Error() == "not found"),任何措辞改动都会悄悄破坏映射。errors.Is + 哨兵错误把「错误是类型化的值」这件事落到了实处。

18.1.13 决策复盘三:内存到数据库的平滑

第 3 章用 []Task + map[int64]Task,第 11 章给 MemStore 加 RWMutex,第 14 章换成 database/sql。这个演进顺序不是随意的:

  1. 先内存:在没有数据库的干扰下把领域逻辑和 HTTP 层调通。
  2. 再加锁:明确并发访问的边界,用 -race 验证。
  3. 最后落库:接口不变,只换实现。

如果一开始就上数据库,你会在「SQL 写错」和「HTTP 处理错」两种 bug 之间反复横跳。分层的一个现实收益就是把问题隔离开。

18.1.14 踩坑复盘

全卷攒下的坑,按类别列出来,比零散记忆有用:

坑现象教训
map 并发写偶发 fatal error: concurrent map writes有并发就必须加锁,别信「应该不会同时访问」
切片共享底层数组append 改了别人的数据slices.Clone 或显式拷贝
错误信息字符串比较改文案导致逻辑失效用 errors.Is + 哨兵错误
goroutine 泄漏进程内存缓慢上涨worker 必须有 close(ch) 与 context 取消
优雅关闭顺序错重启丢请求先摘 readiness、再 Shutdown
CGO 忘关二进制丢进 alpine 报错静态构建三件套
日志打明文 token安全事故用 LogValuer 类型级脱敏

每一条都对应正文里的一节。架构不是设计出来的,是从这些坑里长出来的——这正是把全卷串成一条项目线的原因。

18.1.15 还欠什么:留给下一节的清单

复盘也暴露了当前的缺口,18.2 的端到端实现要补齐:

  • 当前的 MemStore 缺少 Update/Delete 的 HTTP 端点(只有 Create/Get/List)。
  • 分页(第 9 章的 Page[T])还没接进 List handler。
  • 中间件(第 13.3 节)还没统一挂上请求日志与恢复(recover)。
  • 没有 -race 之外的压力验证。

这些不是「没做完」,而是收口节该做的事:把散在各章的零件真正装配成一个能交付的整体。

小结

  • 先写用户故事与非功能需求,架构决策才有依据;US-5、US-6 这类运维需求最容易被漏掉。
  • 四层结构(入口 / 传输 / 领域 / 存储),依赖单向向下,task 包是叶子。
  • 接口抽象的判据是「有没有第二个实现」;TaskStore 接口在第 14 章接数据库时兑现价值。
  • 错误分层用哨兵错误 + errors.Is,让每层只处理自己认识的错误。
  • 演进顺序「内存 → 加锁 → 落库」是为了隔离问题;架构是从踩坑里长出来的。

骨架和复盘都清楚了。下一节我们照着这份清单,把 TaskAPI 的端到端链路真正补齐——从创建到查询、从校验到错误响应,全部串起来跑通。

阅读导航:上一节:17.3 部署与优雅重启 · 下一节:18.2 端到端实现 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练