HTTP 这一层已经能正确接收、发送、限流和处理断开,但“报文传输正确”并不代表“业务语义正确”。状态码、方法、资源名称和重试行为如果没有稳定契约,服务端可能成功处理了请求,客户端却只能把它理解成失败;一次普通重试也可能变成第二次写入。
下面这次重复创建事故,就是从协议生命周期走向 API 设计的分界点。
我遇到过一种很让人抓狂的接口事故:移动端点了一次“创建任务”,界面先转了几秒,随后提示保存失败;用户再点一次,列表里却出现了两条一模一样的任务。更麻烦的是,旧版桌面端把已经完成的任务也显示成未完成,而服务端监控几乎全绿——因为接口连业务失败都返回 200。
我最先怀疑的是按钮连点,于是让客户端加了防抖;重复任务仍然出现。接着我怀疑代理层偷偷重试,逐层核对后也没有证据。数据库里确实有两次插入,但这只说明结果,不说明请求为什么来了两次。
转折出现在请求日志里。同一份请求体在很短的间隔内出现两次,第一次已经提交到数据库,只是响应在网络超时前没能回到客户端。SDK 看见超时,自动重放了 POST /createTask;服务端没有识别“这还是同一次创建”,于是又生成了一个 ID。另一个异常也在此时对上了:新接口把 completed 改名为 done,没有版本边界,也没有兼容期。旧客户端读取不到新字段,只能按默认值 false 渲染。
三个症状看起来互不相干:
POST 的重试语义没有设计;问题不在某一行 Express 代码,而在 API 从未被当成一份长期协议。路径、方法、状态码、字段、分页、错误结构和重试规则,全是协议的一部分。服务内部可以换数据库、拆服务,只要协议没变,客户端就不该被迫同步重写。
日常开发所说的 RESTful JSON API,核心是以资源为中心,并兑现 HTTP 的通用语义。严格的 REST 还包含无状态、缓存、分层系统和超媒体等约束;工程里最先要守住的,是资源、方法、状态码和输入输出契约。
排查时,我把现有路由摊在一起:/getTaskList、/createTask、/updateTaskStatus、/deleteTaskById。每个名字单独看都能猜懂,放在一起却没有规律。客户端必须记忆四套动作词,网关也无法仅凭 HTTP 方法判断请求意图。
真正有效的第一步不是改名,而是先找业务名词:
以任务服务为例,可以先得到这张资源表:
任务标题会改,稳定 ID 不会,因此标题不应该承担资源身份。数据库主键、UUID 或稳定业务编号都可以放进 URI;数据库表名则未必应该出现,因为它只是内部实现。

