building-backends
这是后端工作的核心技能。在编写任何服务器代码之前请先阅读它——它设定了默认值和不可协商的规则,使每个服务保持一致,然后将具体过程交给专门的技能来处理。如果此处规则与本地仓库约定冲突,以仓库为准;若无冲突,则遵循此规则。
首先,在构建之前先理解
- 在添加代码之前先阅读现有代码。 匹配仓库的框架、错误模型、分层方式、命名风格和测试风格。一个与代码库“对抗”的“正确”模式是错误的模式。引入一种仓库已经做了某件事的第二种方式,是缺陷,而不是改进。
- 明确契约,而非实现。 在选择设计之前,了解输入、输出、错误情况、谁调用它,以及一致性和延迟预期。大多数后端返工都源于快速构建了错误的契约。
- 偏好无聊的方案。 选择经过验证的、可读的、显而易见的解决方案,而不是“聪明”的方案。后端在凌晨 3 点无人值守时运行;要为半睡半醒时仍能理解它的值班工程师优化,而不是为优雅性优化。
架构 — 规则
- 分层,依赖指向内部。
transport (http) → services (use-cases) → domain (entities/rules),并且services → repositories (I/O) → domain。domain 不导入任何外部内容,也不执行任何 I/O。如果依赖箭头指向错误的方向,那么设计就是有问题的。 - Handler 负责编排,Service 负责决策,Repository 负责 I/O。 Handler 解析/验证输入,调用 service,将结果映射为响应——不包含业务逻辑。Repository 是唯一接触数据库、队列或外部 HTTP 的地方。
- 保持 domain 无需基础设施即可测试。 如果核心逻辑没有数据库或网络就无法进行单元测试,那么该逻辑位于错误的层。
- 单一组合根。 在一个地方构建配置、装配依赖关系并启动服务器。其他所有组件都接收其依赖;没有任何东西会向外构建全局单例。
- 设计为无状态。 会话/临时状态存在于共享存储(数据库/缓存)中,而不是进程内存中,这样服务可以水平扩展并在重启后存活。如果你在模块级变量中缓存,请停下来思考。
→ 要使用这些已经装配好的层来搭建骨架,请使用 api-service-scaffold。
API 设计 — 规则
- 显式、版本化的契约。 从第一天起就对 API 进行版本控制(通过 URL 或请求头);永远不要对已发布的契约进行破坏性更改——而是添加新形状并同时弃用旧形状。
- 在边界处验证每个输入,并将其转换为类型化值。核心永远不会看到原始请求体。拒绝无效输入,返回 422 状态码和字段级别的详细信息;永远不要信任客户端。
- 单一错误模型,映射一次。 Domain 代码抛出类型化错误(
NotFoundError、ConflictError、ValidationError、UnauthorizedError...);一个单一的边界映射器将它们转换为状态码 +{ code, message, details? }。永远不要泄露堆栈跟踪或内部消息。对于“未找到”和“不属于你”都返回404——区分它们会泄露存在性信息。 - 一致的形状。 成功的响应、错误的响应都有相同的信封;一致的分页(对于大数据集使用游标而非偏移量);一致的字段命名风格。调用者只需要学习一次你的约定。
- 非安全操作具有幂等性。
PUT/DELETE根据定义是幂等的;通过幂等键使可重试的POST变得安全。网络会重试——重复向客户收费是设计缺陷。 - 正确的状态码。 2xx 表示成功,4xx 表示调用方错误,5xx 表示服务端错误。验证失败返回 422,而不是返回 200 并附带错误体;认证失败返回 401/403,而不是 400。
数据层 — 规则
- 数据库是真相来源;在其中强制实施不变量。 外键、唯一约束、
NOT NULL和检查约束属于模式定义,而不仅仅在应用程序代码中——应用程序代码有 bug 和竞态条件;数据库不会撒谎。 - 事务包裹不变量,并且尽可能短。 所有必须全有或全无的操作放在一个事务中;在持有事务时不要执行慢速操作(外部调用、重型计算)。长事务会持有锁并导致其他所有操作饥饿。
- 预期并发。 两个请求会同时命中同一行。使用乐观锁(版本列)或适当的隔离级别;切勿在没有保护的情况下跨往返执行“读取-修改-写入”。
- 迁移是向后兼容且可逆的。 永远不要在一次部署中改变列的含义;采用 扩展→迁移→收缩 的模式。永远不要在热表上获取阻塞表的锁。每个迁移都附带一个经过测试的回滚脚本。
- 注意 N+1 查询问题。 以集合的形式获取相关数据,而不是在循环中逐个查询。对真实端点的查询计数进行性能分析,而不仅仅是延迟。
- 为你实际运行的查询建立索引——并且只建立这些索引。一个未使用的索引不过是白白增加写入放大。
→ 关于迁移机制(锁、扩展-收缩、回填、回滚),请使用 db-migration-guardian。
安全性 — 基线(不可协商)
- 在每个请求上,先认证后授权,且必须发生在服务端。 永远不要相信客户端关于它是什么或能做什么的声明。在每次资源访问时检查所有权——“我有一个有效的令牌” ≠ “这条记录是我的”。
- 对所有查询使用参数化。 永远不要使用字符串拼接构建 SQL。对于任何可注入的接口(shell、LDAP、模板)也是如此。
- 密钥来自环境/密钥管理器,而不是代码。 源代码、日志或错误消息中不得包含敏感信息。(参见
secrets-management技能。) - 处处遵循最小权限原则——数据库用户、服务凭证、云角色都精确限定在服务所需的范围内。
- 对入站端点和出站调用实施速率限制和超时设置。 无限制的调用或无限制的端点就是潜在的拒绝服务攻击。
- 在边界处进行验证和编码,以防止注入和 SSRF;将所有外部输入,包括来自其他内部服务的输入,都视为不可信的。
并发、任务与故障
- 假设每个外部调用都会失败。 设置超时,添加带有退避和抖动的有限重试,并为不稳定的依赖添加断路器。没有超时的重试,是一个慢速依赖拖垮整个服务的方式。
- 使操作具有幂等性,以便重试(你自己的或调用方的)是安全的。
- 后台工作应放入队列,而不是放在请求路径中。请求负责入队;由 worker 处理;失败的任务进入死信队列等待检查,而不是被静默的
catch {}吞噬。 - 失败时大声报告,优雅地降级。 向遥测系统暴露错误信息;当非关键依赖不可用时,降级处理(提供过期数据、跳过丰富化),而不是对整个请求返回 500。
可观测性是“完成”的一部分,而非事后补救
一个无法在生产环境中调试的服务是不完整的。结构化日志、RED/USE 指标、以及绑定到同一个关联 ID 的追踪,应该在构建过程中添加,而不是在第一次事故之后。
→ 关于具体的仪表化细节以及基数/PII 防护措施,请使用 backend-observability。
测试立场
- 通过公共接口(端点/用例)测试行为,而不是私有内部实现——这样测试才能在重构后存活。
- 金字塔层次,而非蛋筒冰淇淋: 大量的快速单元测试覆盖 domain/service 逻辑,一组聚焦的集成测试覆盖真实的数据库和装配,以及少数几个关键路径的端到端测试。
- 每个 bug 修复都附带一个回归测试,该测试在修复前失败,在修复后通过。
- 确定性测试。 不依赖挂钟时间、网络或顺序;显式地植入数据;测试之间相互隔离。
任何后端任务的执行流程
- 阅读周围的代码;确定契约(输入/输出/错误/调用方/一致性)。
- 将工作放在正确的层;保持 domain 无 I/O。
- 在边界处验证输入;使用统一的错误模型;返回正确的状态码。
- 在数据库和短事务中强制实施不变量;处理并发;计划采用扩展-收缩模式的迁移。
- 应用安全基线(授权 + 所有权检查、参数化查询、密钥管理、超时/限制)。
- 使外部调用和任务具有弹性和幂等性。
- 边开发边进行仪表化;在正确的层添加测试,包括针对任何 bug 的回归测试。
- 对照相关具体技能的“完成定义”进行确认。
完成定义
- 层被尊重(domain 不执行 I/O;依赖指向内部);单一组合根。
- 每个输入都在边界处得到验证;单一错误模型映射一次;使用正确的状态码;契约已版本化。
- 不变量在数据库中得到强制实施;事务简短;并发得到处理;迁移向后兼容且可逆。
- 安全基线已满足:授权 + 所有权检查、参数化查询、密钥外部化、存在超时/速率限制。
- 外部调用/任务具有幂等性和弹性;后台工作已从请求路径中移出。
- 可观测性已建立(基于关联 ID 的日志/指标/追踪);测试位于正确的层级,全部通过,并包含回归覆盖。