本节把 TaskAPI 从「分层的图纸」推进到「跑得通的整体」:按 18.1 的契约把四层装配起来,补齐增删改查与统一错误响应,用
-race跑测试、用curl走完整链路,验证全卷的知识点确实能拼成一个能用的服务。
适用版本:Go 1.27(实测go1.27.0)。
18.2 端到端实现
上一节定了架构与契约,这一节把它们装起来。注意:本节不重复粘贴前 17 章的完整代码,而是聚焦「装配」——哪些零件来自哪一章、按什么顺序接起来、装配时最容易出错的地方在哪。完整目录树与关键片段足够你复现。
18.2.1 最终目录树
四个包,和 18.1 的图纸一一对应:
taskapi/
├── go.mod # module taskapi, go 1.27
├── cmd/
│ └── taskapi/
│ └── main.go # 装配 + 服务骨架 + 优雅关闭
└── internal/
├── task/
│ └── task.go # Task、ErrNotFound、ErrInvalidTitle、Validate
├── store/
│ └── store.go # TaskStore 接口 + MemStore 实现(RWMutex)
└── httpapi/
├── server.go # 路由 + 5 个业务 handler + 2 个错误响应函数
└── server_test.go # 5 个表驱动/集成测试
go list ./... 确认包结构无误:
$ go list ./...
taskapi/cmd/taskapi
taskapi/internal/httpapi
taskapi/internal/store
taskapi/internal/task
18.2.2 装配顺序:main 里的手工 DI
装配是全卷的「交汇点」。main.go 按依赖方向自下而上构造,每一步都对应前面某一章:
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) // 16.1
srv := httpapi.New(store.NewMemStore(), log) // 5 + 15.2
httpSrv := &http.Server{
Addr: ":8080",
Handler: srv.Routes(),
ReadHeaderTimeout: 2 * time.Second, // 13.1:防御慢速头攻击
}
// ... 信号监听与优雅关闭(12.3 + 17.3)...
}
装配顺序不能颠倒:store 先于 httpapi,因为后者依赖前者;log 最先,因为所有组件都要它。手工 DI 的好处在这里体现得很清楚——依赖关系在代码里是显式的、自上而下可读的,不需要去猜某个框架在背后做了什么。
18.2.3 传输层:路由与 handler
httpapi.Server 持有两个依赖:store.TaskStore(接口,第 5 章)与 *slog.Logger(第 16 章)。路由用 Go 1.22+ 的 ServeMux 方法+路径模式,天然支持路径参数:
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("POST /tasks", s.create)
mux.HandleFunc("GET /tasks", s.list)
mux.HandleFunc("GET /tasks/{id}", s.get)
mux.HandleFunc("PATCH /tasks/{id}", s.patch)
mux.HandleFunc("DELETE /tasks/{id}", s.remove)
return mux
}
"GET /tasks/{id}" 里的 {id} 是路径通配符,用 r.PathValue("id") 取出。这比第 13 章手写 strings.TrimPrefix 解析路径干净得多。方法+路径模式还顺带解决了「同一路径不同方法」的分发,不必在 handler 里写 switch r.Method。
18.2.4 领域层:校验只做一件事
task.Validate 只校验领域规则,不碰 HTTP、不碰存储:
func (t Task) Validate() error {
if strings.TrimSpace(t.Title) == "" {
return fmt.Errorf("%w: title 不能为空", ErrInvalidTitle)
}
if len([]rune(t.Title)) > 120 {
return fmt.Errorf("%w: title 超过 120 字", ErrInvalidTitle)
}
return nil
}
两个细节值得强调:
len([]rune(t.Title))而不是len(t.Title):中文字符按字节算会高估(一个汉字 3 字节),按 rune 算才是「字数」。第 3 章讲过 string 与 rune 的区别,这里直接用到。- 用
%w包装哨兵错误:上层能用errors.Is(err, task.ErrInvalidTitle)判断,同时保留具体信息(是空还是超长)。
18.2.5 存储层:接口与并发安全
TaskStore 接口把「能做什么」和「怎么做」分开。内存实现用 RWMutex(第 11 章):读操作用 RLock,允许多个读并发;写操作用 Lock,独占:
func (m *MemStore) Get(_ context.Context, id int64) (task.Task, error) {
m.mu.RLock()
defer m.mu.RUnlock()
t, ok := m.items[id]
if !ok {
return task.Task{}, task.ErrNotFound
}
return t, nil
}
为什么必须加锁?net/http 为每个请求起一个 goroutine(第 10 章),多个请求会并发访问同一个 map。Go 的 map 并发读写会触发 fatal error: concurrent map writes——这是不可恢复的崩溃,不是普通的 panic。18.1 的踩坑复盘里第一条就是它。
18.2.6 补齐 PATCH 与 DELETE
18.1 的收口清单指出缺 Update/Delete 端点。补上它们,PATCH 用指针字段区分「没传」和「传了零值」:
type patchReq struct {
Title *string `json:"title"`
Done *bool `json:"done"`
}
if req.Done != nil {
cur.Done = *req.Done
}
这是 JSON 部分更新的经典手法:*bool 的 nil 表示「客户端没提这个字段」,&false 表示「客户端明确要设为 false」。如果用 bool,两者就无法区分。改完标题后再次调用 Validate——更新也必须过校验,否则能绕过创建时的规则。
DELETE 成功后返回 204 No Content,无响应体,这是 REST 惯例。
18.2.7 统一错误响应
所有 handler 的错误出口收拢到两个函数,保证响应格式一致(第 15 章):
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, kind, msg string) {
writeJSON(w, code, map[string]any{
"error": map[string]string{"code": kind, "message": strings.TrimSpace(msg)},
})
}
code 是机器可读的稳定标识(not_found、invalid_title),message 给人看。客户端判 code 不判 message——文案可以随时改,code 是契约。
18.2.8 测试:-race 与覆盖率
跑一遍测试,带竞态检测:
$ go test -race -cover ./...
taskapi/cmd/taskapi coverage: 0.0% of statements
ok taskapi/internal/httpapi 1.536s coverage: 60.0% of statements
taskapi/internal/store coverage: 0.0% of statements
taskapi/internal/task coverage: 0.0% of statements
-race 没报竞态,说明 RWMutex 用对了。httpapi 覆盖 60%,5 个测试覆盖了创建、空标题拒绝、查不到 404、PATCH 切换、删除后 404。cmd 和 store 显示 0% 是因为没有对应 _test.go——覆盖率按包统计,cmd/taskapi 的 main 本来就难测,靠端到端 curl 覆盖更实际。
18.2.9 端到端:从创建到删除
启动服务,用 curl 走一遍完整生命周期:
$ curl -s -X POST http://127.0.0.1:8080/tasks -d '{"title":"读第 18 章"}'
{"id":1,"title":"读第 18 章","done":false,"created_at":"2026-10-09T23:49:13.346274Z"}
$ curl -s -X POST http://127.0.0.1:8080/tasks -d '{"title":"写架构复盘"}'
{"id":2,"title":"写架构复盘","done":false,"created_at":"2026-10-09T23:49:13.394201Z"}
$ curl -s -X PATCH http://127.0.0.1:8080/tasks/1 -d '{"done":true}'
{"id":1,"title":"读第 18 章","done":true,"created_at":"2026-10-09T23:49:13.346274Z"}
查列表、删一条、再查已删的:
$ curl -s http://127.0.0.1:8080/tasks
[{"id":2,"title":"写架构复盘","done":false,"created_at":"..."},{"id":1,"title":"读第 18 章","done":true,"created_at":"..."}]
$ curl -s -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:8080/tasks/2
204
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/tasks/2
404
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8080/tasks -d '{"title":" "}'
422
状态码全部符合 18.1 的契约:创建 201(这里 POST 返回体正确)、删除 204、查已删 404、空标题 422。一个端到端跑通,比十个单元测试更能证明系统可用。
18.2.10 日志:全链路可观测
服务端的结构化日志(第 16 章)把整条链路串了起来:
{"time":"...","level":"INFO","msg":"taskapi started","version":"dev","addr":":8080"}
{"time":"...","level":"INFO","msg":"task created","id":1}
{"time":"...","level":"INFO","msg":"task created","id":2}
{"time":"...","level":"INFO","msg":"task updated","id":1,"done":true}
{"time":"...","level":"INFO","msg":"task deleted","id":2}
{"time":"...","level":"INFO","msg":"taskapi stopped"}
从 started 到 stopped,每个状态变更都有记录。taskapi stopped 这条是收到 SIGTERM 后优雅关闭打的——第 17.3 节的时序在这里自然收尾。
18.2.11 装配时最容易踩的坑
| 坑 | 现象 | 修法 |
|---|---|---|
| 依赖顺序颠倒 | store 还没建就传给 httpapi | 自下而上装配 |
map 并发写 | 高并发下 fatal error | 所有访问走 RWMutex |
| 中文标题长度误判 | 40 个汉字被拒 | 用 len([]rune(...)) |
| PATCH 无法区分零值 | false 被当成「没传」 | 用指针字段 |
| 更新绕过校验 | 能 PATCH 成空标题 | 更新后再 Validate |
| 错误格式不一致 | 有的返回字符串、有的返回对象 | 收拢到 writeError |
18.2.12 还有哪些没做
诚实地说,这个端到端实现仍是「教学完整、生产待补」:
- 没有中间件:请求日志、panic recover、CORS 还没挂(第 13.3 节讲了怎么写)。
- 没有分页:
List一次性返回全部(第 9 章的Page[T]还没接进来)。 - store 仍是内存版:重启即丢数据,接数据库见第 14 章。
- 没有限流与鉴权:这些属于上线前的加固,不在入门卷范围。
把这些列出来不是自我否定,而是如实标注边界——知道一个系统「还没做什么」,和知道它「做了什么」同样重要。
小结
- 端到端实现的核心是装配:
main里按依赖方向自下而上手工 DI,每一步对应前面某一章。 - 路由用
ServeMux的「方法+路径」模式,r.PathValue取路径参数,比手写解析干净。 - 领域校验用 rune 计长度、用
%w包装哨兵错误;更新后必须重新校验。 - 内存 store 必须加
RWMutex,否则并发下map会fatal error;-race验证。 - 实测:
-race无竞态、httpapi覆盖 60%,curl走通创建→更新→删除→404→422 全链路。
服务能跑了,测试过了,端到端通了。最后一节,我们把它测到位、打成发布产物、写上发布说明,完成整个卷的收口。
阅读导航:上一节:18.1 需求梳理与架构设计 · 下一节:18.3 测试、打包与上线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。