我后来用一句话审查 URI:路径负责定位资源,查询参数负责改变资源的观察方式。
GET /tasks?status=pending&sort=-createdAt&page=2&pageSize=20这里的 /tasks 始终是任务集合;status 做筛选,sort 做排序,page 和 pageSize 控制分页。即使这些参数全被省略,集合依然有意义。相反,GET /tasks/{taskId} 里的 ID 一旦省略,目标就变成了另一种资源。
这个区分还保护了 GET 的只读语义。像 GET /tasks/42?action=delete 这样的接口,浏览器预取、爬虫访问、缓存重放都可能误触发删除。正确表达是 DELETE /tasks/42。
我不会为了“看起来有层级”不断加深路径。/users/{userId}/tasks/{taskId}/comments/{commentId} 把所有关系都写出来,却会让路由复用、权限判断和资源迁移变复杂。
我的判断标准是:父资源是否决定子资源的身份或访问边界。评论离开任务可能没有独立意义,/tasks/{taskId}/comments/{commentId} 很自然;任务已经能由 taskId 唯一定位,详情仍用 /tasks/{taskId}。需要按用户筛选时,再用 /users/{userId}/tasks 或 /tasks?ownerId=...。
有些业务意图确实很难直接翻成 CRUD,例如取消订单。可以把取消行为建模为资源:POST /orders/{orderId}/cancellations 表示创建一次取消记录。如果团队选择 POST /orders/{orderId}:cancel 这种动作形式,也要统一命名、权限、幂等和错误规则,不能让每个接口临时发明一种方言。
REST 不是把数据库表机械地暴露出来。我们设计的是客户端能理解的业务资源,以及它对资源表达的意图。
资源确定后,我才给每种意图选择 HTTP 方法。任务服务的骨架可以写成:
Express 路由只是在代码里兑现这张表:
router.get('/tasks', listTasks)
router.get('/tasks/:taskId', getTask)
router.post('/tasks', createTask)
router.put('/tasks/:taskId', replaceTask)
router.patch('/tasks/:taskId', updateTask)
router.delete('/tasks/:taskId', deleteTask)PUT 表示用完整表述替换目标资源。假设任务有 title、done、priority 三个可写字段,客户端通常要提交完整的新状态;遗漏字段究竟恢复默认值还是被删除,契约必须说清楚。
PATCH 传的是修改说明,只改变指定部分。它没有唯一格式,可以采用 JSON Patch、JSON Merge Patch,也可以使用项目定义的局部更新对象。若接口接受 { "done": true },就要明确“省略字段保持不变”,不能让调用方猜。
有一个很常见的坑:把“计数加一”设计成 PATCH { "views": "+1" },然后默认它幂等。这个补丁每执行一次都会继续改变状态,网络重放就会重复累加。若要求可安全重试,可以发送目标值并做并发控制,或把一次增量操作建模成带唯一操作 ID 的资源。
HTTP 里的“安全”不是没有安全漏洞,而是客户端没有要求服务端改变业务资源。记录访问日志和指标不破坏 GET 的安全语义,因为它们不是客户端想要的业务效果。
“幂等”是同一个请求执行一次或多次,客户端要求的最终效果相同。响应不必完全一致:第一次 DELETE /tasks/42 返回 204,第二次返回 404,只要最终都是“任务 42 不存在”,仍可符合幂等语义。

事故里的防抖只能减少用户连点,挡不住 SDK、代理或用户在超时后的合理重试。对创建订单、扣款、提交工单这类操作,我会定义 Idempotency-Key:
POST /orders
Idempotency-Key: 8f5a1b72-25cb-48b6-9dc7-59c3c7795ab1
Content-Type: application/json服务端不能只在单进程内存里做一次 if。一套可用的实现至少要处理五件事:
409 Conflict,而不是复用旧结果。这才把“客户端可以重试”变成服务端能兑现的承诺。
两个客户端先读取同一任务,再分别写回,后提交的一方可能覆盖先提交者的更新。这不是重复请求,而是并发写冲突。
常见做法是给资源表述返回 ETag。客户端更新时携带 If-Match;服务端发现版本已经变化,就返回 412 Precondition Failed,让客户端重新读取并显式处理冲突。幂等键回答“这是不是同一次操作”,条件请求回答“我修改的是不是刚才读到的版本”,两者不能互相替代。
HTTP 方法不会自动保证语义。删除逻辑挂在 GET 上,GET 就不再安全;PUT 每次都追加记录,PUT 也不再幂等。协议给出承诺,应用代码负责兑现。
当时监控全绿,是因为接口把所有结果都包装成 200:
{
"success": false,
"code": 50001,
"message": "任务标题冲突"
}人能看懂,网关、监控和通用 SDK 却只看到成功。状态码应该先表达通用结果,响应体再补充业务细节。

我选择状态码时会先问:
“任务标题已存在”可以用 409 表达冲突,再在错误体里给出 TASK_TITLE_CONFLICT。客户端不必解析中文句子,监控也不会再把失败算成成功。
修完状态码后,我顺手追了一遍输入流,发现另一个危险假设:团队只校验 req.body,却把 req.params 和 req.query 原样交给数据层。TypeScript 类型只能约束编译时,挡不住真实网络请求。
一条写请求进入业务逻辑前,我会按这个顺序收紧边界:
限制传输层输入。确认写请求使用允许的 Content-Type,设置请求体大小上限,并拒绝无法解析的 JSON。
检查结构和类型。请求体必须是普通对象,必填字段必须存在,done 必须真是布尔值,字符串 "false" 不能被当成 false。
对字段做白名单校验。客户端只能修改公开字段,不能顺手写入 ownerId、role、createdAt 等服务端属性。

