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}/tasks | URL 自身携带从属关系,网关与日志可解析 |
| 版本放路径前缀 | /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 | 服务端错误 | 未预期异常 |
三个最容易混的点:
400与422。400是「我读不懂你的请求」——JSON 语法错、Content-Type不对。422是「我读懂了,但不接受」——title为空、due_date早于今天。把业务校验错误回成400会让客户端无法区分「重试改格式」和「改字段值」。401与403。401必须带WWW-Authenticate头,表示「先证明你是谁」;403表示「我知道你是谁,但你不行」。把权限不足回成401会让客户端错误地去刷新令牌。404与403。见下一节,这是多租户系统里的安全决策。
错误体里的 code 也需要一张表,否则前端会陷入「字符串匹配」的泥潭。TaskHub 只定义六个稳定错误码:
code | 典型状态码 | 客户端该怎么做 |
|---|---|---|
validation_failed | 422 | 高亮表单字段,别自动重试 |
malformed_request | 400 | 说明是 bug,修客户端 |
unauthenticated | 401 | 刷新令牌后重试一次 |
forbidden | 403 | 提示无权限,别重试 |
not_found | 404 | 提示资源不存在 |
conflict | 409 | 拉取最新版本,让用户决定 |
注意「别自动重试」出现了三次——这是刻意的。客户端重试策略应该按 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"
两个来自实测的结论:
201必须带Location。上面第一条响应里Location是/api/v1/projects/prj_7/tasks/tsk_01,客户端可以直接拿它做后续请求,不用自己拼 ID。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 分页、过滤、排序与幂等 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。