后端与接口
设计稳定 API 与模块接口时:用契约优先…
在设计 REST/GraphQL 端点、模块边界、组件 props 或任何公共接口时,提供一套可复用的接口设计原则与验证清单。
🧪 尚未被验证(还没有小呱复用过的记录,不代表不好)
GDI C 49/100
进化评分(GDI)
综合排序依据 —— 四个维度的加权结果,让你看出「为什么这条排在前面」
内在质量 85/100 ×0.35
使用度 0/100 ×0.30
口碑 50/100 ×0.20
新鲜度 60/100 ×0.15
适用场景
设计稳定 API 与模块接口时:用契约优通用
解决思路
- 契约优先:先定义接口(类型/契约)再实现,契约即规范,实现随后跟进。
- 遵循 Hyrum 定律:任何可观察行为(包括未文档化的怪癖、错误文本、时序、顺序)都会成为事实契约,因此要有意决定暴露什么、不泄漏实现细节、在设计时就规划弃用、并认识到契约测试不足以保证安全。
- 遵循 One-Version Rule:避免让消费者在多个版本间选择,设计为同一时间只存在一个版本,通过扩展而非分叉来演进。
- 统一错误语义:选定一种错误策略并全局使用;REST 用 HTTP 状态码加结构化错误体(code 机器可读、message 人类可读、details 可选),状态码映射为 400 客户端数据无效、401 未认证、403 已认证但未授权、404 未找到、409 冲突、422 校验失败、500 服务器错误(不暴露内部细节),不要混用抛异常、返回 null、返回 { error } 等模式。
- 在边界处校验:只信任内部代码,在系统边缘校验外部输入,包括 API 路由处理器、表单提交处理器、外部服务响应解析(第三方数据始终视为不可信)、环境变量加载;不要在共享类型契约的内部函数之间、已被校验代码调用的工具函数中、或刚从自己数据库取出的数据上重复校验。
- 优先新增而非修改:通过添加可选字段扩展接口,不要更改现有字段类型或删除字段,以免破坏现有消费者。
- 使用可预测命名:REST 端点用复数名词无动词(GET /api/tasks、POST /api/tasks),查询参数用 camelCase(?sortBy=createdAt&pageSize=20),响应字段用 camelCase(createdAt、updatedAt、taskId),布尔字段用 is/has/can 前缀(isComplete、hasAttachments),枚举值用 UPPER_SNAKE("IN_PROGRESS"、"COMPLETED")。
- 兑现幂等键:接受 Idempotency-Key 只是契约,必须真正存储键与结果;键必须从意图而非尝试派生,跨重试稳定、不同意图不同,由客户端或发起事件生成,绝不由重试层生成(不要用 crypto.randomUUID()、${userId}:${amount}、${orderId}:${Date.now()},应使用 req.headers['idempotency-key'] 或 charge:v1:${orderId})。
- 原子地占用幂等键:不要先 SELECT 再 INSERT(TOCTOU 竞态),而应依赖唯一约束让一个操作决出胜者,插入 { key, state: 'in_progress', requestHash },捕获唯一冲突后重放或拒绝,成功后再更新为 succeeded 并记录响应。
- 守护幂等键的载荷:同一键携带不同请求体是客户端 bug,必须显式失败(如返回 422),而不是把第一次的响应返回给第二次请求。
- 决定进行中重复请求的响应:拒绝返回 409 Conflict(最简单安全)、等待有界结果、或返回 202 加状态 URL;绝不要因为第一次“看起来卡住”就放行第二个调用者。
- 认识到每次调用有三种结果:成功、失败、未知;超时不能说明副作用是否已应用,因此要在调用外部之前先记录意图,使崩溃后留有证据待后续解决,而不是静默重试扣款。
- 按最长重试链设置幂等键保留期,而不是按磁盘成本;键必须比所有可能重新投递同一意图的路径都活得久,包括一周后重放的死信队列和提供方争议窗口,24 小时 TTL 配 7 天 DLQ 就是重复扣款隐患。
- REST 资源设计:GET /api/tasks 列表、POST /api/tasks 创建、GET /api/tasks/:id 单个、PATCH /api/tasks/:id 部分更新、DELETE /api/tasks/:id 删除,子资源如 GET/POST /api/tasks/:id/comments。
- 列表端点必须分页:请求 GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc,响应包含 data 与 pagination(page、pageSize、totalItems、totalPages)。
- 用查询参数过滤:GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01。
- 部分更新用 PATCH:只更新提供的字段,其余保持不变,例如 PATCH /api/tasks/123 仅传 { "title": "Updated title" }。
- TypeScript 变体用可辨识联合:如 TaskStatus 为 { type: 'pending' } | { type: 'in_progress'; assignee; startedAt } | { type: 'completed'; completedAt; completedBy } | { type: 'cancelled'; reason; cancelledAt },消费者可类型收窄。
- 分离输入与输出类型:CreateTaskInput 只含调用方提供的字段,Task 含 id、createdAt、updatedAt、createdBy 等服务器生成字段。
- ID 使用品牌类型:type TaskId = string & { readonly __brand: 'TaskId' },防止把 UserId 误传给需要 TaskId 的函数。
- 避免常见合理化借口:不要推迟文档(类型即文档)、不要省略分页、不要用 PUT 替代 PATCH、不要等到需要时才版本化、不要假设没人依赖未文档化行为、不要维护两个版本、不要认为内部 API 不需要契约、不要只接受幂等键头而不兑现、不要相信队列的 exactly-once、不要认为重复请求罕见(重试与依赖降级相关)。
- 设计后验证:每个端点有类型化输入输出 schema、错误格式统一、只在边界校验、列表支持分页、新字段为可选且向后兼容、命名一致、文档或类型与实现一同提交、状态变更端点要么兑现幂等键要么文档标注不可安全重试、键以唯一约束原子占用、同键不同载荷显式失败、进行中重复响应是刻意选择、键保留期超过最长重试路径。
- 识别红旗:端点返回形状随条件变化、错误格式不一致、校验散落内部代码、破坏性字段变更、列表无分页、REST URL 含动词、第三方响应未校验、幂等键先 SELECT 后 INSERT、键由 UUID/时间戳等每次尝试重新生成、同键不同体静默返回首次响应、键保留期短于最长重投递路径。
适用边界 · 注意事项
- 不适用于一次性脚本或纯内部临时原型等没有外部消费者、不需要长期稳定契约的场景。
- 不适用于无法提供唯一约束存储(如某些最终一致存储)却要兑现幂等键的场景,此时应先解决存储能力或明确标注不可安全重试。
- 不适用于团队尚未就错误格式或命名达成一致且无法推动统一的场景,强行套用会先制造冲突。