直接把请求体交给数据层,会形成批量赋值风险:
// 不建议:客户端能写哪些字段,取决于数据模型碰巧接受什么
await Task.updateOne({ _id: req.params.id }, req.body)今天模型可能只有 title 和 done,明天新增 ownerId 或 isArchived 后,旧接口就可能意外开放新字段。更稳妥的方式是构造新的输入对象:
const allowedFields = ['title', 'done']
const unknownFields = Object.keys(req.body).filter(
(field) => !allowedFields.includes(field)
)
if (unknownFields.length > 0) {
throw new HttpError(422, 'UNKNOWN_FIELD', '请求包含不可写字段')
}
查询参数也一样:不要把 req.query 原样传给 MongoDB 或 ORM,不要允许任意字段排序。公开查询语言应该先被翻译成内部查询对象,并对字段、运算符、数据类型与复杂度设限。输入校验能缩小攻击面,但不能替代鉴权、数据库约束、参数化查询和限流。
事故修完两周后,任务数开始增长。GET /tasks 仍然一次返回全部数据,响应越来越慢。给旧接口突然加默认分页也不安全:旧客户端会悄悄只拿到第一批数据,却以为已经拿全。
所以筛选、排序、分页应该从第一版一起设计:
GET /api/v1/tasks?status=pending&sort=-createdAt&page=2&pageSize=20每个参数都要有可测试的规则:
status 只接受 all、pending、done,默认 all。sort 只接受 createdAt、-createdAt、title、-title,前导 - 表示降序。page 是从 1 开始的正整数,默认 1。pageSize 是正整数,默认 20,最大 50。排序必须稳定。只按 createdAt 排序时,多条记录可能具有相同时间;数据库在不同请求中可能返回不同顺序,导致跨批读取出现重复或遗漏。追加唯一 ID 作为第二排序键,才能固定顺序。排序字段同样要白名单化,否则任意字段排序可能造成慢查询,还会泄露内部结构。

