本文是「从 C++ 到 Go」系列的第七篇,也是整个系列的收官之作。上一篇 讨论了 IO 接口与标准库,本篇把前面所有知识串联起来,用纯标准库(不依赖任何第三方包)实现一个完整的 TODO REST API 服务。项目不大,但覆盖了包组织、接口、泛型、JSON、HTTP、并发安全、错误处理和测试——每个部分都对应着前面某一课的内容。
项目结构 先看整体目录:
1 2 3 4 5 6 7 8 9 10 11 12 14_todo_api/ ├── main.go ← 入口:组装依赖、启动服务 ├── model/ │ └── todo.go ← 数据模型 ├── response/ │ └── response.go ← 泛型 API 响应包装 ├── store/ │ ├── store.go ← Store 接口 + 内存实现 │ └── store_test.go ← 表驱动测试 + 并发安全测试 ├── handler/ │ ├── handler.go ← HTTP 处理层 │ └── handler_test.go ← httptest 集成测试
四个包按职责划分:model 定义数据结构,store 负责存取,handler 处理 HTTP,response 提供统一的响应格式。main 只做组装。这是第四篇 讨论的「按领域拆包」原则的实际应用。
调用链路也很清晰:
1 2 3 4 5 客户端请求 → net/http(路由匹配 + 连接管理) → handler(解析请求 → 调用 store → 构建响应) → store(业务逻辑 + 数据存取) → model(纯数据结构)
每一层只依赖它下面的层,不反向依赖。
API 设计 遵循 REST 风格——用 URL 标识资源,用 HTTP 方法表达操作:
1 2 3 4 5 GET /todos 列出所有(可选 ?status=done 过滤) POST /todos 创建 GET /todos/{id} 获取单个 PUT /todos/{id} 更新(支持部分更新) DELETE /todos/{id} 删除
所有响应使用统一的 JSON 格式:成功时 {"data": ...},失败时 {"error": "..."}。
数据模型 从依赖树的叶子开始。model 包不依赖项目内的任何其他包,只定义数据结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package modelimport "time" type Todo struct { ID string `json:"id"` Title string `json:"title"` Done bool `json:"done"` CreatedAt time.Time `json:"created_at"` } type CreateRequest struct { Title string `json:"title"` } type UpdateRequest struct { Title *string `json:"title,omitempty"` Done *bool `json:"done,omitempty"` }
Todo 是核心实体,CreateRequest 和 UpdateRequest 是客户端发送的请求体。将请求体和实体分开定义是 API 设计的常见做法——客户端不应该控制 ID 和创建时间。
UpdateRequest 的字段是指针类型,这是为了实现「部分更新」:客户端发 {"done": true} 时,Title 为 nil(不更新),Done 非 nil(更新)。如果用 bool 而不是 *bool,就无法区分「客户端没传」和「客户端传了 false」——因为两者反序列化后都是零值 false。这个技巧只在需要区分「未传」和「传了零值」的场景下才需要;对于 CreateRequest 这种全量必填的结构,直接用值类型就好。
泛型响应包装 response 包用泛型定义统一的 API 响应格式——这是第五篇 讨论的泛型在实际项目中的应用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package responseimport ( "encoding/json" "net/http" ) type Response[T any] struct { Data T `json:"data,omitempty"` Error string `json:"error,omitempty"` } func JSON [T any ](w http.ResponseWriter, status int , resp Response[T]) { w.Header().Set("Content-Type" , "application/json" ) w.WriteHeader(status) json.NewEncoder(w).Encode(resp) } func OK [T any ](w http.ResponseWriter, data T) { JSON(w, http.StatusOK, Response[T]{Data: data}) } func Created [T any ](w http.ResponseWriter, data T) { JSON(w, http.StatusCreated, Response[T]{Data: data}) } func Err (w http.ResponseWriter, status int , msg string ) { JSON(w, status, Response[any]{Error: msg}) }
没有泛型时,Data 只能是 interface{},调用方拿到响应后还得做类型断言。有了 Response[T],OK(w, todo) 返回的就是 Response[model.Todo],OK(w, todos) 返回的就是 Response[[]*model.Todo]——类型在编译期确定。
存储层 store 包是业务逻辑的核心,综合运用了接口、错误处理和并发控制。
接口与哨兵错误 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package storeimport ( "errors" "fmt" "sync" "time" "learn-go/14_todo_api/model" ) var ErrNotFound = errors.New("todo not found" )type Store interface { List() []*model.Todo Get(id string ) (*model.Todo, error ) Create(title string ) *model.Todo Update(id string , req model.UpdateRequest) (*model.Todo, error ) Delete(id string ) error }
Store 是接口,定义了存储层的行为契约。handler 依赖这个接口,不依赖具体实现——未来换成数据库只需要写一个新的实现,handler 代码不用改。这是第二篇 讨论的隐式接口的实际价值。
ErrNotFound 是哨兵错误,调用方通过 errors.Is(err, store.ErrNotFound) 来判断——不比较字符串,而是比较值。这是第三篇 讨论过的错误处理惯例。
内存实现 1 2 3 4 5 6 7 8 9 10 11 type MemoryStore struct { mu sync.RWMutex todos map [string ]*model.Todo nextID int } func NewMemoryStore () *MemoryStore { return &MemoryStore{ todos: make (map [string ]*model.Todo), } }
sync.RWMutex 区分读锁和写锁:多个读操作可以同时进行(RLock),写操作独占(Lock)。对于「读多写少」的 API 服务,比普通的 sync.Mutex 并发性能更好。
读操作用 RLock:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func (s *MemoryStore) List() []*model.Todo { s.mu.RLock() defer s.mu.RUnlock() result := make ([]*model.Todo, 0 , len (s.todos)) for _, todo := range s.todos { result = append (result, todo) } return result } func (s *MemoryStore) Get(id string ) (*model.Todo, error ) { s.mu.RLock() defer s.mu.RUnlock() todo, ok := s.todos[id] if !ok { return nil , ErrNotFound } return todo, nil }
写操作用 Lock:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 func (s *MemoryStore) Create(title string ) *model.Todo { s.mu.Lock() defer s.mu.Unlock() s.nextID++ id := fmt.Sprintf("todo-%d" , s.nextID) todo := &model.Todo{ ID: id, Title: title, Done: false , CreatedAt: time.Now(), } s.todos[id] = todo return todo } func (s *MemoryStore) Update(id string , req model.UpdateRequest) (*model.Todo, error ) { s.mu.Lock() defer s.mu.Unlock() todo, ok := s.todos[id] if !ok { return nil , ErrNotFound } if req.Title != nil { todo.Title = *req.Title } if req.Done != nil { todo.Done = *req.Done } return todo, nil } func (s *MemoryStore) Delete(id string ) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.todos[id]; !ok { return ErrNotFound } delete (s.todos, id) return nil }
Update 中体现了指针字段的作用:只有非 nil 的字段才会被更新,实现部分更新语义。
存储层测试 测试紧跟实现来读。用到了表驱动测试、子测试和并发安全测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 package storeimport ( "sync" "testing" "learn-go/14_todo_api/model" ) func TestCRUD (t *testing.T) { s := NewMemoryStore() var todoID string t.Run("Create" , func (t *testing.T) { todo := s.Create("买牛奶" ) if todo.Title != "买牛奶" { t.Errorf("Title = %q, want %q" , todo.Title, "买牛奶" ) } if todo.Done { t.Error("new todo should not be done" ) } if todo.ID == "" { t.Error("ID should not be empty" ) } todoID = todo.ID }) t.Run("Get" , func (t *testing.T) { todo, err := s.Get(todoID) if err != nil { t.Fatalf("Get(%q) error: %v" , todoID, err) } if todo.Title != "买牛奶" { t.Errorf("Title = %q, want %q" , todo.Title, "买牛奶" ) } }) t.Run("Get_NotFound" , func (t *testing.T) { _, err := s.Get("nonexistent" ) if err != ErrNotFound { t.Errorf("Get(nonexistent) error = %v, want ErrNotFound" , err) } }) t.Run("Update_Title" , func (t *testing.T) { newTitle := "买酸奶" todo, err := s.Update(todoID, model.UpdateRequest{Title: &newTitle}) if err != nil { t.Fatalf("Update error: %v" , err) } if todo.Title != "买酸奶" { t.Errorf("Title = %q, want %q" , todo.Title, "买酸奶" ) } if todo.Done { t.Error("Done should not change when only Title is updated" ) } }) t.Run("Update_Done" , func (t *testing.T) { done := true todo, err := s.Update(todoID, model.UpdateRequest{Done: &done}) if err != nil { t.Fatalf("Update error: %v" , err) } if !todo.Done { t.Error("Done should be true" ) } }) t.Run("Delete" , func (t *testing.T) { err := s.Delete(todoID) if err != nil { t.Fatalf("Delete error: %v" , err) } _, err = s.Get(todoID) if err != ErrNotFound { t.Error("Get after Delete should return ErrNotFound" ) } }) }
这些子测试共享同一个 store 实例,前一个步骤的结果是后一个步骤的输入——Create 的 ID 传给 Get,Update 修改后 Delete 删除,Delete 后 Get 验证 404。
注意测试文件声明的是 package store(不是 package store_test),所以可以直接访问 ErrNotFound 等未导出的标识符——这是第四篇 讨论的 _test.go 特权。
并发安全测试更有意思:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 func TestConcurrency (t *testing.T) { s := NewMemoryStore() s.Create("初始任务" ) var wg sync.WaitGroup const goroutines = 50 for i := 0 ; i < goroutines; i++ { wg.Add(2 ) go func () { defer wg.Done() s.Create("并发创建" ) }() go func () { defer wg.Done() s.List() }() } wg.Wait() todos := s.List() expected := 1 + goroutines if len (todos) != expected { t.Errorf("List() len = %d, want %d" , len (todos), expected) } }
50 个 goroutine 同时写、50 个同时读。如果 RWMutex 保护有遗漏,go test -race 会捕获数据竞争。
HTTP 处理层 handler 包是 HTTP 和业务逻辑的衔接点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package handlerimport ( "encoding/json" "errors" "net/http" "strings" "learn-go/14_todo_api/model" "learn-go/14_todo_api/response" "learn-go/14_todo_api/store" ) type Handler struct { store store.Store } func New (s store.Store) *Handler { return &Handler{store: s} }
Handler 持有 store.Store 接口——依赖注入。main 函数负责创建具体的 MemoryStore 并传进来。
路由注册 Go 1.22 开始支持 "METHOD /path" 格式的路由,{id} 是路径参数:
1 2 3 4 5 6 7 func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /todos" , h.handleList) mux.HandleFunc("POST /todos" , h.handleCreate) mux.HandleFunc("GET /todos/{id}" , h.handleGet) mux.HandleFunc("PUT /todos/{id}" , h.handleUpdate) mux.HandleFunc("DELETE /todos/{id}" , h.handleDelete) }
在 1.22 之前,标准库的 ServeMux 不支持方法匹配和路径参数,需要第三方路由库(如 gorilla/mux)或手动在 handler 里判断 r.Method。现在标准库就够用了。
各 Handler 实现 列出所有 todo,支持 ?status=done 过滤:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) { todos := h.store.List() statusFilter := r.URL.Query().Get("status" ) if statusFilter != "" { filtered := make ([]*model.Todo, 0 ) for _, todo := range todos { switch strings.ToLower(statusFilter) { case "done" : if todo.Done { filtered = append (filtered, todo) } case "undone" : if !todo.Done { filtered = append (filtered, todo) } } } todos = filtered } response.OK(w, todos) }
创建:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) { var req model.CreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Err(w, http.StatusBadRequest, "invalid JSON: " +err.Error()) return } if strings.TrimSpace(req.Title) == "" { response.Err(w, http.StatusBadRequest, "title is required" ) return } todo := h.store.Create(req.Title) response.Created(w, todo) }
获取、更新、删除的结构都是同一个模式——解析请求、调用 store、处理错误、返回响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id" ) todo, err := h.store.Get(id) if err != nil { h.handleError(w, err) return } response.OK(w, todo) } func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id" ) var req model.UpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Err(w, http.StatusBadRequest, "invalid JSON: " +err.Error()) return } todo, err := h.store.Update(id, req) if err != nil { h.handleError(w, err) return } response.OK(w, todo) } func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id" ) if err := h.store.Delete(id); err != nil { h.handleError(w, err) return } response.OK(w, map [string ]string {"deleted" : id}) }
错误映射用 errors.Is 将 store 层的哨兵错误转换为 HTTP 状态码:
1 2 3 4 5 6 7 func (h *Handler) handleError(w http.ResponseWriter, err error ) { if errors.Is(err, store.ErrNotFound) { response.Err(w, http.StatusNotFound, err.Error()) return } response.Err(w, http.StatusInternalServerError, "internal error" ) }
Handler 测试 Go 标准库的 httptest 包可以在不启动真实 HTTP 服务的情况下测试 handler——构造请求、录制响应、直接调用 ServeHTTP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package handlerimport ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "learn-go/14_todo_api/model" "learn-go/14_todo_api/response" "learn-go/14_todo_api/store" ) func setupTestServer () *http.ServeMux { s := store.NewMemoryStore() h := New(s) mux := http.NewServeMux() h.RegisterRoutes(mux) return mux } func decodeResponse [T any ](t *testing.T, rec *httptest.ResponseRecorder) response.Response[T] { t.Helper() var resp response.Response[T] if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v" , err) } return resp }
decodeResponse 是一个泛型辅助函数——又一个泛型的实际用例:不同测试需要解码不同类型的响应体,一个函数搞定。
测试创建和获取的完整流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 func TestCreateAndGet (t *testing.T) { mux := setupTestServer() body := `{"title":"学习 Go"}` req := httptest.NewRequest("POST" , "/todos" , bytes.NewBufferString(body)) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("POST /todos status = %d, want %d" , rec.Code, http.StatusCreated) } resp := decodeResponse[model.Todo](t, rec) if resp.Data.Title != "学习 Go" { t.Errorf("Title = %q, want %q" , resp.Data.Title, "学习 Go" ) } todoID := resp.Data.ID req = httptest.NewRequest("GET" , "/todos/" +todoID, nil ) rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("GET status = %d, want %d" , rec.Code, http.StatusOK) } }
表驱动测试覆盖输入校验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 func TestCreateValidation (t *testing.T) { mux := setupTestServer() tests := []struct { name string body string wantStatus int }{ {"empty title" , `{"title":""}` , http.StatusBadRequest}, {"whitespace title" , `{"title":" "}` , http.StatusBadRequest}, {"invalid JSON" , `not json` , http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func (t *testing.T) { req := httptest.NewRequest("POST" , "/todos" , bytes.NewBufferString(tt.body)) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Errorf("status = %d, want %d" , rec.Code, tt.wantStatus) } }) } }
对比 C++:C++ 的 HTTP 框架测试通常需要启动真实服务或引入 mock HTTP 层。Go 的 httptest 是标准库的一部分,零配置——这和 go test 本身的设计哲学一脉相承。
入口 main.go 只做组装——创建依赖、注入、注册路由、启动服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package mainimport ( "fmt" "log" "net/http" "learn-go/14_todo_api/handler" "learn-go/14_todo_api/store" ) func main () { s := store.NewMemoryStore() h := handler.New(s) mux := http.NewServeMux() h.RegisterRoutes(mux) addr := ":8080" fmt.Printf("TODO API 启动: http://localhost%s\n" , addr) log.Fatal(http.ListenAndServe(addr, mux)) }
整个入口不到 20 行。所有业务逻辑都在各自的包里,main 只做「连线」——这是 Go 项目的常见模式。
运行与测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 $ go test ./14_todo_api/... -race -cover ok learn-go/14_todo_api/handler coverage: 94.2% ok learn-go/14_todo_api/store coverage: 100.0% $ go run ./14_todo_api/ TODO API 启动: http://localhost:8080 $ curl -X POST localhost:8080/todos -d '{"title":"学 Go"}' {"data" :{"id" :"todo-1" ,"title" :"学 Go" ,"done" :false ,"created_at" :"..." }} $ curl localhost:8080/todos {"data" :[{"id" :"todo-1" ,"title" :"学 Go" ,"done" :false ,"created_at" :"..." }]} $ curl -X PUT localhost:8080/todos/todo-1 -d '{"done":true}' {"data" :{"id" :"todo-1" ,"title" :"学 Go" ,"done" :true ,"created_at" :"..." }} $ curl 'localhost:8080/todos?status=done' {"data" :[{"id" :"todo-1" ,"title" :"学 Go" ,"done" :true ,"created_at" :"..." }]} $ curl -X DELETE localhost:8080/todos/todo-1 {"data" :{"deleted" :"todo-1" }} $ curl localhost:8080/todos/todo-1 {"error" :"todo not found" }
小结 这个不到 300 行的项目,覆盖了系列前六篇讨论的几乎所有核心知识点:
知识点
体现
包组织与可见性
4 个包按职责划分,导出/未导出控制 API 边界
接口
Store 接口解耦 handler 和存储实现
泛型
Response[T] 统一 API 响应格式
JSON struct tag
请求解析和响应序列化
HTTP 服务
net/http 路由、handler、状态码
并发安全
sync.RWMutex 保护共享状态
错误处理
哨兵错误 + errors.Is 映射为 HTTP 状态码
测试
表驱动测试、子测试、httptest、竞态检测
回头看整个系列,Go 给 C++ 程序员最大的感受可能是「少」——没有头文件、没有构建系统配置、没有模板元编程、没有 RAII、没有异常、没有继承。每一个「没有」背后都是一个刻意的设计选择:用更少的概念覆盖大多数场景,用显式代码替代隐式机制,用工具链内置替代生态碎片化。这些选择不一定都是「更好」的——defer 不如 RAII 优雅,if err != nil 不如异常简洁,接口的隐式实现有时让人困惑——但它们构成了一套高度一致的工程哲学。在这套哲学下,一个刚入职的工程师能在几天内读懂任何 Go 项目的代码结构,这本身就是一种强大的能力。