0%

从 C++ 到 Go(七):实战 TODO API

本文是「从 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 model

import "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 是核心实体,CreateRequestUpdateRequest 是客户端发送的请求体。将请求体和实体分开定义是 API 设计的常见做法——客户端不应该控制 ID 和创建时间。

UpdateRequest 的字段是指针类型,这是为了实现「部分更新」:客户端发 {"done": true} 时,Titlenil(不更新),Donenil(更新)。如果用 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 response

import (
"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 store

import (
"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 store

import (
"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 传给 GetUpdate 修改后 Delete 删除,DeleteGet 验证 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 handler

import (
"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 handler

import (
"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")
}

// 用返回的 ID 获取
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 main

import (
"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"}}

# 删除后获取 → 404
$ 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 项目的代码结构,这本身就是一种强大的能力。

俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。