《Go 语言编程实战》4.1 REST 资源建模与状态码

接口一多,最先失控的不是代码而是 URL 和状态码。本节把 TaskHub 的租户/项目/任务三级资源建模成稳定的 URL 层级,梳理每个方法与状态码的对应关系,用 Go 1.22+ ServeMux 的路径参数承接,并解决多租户下 404 与 403 的取舍、ID 前缀与时间格式等表示层约定,最后用 httptest 把状态码固化成可回归的契约。

4.1 REST 资源建模与状态码

前四章我们把 TaskHub 的工程骨架搭了起来:多模块工作区、依赖治理、分层配置。从这一章开始,骨架里要长出真正的接口。第一个要定下来的东西不是代码,而是契约——URL 长什么样、每个动作该回哪个状态码。这两件事一旦定错,后面所有的客户端、文档、网关规则、监控面板都得跟着改。

本节把 TaskHub 推进到:租户 / 项目 / 任务三级资源的 REST 接口定型,URL、方法、状态码、错误体全部落地,并用可回归的测试固化下来。

4.1.1 从单资源到三级资源

卷一里的 TaskAPI 只有一层资源:/tasks。TaskHub 不一样,它是一个多租户协作平台,资源天然是三层:

tenant(租户,付费与隔离的边界)
  └── project(项目,团队协作的容器)
        └── task(任务,最小工作单元)

层级不是装饰。它直接回答了三个工程问题:谁能访问(租户边界即权限边界)、数据怎么隔离(每条查询都带 tenant_id)、URL 怎么表达从属关系(子资源的 URL 里必须出现父资源)。第 5 章会把前两个问题展开,本节只解决第三个。

4.1.2 URL 设计:名词、层级与所有权

TaskHub 的 URL 规则只有四条,但每条都有代价:

规则例子为什么
用名词复数,不用动词/tasks 而不是 /getTask动作由 HTTP 方法表达,URL 只标识资源
子资源挂在父资源下/projects/{projectID}/tasksURL 自身携带从属关系,网关与日志可解析
版本放路径前缀/api/v1/...网关、CDN、客户端都能按前缀分流与灰度
全局唯一 ID 用前缀tsk_01h2、prj_7日志与工单里一眼看出这是哪类资源

反面例子是把动作写进 URL:/tasks/create、/tasks/{id}/approve。前者在方法语义上重复(POST 已经是「创建」),后者是「状态转移」——它该建模成对资源的部分更新(PATCH /tasks/{id} 带 {"status":"done"}),而不是新增一个动词端点。当状态机复杂到需要审批流时,再把它提升为独立资源 POST /tasks/{id}/transitions,仍然不是动词。

TaskHub 第一版定型的 URL 全集如下,共七条:

GET    /api/v1/tenants/{tenantID}                         读租户
GET    /api/v1/tenants/{tenantID}/projects                列项目
POST   /api/v1/tenants/{tenantID}/projects                建项目
GET    /api/v1/projects/{projectID}/tasks                 列任务(分页)
POST   /api/v1/projects/{projectID}/tasks                 建任务
GET    /api/v1/projects/{projectID}/tasks/{taskID}        读任务
PATCH  /api/v1/projects/{projectID}/tasks/{taskID}        改任务
DELETE /api/v1/projects/{projectID}/tasks/{taskID}        删任务

注意 projects 挂在 tenants 下,而 tasks 挂在 projects 下——父资源只在路径里出现一次。为什么 tasks 不写成 /tenants/{tenantID}/projects/{projectID}/tasks?因为项目 ID 已经全局唯一,多带一层租户只会让 URL 更长、缓存键更碎、日志更难读。层级要表达「从属」,但不要求把整条祖先链都写出来。反过来说,如果项目 ID 不唯一(多个租户下都有 prj_1),那就必须带全层级——唯一性决定了 URL 能不能省略祖先。

4.1.3 方法与语义

方法的选择只有五种,别发明第六种:

方法语义幂等安全TaskHub 用法
GET读取,不改变服务端状态是是列表、详情
POST创建子资源 / 非幂等动作否否创建任务、批量导入
PUT全量替换是否替换整份项目配置
PATCH部分更新否否改任务标题、状态
DELETE删除是否删任务、删项目

「幂等」这一列是运维视角的硬指标:网关超时后重试 GET、PUT、DELETE 是安全的,重试 POST 可能造出两条任务——这正是 4.2 节幂等键要解决的问题。

PATCH 的幂等性值得单独说:PATCH {"status":"done"} 重复执行结果相同,看似幂等;但 PATCH {"count": +1} 这类相对更新就不幂等了。所以 TaskHub 规定 PATCH 的字段值一律是绝对值,禁止「自增」「追加」语义。

4.1.4 状态码:少而准

状态码的选择范围比很多人想象的小。TaskHub 只使用下面这些:

码含义触发场景
200成功GET / PATCH 返回资源
201已创建POST 成功,带 Location 头
204成功无内容DELETE 成功,无响应体
400请求格式错JSON 解析失败、路径参数类型不对
401未认证缺少或过期的令牌(第 5 章)
403已认证无权限令牌有效但角色不足
404资源不存在ID 不存在,或不属于当前租户
409状态冲突唯一键重复、幂等键复用、并发版本冲突
422语义校验失败JSON 合法但字段不满足业务规则
429限流超过配额(第 9 章)
500服务端错误未预期异常

三个最容易混的点:

  1. 400 与 422。400 是「我读不懂你的请求」——JSON 语法错、Content-Type 不对。422 是「我读懂了,但不接受」——title 为空、due_date 早于今天。把业务校验错误回成 400 会让客户端无法区分「重试改格式」和「改字段值」。
  2. 401 与 403。401 必须带 WWW-Authenticate 头,表示「先证明你是谁」;403 表示「我知道你是谁,但你不行」。把权限不足回成 401 会让客户端错误地去刷新令牌。
  3. 404 与 403。见下一节,这是多租户系统里的安全决策。

错误体里的 code 也需要一张表,否则前端会陷入「字符串匹配」的泥潭。TaskHub 只定义六个稳定错误码:

code典型状态码客户端该怎么做
validation_failed422高亮表单字段,别自动重试
malformed_request400说明是 bug,修客户端
unauthenticated401刷新令牌后重试一次
forbidden403提示无权限,别重试
not_found404提示资源不存在
conflict409拉取最新版本,让用户决定

注意「别自动重试」出现了三次——这是刻意的。客户端重试策略应该按 code 而不是按状态码分支:只有 429 与网络错误才值得自动退避重试,业务语义错误重试只会放大问题。

4.1.5 Go 1.22+ ServeMux 承接路径参数

从 Go 1.22 起,标准库 ServeMux 原生支持方法前缀与 {name} 路径参数,不再需要第三方路由库:

mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/projects/{projectID}/tasks", handleCreateTask)
mux.HandleFunc("GET /api/v1/projects/{projectID}/tasks/{taskID}", handleGetTask)
mux.HandleFunc("DELETE /api/v1/projects/{projectID}/tasks/{taskID}", handleDeleteTask)

func handleGetTask(w http.ResponseWriter, r *http.Request) {
	projectID := r.PathValue("projectID") // 直接取,不用正则解析
	taskID := r.PathValue("taskID")
	_ = projectID
	_ = taskID
}

模式串里 {projectID} 的匹配值用 r.PathValue("projectID") 取出。有一个细节必须记住:PathValue 返回的是解码后的值,如果 URL 里出现 %2F,它会被还原成 /——所以路径参数里绝不能直接拼 SQL,必须当作不可信输入处理(第 17 章展开)。

状态码不要在每个 handler 里各写各的。把「写 JSON 响应」收敛成一个函数,Content-Type、charset、状态码就只有一处需要维护:

type APIError struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	if v != nil {
		_ = json.NewEncoder(w).Encode(v)
	}
}

func writeError(w http.ResponseWriter, status int, code, msg string) {
	writeJSON(w, status, APIError{Code: code, Message: msg})
}

创建任务时把 Location 一起写好,客户端就不用猜新资源的地址:

func handleCreateTask(w http.ResponseWriter, r *http.Request) {
	projectID := r.PathValue("projectID")
	var in struct {
		Title string `json:"title"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeError(w, http.StatusBadRequest, "malformed_request", "invalid JSON body")
		return
	}
	if strings.TrimSpace(in.Title) == "" {
		writeError(w, http.StatusUnprocessableEntity, "validation_failed", "title is required")
		return
	}
	t := Task{ID: "tsk_01", ProjectID: projectID, Title: in.Title, Status: "todo", CreatedAt: time.Now().UTC()}
	w.Header().Set("Location", "/api/v1/projects/"+projectID+"/tasks/"+t.ID)
	writeJSON(w, http.StatusCreated, t)
}

两个细节:json.Decoder 解码失败时回 400(格式问题),字段校验失败回 422(语义问题);Content-Type 里带 charset=utf-8 不是必须的(JSON 规范默认 UTF-8),但能让一些老客户端少踩坑。

4.1.6 404 还是 403:多租户下的存在性泄露

假设用户 A 属于租户 tnt_1,他请求 /api/v1/tenants/tnt_2/projects/prj_9。这里有两种回法:

  • 回 403:等于告诉 A「prj_9 这个项目确实存在,只是不归你」。
  • 回 404:等于告诉 A「这个路径下什么都没有」。

TaskHub 选择 404。理由是:ID 往往是顺序或可枚举的,回 403 会让攻击者靠状态码差异枚举出别的租户有哪些资源,从而摸清对手的业务规模。安全原则是「不泄露存在性」——只要请求的资源不在当前认证主体可见的范围内,一律 404。

这条规则有个前提:租户 ID 不能完全来自 URL。如果 tenant_id 只从路径取,攻击者把 tnt_2 换进去就能横向越权。正确做法是:tenant_id 取自令牌里的声明,路径里的值只用于校验是否与令牌一致,不一致直接 404。这个「租户 ID 强制注入」的机制在第 5.2 节展开。

4.1.7 表示层约定

URL 和状态码定完,还有一层容易忽略:响应体的形状。TaskHub 的约定如下:

项约定原因
字段命名snake_case与数据库、SQL 列一致,减少心智转换
时间RFC3339 UTC,如 2026-09-25T03:00:00Z时区交给客户端渲染,服务端只存 UTC
枚举小写字符串 todo/doing/done数字枚举不可读,改含义要动客户端
ID字符串 + 类型前缀大整数在 JS 里会丢精度,前缀便于排查
错误体{"code","message"}code 稳定可编程,message 给人看
列表{"items":[...], "next_cursor":"..."}用对象包一层,便于后续加分页元信息

错误体的 code 是面向程序的稳定标识(如 validation_failed),message 是面向人的说明,可以随时改措辞。永远不要让客户端去匹配 message 字符串。

4.1.8 实测:把状态码固化成契约

设计说得再漂亮,也要能被测试锁住。下面这段是真实跑过的 httptest 场景,覆盖了创建、校验失败、查询、删除、重复删除、方法不允许六种路径。实测输出:

POST   /api/v1/projects/prj_7/tasks                   -> 201  Location="/api/v1/projects/prj_7/tasks/tsk_01"  body={"id":"tsk_01","project_id":"prj_7","title":"写卷二第 4 章","status":"todo","created_at":"2026-10-10T02:18:42.043753Z"}
POST   /api/v1/projects/prj_7/tasks                   -> 422  body={"code":"validation_failed","message":"title is required"}
GET    /api/v1/projects/prj_7/tasks/tsk_01            -> 200  body={"id":"tsk_01",...}
GET    /api/v1/projects/prj_7/tasks/tsk_99            -> 404  body={"code":"not_found","message":"task not found"}
DELETE /api/v1/projects/prj_7/tasks/tsk_01            -> 204  body=
DELETE /api/v1/projects/prj_7/tasks/tsk_01            -> 404  body={"code":"not_found","message":"task not found"}
PUT    /api/v1/projects/prj_7/tasks/tsk_01            -> 405  Allow="DELETE, GET, HEAD, PATCH"

两个来自实测的结论:

  1. 201 必须带 Location。上面第一条响应里 Location 是 /api/v1/projects/prj_7/tasks/tsk_01,客户端可以直接拿它做后续请求,不用自己拼 ID。
  2. 405 是标准库自动给的。注册了 GET/DELETE/PATCH 却来了 PUT 时,ServeMux 回 405 并自动填好 Allow: DELETE, GET, HEAD, PATCH——注意 HEAD 是 GET 的隐式伴随方法。你不需要手写这段逻辑,但要知道它的存在,否则会以为是 bug。

把这张表写成表驱动测试,就得到了一份「状态码回归契约」:

cases := []struct {
	name   string
	method string
	path   string
	body   string
	want   int
}{
	{"create ok", "POST", "/api/v1/projects/prj_7/tasks", `{"title":"x"}`, 201},
	{"create empty title", "POST", "/api/v1/projects/prj_7/tasks", `{"title":"  "}`, 422},
	{"get missing", "GET", "/api/v1/projects/prj_7/tasks/tsk_99", "", 404},
	{"method not allowed", "PUT", "/api/v1/projects/prj_7/tasks/tsk_01", "", 405},
}
for _, tc := range cases {
	t.Run(tc.name, func(t *testing.T) {
		var r *http.Request
		if tc.body == "" {
			r = httptest.NewRequest(tc.method, tc.path, nil)
		} else {
			r = httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
		}
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, r)
		if rec.Code != tc.want {
			t.Fatalf("got %d, want %d", rec.Code, tc.want)
		}
	})
}

4.1.9 小结

  • 三级资源(租户 / 项目 / 任务)决定了 URL 的层级,层级本身就是权限与隔离的表达。
  • URL 只放名词,动作交给方法;状态转移建模成 PATCH,不要新增动词端点。
  • 状态码只用那十一个,重点区分 400/422、401/403、404/403。
  • 多租户下「不可见的资源一律 404」,且 tenant_id 必须来自令牌而非纯路径。
  • ServeMux 的 {name} 参数与自动 405 是标准库送的,但 PathValue 是不可信输入。
  • 把状态码写进表驱动测试,契约才算真正冻结。

接口的形状定下来了,但列表接口还只是「返回全部」——真实系统里它必须是可翻页、可过滤、可排序、可重试的。下一节解决这四件事。

阅读导航:上一节:3.3 配置热更新与校验 · 下一节:4.2 分页、过滤、排序与幂等 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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