本节把 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/httpapi | HTTP 编解码、状态码 | 不碰存储细节 |
| 领域 | 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":"..."} | 201 | 400 / 422 |
| GET | /tasks | — | 200 | — |
| GET | /tasks/{id} | — | 200 | 400 / 404 |
| PATCH | /tasks/{id} | {"done":true} | 200 | 400 / 404 |
| DELETE | /tasks/{id} | — | 204 | 400 / 404 |
| GET | /healthz | — | 200 | — |
| GET | /readyz | — | 200 | 503 |
| 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–3 | go mod init、[]Task、map 索引 | 让程序先跑起来 |
| 建模 | 4–6 | 方法、接口、错误分层 | 摆脱散装函数 |
| 工程化 | 7–9 | 拆包、测试、泛型分页 | 代码要能被维护 |
| 并发 | 10–12 | goroutine、RWMutex、context | 支持后台任务与优雅关闭 |
| 服务化 | 13–15 | net/http、数据库、配置与 DI | 变成真正的服务 |
| 可观测 | 16 | slog、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。这个演进顺序不是随意的:
- 先内存:在没有数据库的干扰下把领域逻辑和 HTTP 层调通。
- 再加锁:明确并发访问的边界,用
-race验证。 - 最后落库:接口不变,只换实现。
如果一开始就上数据库,你会在「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])还没接进Listhandler。 - 中间件(第 13.3 节)还没统一挂上请求日志与恢复(recover)。
- 没有
-race之外的压力验证。
这些不是「没做完」,而是收口节该做的事:把散在各章的零件真正装配成一个能交付的整体。
小结
- 先写用户故事与非功能需求,架构决策才有依据;US-5、US-6 这类运维需求最容易被漏掉。
- 四层结构(入口 / 传输 / 领域 / 存储),依赖单向向下,
task包是叶子。 - 接口抽象的判据是「有没有第二个实现」;
TaskStore接口在第 14 章接数据库时兑现价值。 - 错误分层用哨兵错误 +
errors.Is,让每层只处理自己认识的错误。 - 演进顺序「内存 → 加锁 → 落库」是为了隔离问题;架构是从踩坑里长出来的。
骨架和复盘都清楚了。下一节我们照着这份清单,把 TaskAPI 的端到端链路真正补齐——从创建到查询、从校验到错误响应,全部串起来跑通。
阅读导航:上一节:17.3 部署与优雅重启 · 下一节:18.2 端到端实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。