游标应该是 URL 安全、对客户端不透明的字符串,内部通常携带当前批次最后一条记录的排序值和唯一 ID。客户端只负责原样回传,不能依赖编码细节。后续请求还要保持筛选与排序条件一致,否则游标会失去确定含义。
列表响应要同时告诉客户端“拿到了什么”和“如何继续”:
{
"data": [
{ "id": "tsk_42", "title": "补充接口测试", "done": false }
],
"meta": {
"page": 2,
"pageSize": 20,
"total": 67,
"pageCount": 4
},
"links": {
"self": "/api/v1/tasks?status=pending&sort=-createdAt&page=2&pageSize=20"
total 不是所有场景都必须返回。超大数据集上的精确计数可能比取一批数据更昂贵;此时可以只返回 next 或 nextCursor,也可以明确总数是估算值。关键是把行为写进契约,不能在数据增长后悄悄改变。
状态码只能说明错误大类。客户端还需要知道哪个字段有问题、稳定错误码是什么,排障人员则需要一个能关联日志的请求 ID。
我倾向采用 application/problem+json 的问题详情结构,再添加业务扩展字段:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
X-Request-Id: 7a7be254-f3d4-4f42-9bdf-8cc33f9d77ba{
"type": "https://api.example.com/problems/validation-failed",
"title": "输入校验失败",
"status": 422,
"detail": "请修改标出的字段后重试",
"instance": "urn:request:7a7be254-f3d4-4f42-9bdf-8cc33f9d77ba",
"code": "VALIDATION_FAILED",
"errors": [
{ "field": "title", "reason": "长度必须在 1 到 80 个字符之间" }
]
}type 是稳定的问题类型标识;title 是简短类别;detail 描述这次失败;instance 标记本次问题;code 与 errors 是业务扩展。客户端应根据 status、type 或稳定 code 分支,不能解析中文 detail。
生产响应不要泄露堆栈、SQL、集合名、访问令牌或上游完整响应。完整上下文写入服务端日志,用请求 ID 与客户端错误关联。500 对外只返回可操作的通用说明。
我把一次请求拆成五个责任边界:

一个薄 Controller 读起来应该很平淡:
function getTask(req, res) {
const task = taskService.getById(req.params.taskId)
if (!task) {
throw new HttpError(404, 'TASK_NOT_FOUND', '任务不存在')
}
res.status(200).json({ data: task })
}查库、权限和状态迁移不应逐渐堆进这个函数。这样服务层能脱离 HTTP 做单元测试,Controller 测试只验证参数传递与 HTTP 映射。
completed 改成 done 的事故让我重新定义“破坏性变化”:只要旧客户端按原契约工作却得到错误结果,就是破坏兼容,哪怕服务端只改了一行。
路径版本 /api/v1/tasks 容易观察、测试和网关分流。通过 Accept 头协商版本也可行,但缓存、SDK 和网关配置会更复杂。选哪种不是关键,关键是整个项目保持一致。
版本通常只标主版本,不要每次小改都创建 /v1.7。接口说明、契约测试和变更记录负责描述兼容演进。发布破坏性版本时,要让新旧版本并行一段时间,监测旧版本调用量,并明确停止维护与下线时间。
还有一种很隐蔽的破坏:路径仍是 /v1/tasks,字段也没改,却把 page 从 1 起算改成 0 起算,或更换默认排序。版本保护的不只是 URI,还包括默认值、日期格式、空值规则、错误码和分页含义。
我会把兼容方向说得更具体:客户端应容忍响应新增未知字段;服务端则对请求字段保持白名单,避免拼写错误和越权字段被静默接受。这不是让契约含糊,而是让双方明确哪些方向允许扩展。
下面用内存数组实现最小任务 API,重点验证资源路由、JSON 类型、字段白名单、列表查询、薄 Controller、405/404 和统一错误处理。进程重启后数据会恢复,它不适合作为生产存储;Idempotency-Key 与 ETag 也需要持久化、事务或条件写,因此不在这个内存示例里伪装实现。
先创建项目并安装 Express:
mkdir task-api
cd task-api
npm init -y
npm install express新建 app.js:
const express = require('express')
const { randomUUID } = require('node:crypto')
const app = express()
const port = 3000
class HttpError extends Error {
constructor(status, code, detail, errors = undefined) {
运行服务:
node app.js再开一个终端,创建任务并读取列表:
curl -i \
-X POST http://localhost:3000/api/v1/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"复查状态码","done":false}'
curl -i \
'http://localhost:3000/api/v1/tasks?status=pending&sort=-createdAt&page=1&pageSize=10'创建响应应包含 201 Created、Location 和新任务;列表响应应包含 data、meta 与 links。还可以故意把 done 写成字符串、把 pageSize 改成 100,或向任务资源发送 PUT,分别观察 422、400 和带 Allow 头的 405。
最小实现的价值不在代码量,而在于让契约能被真实请求验证。路由表、状态码、校验规则和错误结构一旦可以运行,就能继续接入数据库、鉴权、缓存和契约测试,而不必推翻客户端接口。
经历过那次排查后,我不再从 Controller 有没有写完开始验收,而是先做契约审查:
Location?204 是否真的没有响应体?ETag 和 If-Match?这份清单背后的方法可以压成四步:先找资源,再定义意图;先写正常契约,再写失败契约;把重试和并发当成正常路径;最后用兼容性审查每一个“看起来很小”的变更。
API 的稳定性不是靠“大家小心一点”维持的。资源命名减少记忆成本,HTTP 方法给重试提供依据,状态码让通用基础设施理解结果,错误结构让程序可靠分支,版本边界则给变化划出安全区。把这些决定写成可测试的契约,客户端和服务端才能真正独立演进。
契约一旦铺到几十个路由,新的重复会立刻出现:每个接口都要记录日志、解析身份、验证输入、处理限流,再把错误翻译成统一响应。如果这些规则复制在控制器里,API 表面一致,执行顺序却会逐渐漂移。中间件要解决的正是这种横切重复,但它也把请求何时继续、何时短路的控制权集中到了一条链上。
做规范化和范围检查。标题先去首尾空格再检查长度;分页大小设置默认值与上限;排序字段和方向必须在允许列表中。
最后验证业务规则与当前状态,例如标题是否冲突、状态迁移是否合法、操作者能否修改目标资源。