《Go 语言编程入门》18.2 端到端实现

把 TaskAPI 的四层装配成完整链路并实跑:从 main 的手工 DI 到 httpapi 的路由、store 的实现、领域校验,补齐 PATCH/DELETE 与统一错误响应,用 -race 跑测试、用 curl 走一遍创建到删除的端到端,复盘装配顺序与并发安全的关键决策。

本节把 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 测试、打包与上线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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