代码中的规约工程:如何让日常开发从“口头约定”变成“可执行规则”
在日常开发中,我们经常遇到这样的情况:
产品说:“增加一个订单取消功能。”
开发理解的是:
把订单状态改成已取消。
但真正开发时才发现:
- 已支付订单能不能取消?
- 已发货订单如何处理?
- 重复取消返回成功还是错误?
- 取消时是否需要释放库存?
- 优惠券是否需要退回?
- 消息发送失败是否影响取消结果?
- 接口超时后,客户端能不能重试?
- 管理员取消和用户取消是否遵循相同规则?
如果这些内容没有提前明确,开发人员只能边写边猜。使用 Codex、Claude Code 等 AI 编程工具后,这个问题会更加明显:AI 可以很快生成大量代码,但也可能很快把错误理解扩散到整个项目。
因此,代码开发中的规约工程并不是写一份很长的需求文档,而是:
把业务规则、接口约定、数据约束、异常边界和验收条件,转化成可以执行、验证和持续维护的工程规则。
规约工程的目标不是增加流程,而是减少开发中的猜测。
一、规约工程不只是代码规范
很多人提到规约,首先想到的是:
- 变量怎么命名;
- 文件怎么组织;
- 接口路径怎么写;
- 是否允许使用全局变量;
- 单个函数不能超过多少行。
这些属于编码规范,但只是规约工程的一部分。
完整的规约至少应该覆盖以下几个层次:
业务规约
↓
接口规约
↓
数据规约
↓
异常规约
↓
实现规约
↓
测试与验收规约
例如“创建订单”这个功能,不能只规定接口路径是:
POST /api/v1/orders
还需要规定:
- 什么条件下允许创建订单;
- 金额由客户端计算还是服务端计算;
- 重复请求如何处理;
- 库存不足返回什么错误;
- 创建成功以什么为准;
- 数据库成功但消息发送失败怎么办;
- 客户端超时后再次请求会不会产生重复订单。
这些规则才是真正决定代码质量的规约。
二、好的规约应该具备什么特点?
1. 明确
错误写法:
订单创建失败时返回错误。
更好的写法:
商品不存在时返回 PRODUCT_NOT_FOUND。
库存不足时返回 STOCK_INSUFFICIENT。
订单已经创建成功时,重复提交相同 idempotency_key,
必须返回原订单结果,不得再次扣减库存。
“返回错误”只是描述了现象,没有定义具体行为。
2. 可验证
错误写法:
接口性能要好。
更好的写法:
在正常数据库负载下,创建订单接口的 P95 响应时间不得超过 300ms。
第三方服务响应时间超过 2 秒时必须主动超时,
不得无限等待。
无法验证的规约,最后通常只能依赖开发人员的主观判断。
3. 有边界
错误写法:
实现订单取消功能。
更好的写法:
本次支持待支付订单和待发货订单取消。
已发货订单不允许直接取消,需要进入售后流程。
本次不处理退款、逆向物流和部分商品取消。
明确“不做什么”,和明确“要做什么”同样重要。
4. 能落到代码中
规约不能永远停留在 Markdown 文档里。
它应该尽可能转化为:
- 类型定义;
- 枚举;
- 数据库约束;
- OpenAPI 文件;
- JSON Schema;
- 状态机;
- 错误码;
- 单元测试;
- 契约测试;
- CI 检查。
OpenAPI 本身就是一种与编程语言无关的 HTTP API 描述标准,可以让人和程序理解服务提供了哪些接口。JSON Schema 可以声明 JSON 数据的结构、类型和验证约束。Pact 等契约测试工具则用于验证服务消费者和提供者对请求、响应的理解是否一致。
三、日常开发需要完善哪些规约?
1. 业务规则规约
业务规约回答的是:
这个功能在什么条件下允许执行?
以订单取消为例,可以先定义规则:
规则一:只有订单所有者或管理员可以取消订单。
规则二:待支付订单可以直接取消。
规则三:待发货订单取消时必须释放库存。
规则四:已发货订单不能直接取消。
规则五:已取消订单重复取消时返回原取消结果。
规则六:取消成功后必须记录取消人、取消时间和取消原因。
这些规则最好不要散落在多个 Handler 和 Service 方法中。
可以集中定义领域行为:
type OrderStatus string
const (
OrderStatusPendingPayment OrderStatus = "pending_payment"
OrderStatusPendingShipment OrderStatus = "pending_shipment"
OrderStatusShipped OrderStatus = "shipped"
OrderStatusCancelled OrderStatus = "cancelled"
)
func (o *Order) CanCancel() bool {
switch o.Status {
case OrderStatusPendingPayment, OrderStatusPendingShipment:
return true
default:
return false
}
}
更进一步,可以明确状态转换:
var allowedTransitions = map[OrderStatus]map[OrderStatus]bool{
OrderStatusPendingPayment: {
OrderStatusCancelled: true,
},
OrderStatusPendingShipment: {
OrderStatusCancelled: true,
OrderStatusShipped: true,
},
}
这样,“什么状态可以转换到什么状态”不再依赖开发人员记忆,而是成为代码中的明确规约。
2. 接口规约
接口规约不能只描述 URL 和请求参数,还要定义完整契约。
一个接口至少应当明确:
请求方法
请求路径
认证方式
权限要求
请求字段
字段约束
成功响应
失败响应
错误码
幂等要求
超时要求
分页规则
兼容性要求
例如:
POST /api/v1/orders/{order_id}/cancel
请求头:
Authorization: Bearer <token>
Idempotency-Key: <uuid>
请求体:
reason:
type: string
minLength: 1
maxLength: 200
成功:
HTTP 200
code: OK
data:
order_id: string
status: cancelled
cancelled_at: datetime
失败:
ORDER_NOT_FOUND
ORDER_STATUS_NOT_ALLOWED
PERMISSION_DENIED
IDEMPOTENCY_CONFLICT
OpenAPI 的价值就在于把接口能力描述成机器可读取的规约,而不是只在群聊或者接口文档中口头约定。
接口规约中最容易遗漏的内容
字段是否允许为空
不要只写:
{
"reason": "string"
}
应该明确:
reason 必填;
去除首尾空格后不能为空;
长度不能超过 200;
不能只包含换行符;
暂不允许上传附件。
时间单位
错误写法:
{
"timeout": 30
}
应该明确:
{
"timeout_seconds": 30
}
金额单位
不要使用不明确的浮点金额:
{
"amount": 19.9
}
可以规定统一使用最小货币单位:
{
"amount": 1990,
"currency": "CNY"
}
空数组和 null
必须提前统一:
没有数据时,列表字段统一返回 [],不得返回 null。
可选对象不存在时返回 null。
字段禁止因为没有值而随机省略,除非接口规约明确允许。
这些看起来很小,却是前后端联调中最常见的争议来源。
3. 数据规约
数据规约负责回答:
数据以什么形式存储,什么状态才是有效数据?
例如用户表中的状态字段,不能只使用没有含义的数字:
Status int
应该通过类型和常量约束:
type UserStatus int8
const (
UserStatusDisabled UserStatus = 0
UserStatusEnabled UserStatus = 1
UserStatusLocked UserStatus = 2
)
数据库层也应该增加必要约束:
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_users_email (email),
CONSTRAINT chk_users_status CHECK (status IN (0, 1, 2))
);
数据规约通常需要明确:
- 主键生成方式;
- 字段长度;
- 是否允许为空;
- 默认值;
- 唯一约束;
- 时间字段时区;
- 金额单位;
- 枚举范围;
- 删除策略;
- 数据保留时间;
- 敏感字段加密方式;
- 数据迁移和回滚方式。
时间规约
项目中应该统一规定:
数据库统一保存 UTC 时间。
接口统一返回 RFC 3339 格式。
前端负责按照用户时区展示。
禁止在业务代码中直接依赖服务器本地时区。
删除规约
“删除数据”也需要明确含义:
普通业务记录默认软删除。
审计记录禁止删除。
临时文件超过七天自动清理。
用户注销后,对个人数据进行匿名化处理,
而不是简单删除所有关联数据。
4. 错误规约
很多项目只有统一响应结构,却没有真正统一错误行为。
例如:
{
"code": 500,
"message": "系统错误"
}
这不能算完整的错误规约。
应该区分:
业务错误
参数错误
认证错误
权限错误
资源不存在
重复请求
依赖服务错误
数据库错误
系统内部错误
可以定义统一错误结构:
type AppError struct {
Code string
Message string
HTTPStatus int
Retryable bool
Cause error
}
例如:
var ErrStockInsufficient = &AppError{
Code: "STOCK_INSUFFICIENT",
Message: "商品库存不足",
HTTPStatus: http.StatusConflict,
Retryable: false,
}
必须定义“成功”和“失败”的边界
以发送报告邮件为例:
情况一:报告记录保存成功,邮件进入消息队列。
结果:接口返回成功。
情况二:报告记录保存成功,消息队列暂时不可用。
结果:数据库事务回滚,接口返回失败。
情况三:消息已进入队列,但邮件服务商发送失败。
结果:原接口仍然成功,由异步任务重试并记录告警。
情况四:邮件发送超过最大重试次数。
结果:任务标记为最终失败,产生告警,允许人工重试。
如果这些边界没有提前定义,开发人员很容易把“调用第三方接口没有报错”直接当成业务成功。
5. 幂等与并发规约
只要涉及以下场景,就应该主动定义幂等规约:
- 创建订单;
- 支付回调;
- 退款;
- 发放奖励;
- 扣减库存;
- 消息消费;
- 定时任务;
- Webhook;
- 用户重复提交表单。
一个完整的幂等规约应该明确:
幂等键由谁生成?
幂等键的作用范围是什么?
幂等记录保留多久?
相同幂等键但请求参数不同怎么办?
第一次请求处理中,第二次请求怎么办?
第一次请求失败后是否允许重试?
返回第一次结果,还是重新执行?
例如:
客户端创建订单时必须提供 Idempotency-Key。
同一用户、同一接口、同一 Idempotency-Key 视为同一次请求。
相同幂等键且请求体一致时,返回第一次处理结果。
相同幂等键但请求体不一致时,返回 IDEMPOTENCY_CONFLICT。
请求处理中收到重复请求时,返回 REQUEST_PROCESSING。
这比简单写一句“使用 Redis 保证幂等”完整得多。
Redis 是实现方案,幂等行为才是规约。
6. 日志和可观测性规约
不要等线上出问题后,才发现日志中只有:
处理失败
日志规约应该规定关键字段:
trace_id
request_id
user_id
tenant_id
order_id
operation
duration_ms
result
error_code
例如:
logger.Error("cancel order failed",
zap.String("trace_id", traceID),
zap.Uint64("user_id", userID),
zap.Uint64("order_id", orderID),
zap.String("error_code", appErr.Code),
zap.Error(appErr.Cause),
)
同时要规定禁止记录:
用户密码
完整 Token
身份证号码
银行卡号
第三方密钥
完整支付凭证
未经脱敏的手机号
可观测性也应该有验收标准:
所有外部 HTTP 调用必须记录耗时和结果。
所有消息消费者必须记录 message_id。
所有订单状态变化必须记录原状态、新状态和操作人。
核心接口必须提供请求量、错误率和延迟指标。
四、如何把规约融入日常开发?
规约工程最容易失败的原因,是把规约当成开发之前的一次性文档。
更有效的做法是把它嵌入每天的开发流程。
开发前:先写最小规约
一个普通功能不需要几十页文档。
开发前至少回答七个问题:
1. 为什么要做?
2. 谁会使用?
3. 什么条件下允许执行?
4. 正常结果是什么?
5. 失败结果是什么?
6. 哪些内容本次不做?
7. 如何证明已经完成?
例如:
# 功能:撤销报告分享链接
## 目标
允许分享链接创建者撤销未过期的分享链接。
## 前置条件
当前用户必须是链接创建者或管理员。
## 成功结果
链接状态更新为 revoked;
记录 revoked_at 和 revoked_by;
删除 Redis 缓存;
后续访问统一返回 SHARE_LINK_REVOKED。
## 失败情况
链接不存在:SHARE_LINK_NOT_FOUND;
无权限:PERMISSION_DENIED;
链接已经撤销:返回原撤销结果。
## 不在本次范围
不删除历史访问记录;
不向历史访问者发送通知。
## 验收条件
撤销后立即无法访问;
重复撤销不会产生错误数据;
无权限用户无法撤销;
数据库更新失败时不得删除缓存。
写到这个程度,开发方向通常已经比较清晰。
开发中:让规约进入代码
规约要尽量转化为代码中的强约束。
使用类型代替注释
不要这样写:
// status: 1 启用,2 禁用
Status int
应该这样写:
type AccountStatus string
const (
AccountStatusEnabled AccountStatus = "enabled"
AccountStatusDisabled AccountStatus = "disabled"
)
使用验证器代替人工约定
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8,max=64"`
Name string `json:"name" binding:"required,min=1,max=50"`
}
对于更复杂的数据交换,可以通过 JSON Schema 描述数据结构、类型和验证条件,使数据预期更加明确。
使用数据库约束守住底线
不能只依赖业务代码检查唯一性:
UNIQUE KEY uk_tenant_user_email (tenant_id, email)
因为并发请求可能同时通过应用层检查。
使用测试表达业务规则
func TestOrder_Cancel(t *testing.T) {
tests := []struct {
name string
status OrderStatus
wantError bool
}{
{
name: "pending payment order can be cancelled",
status: OrderStatusPendingPayment,
wantError: false,
},
{
name: "shipped order cannot be cancelled",
status: OrderStatusShipped,
wantError: true,
},
{
name: "cancelled order is idempotent",
status: OrderStatusCancelled,
wantError: false,
},
}
// 执行测试
}
测试名称本身就应该能够说明业务规约。
开发后:按照规约验收,而不是只看代码能不能跑
代码完成后,可以按照下面的顺序审查。
需求一致性
实现是否符合原始业务目标?
有没有遗漏异常场景?
有没有实现本次范围之外的功能?
接口一致性
请求和响应是否符合 OpenAPI?
字段是否存在类型、长度和空值约束?
错误码和 HTTP 状态码是否统一?
数据一致性
数据库约束是否完整?
并发写入会不会产生重复数据?
事务边界是否正确?
缓存和数据库如何保持一致?
失败处理
数据库失败会发生什么?
Redis 失败会发生什么?
消息队列失败会发生什么?
第三方接口超时会发生什么?
客户端重试会发生什么?
可观测性
失败后是否能够通过日志定位?
是否可以根据 trace_id 串联调用过程?
核心指标是否可以监控?
异常是否会触发告警?
五、适合日常项目的规约目录
规约不需要全部放在一个巨大文档中。
可以按照职责拆分:
project/
├── specs/
│ ├── constitution.md
│ ├── api/
│ │ ├── order-api.yaml
│ │ └── report-api.yaml
│ ├── features/
│ │ ├── order-cancel.md
│ │ └── report-share.md
│ ├── errors/
│ │ └── error-codes.md
│ ├── data/
│ │ └── data-conventions.md
│ └── decisions/
│ ├── 001-use-outbox-pattern.md
│ └── 002-disable-database-foreign-key.md
├── internal/
├── migrations/
├── tests/
└── openapi.yaml
其中:
constitution.md
保存项目长期规则:
Handler 不直接访问数据库。
业务异常不得直接返回原始 error。
新增接口必须补充 OpenAPI。
消息消费者必须支持重复消费。
所有外部调用必须设置超时。
数据库迁移必须同时提供回滚方案。
features
保存单个功能的业务范围、流程和验收标准。
decisions
保存重要技术决策,也就是常说的 ADR:
为什么选择 Outbox?
为什么不用分布式事务?
为什么订单号使用雪花算法?
为什么不使用数据库外键?
这样以后新成员或者 AI 编程工具修改代码时,就能理解原有设计,而不是重新猜测。
GitHub Spec Kit 的规约驱动流程同样强调从规约出发,再形成实现计划和任务,使实现能够追溯到最初的场景和预期结果。
六、给 Codex 或 Claude Code 的规约模板
在让 AI 开发功能时,可以直接使用下面的结构。
你需要在现有项目中实现【功能名称】。
一、业务目标
说明为什么开发这个功能,以及它解决什么问题。
二、功能范围
必须实现:
1.
2.
3.
本次不实现:
1.
2.
三、业务规则
1.
2.
3.
四、成功标准
什么情况下视为业务成功。
五、失败标准
列出参数错误、权限错误、业务错误、依赖失败和系统错误。
六、接口契约
定义请求方法、路径、认证、请求参数、响应结构和错误码。
七、数据约束
定义字段类型、空值、长度、唯一约束、状态枚举和时间格式。
八、并发与幂等
说明重复请求、并发更新、事务和消息重复消费的处理方式。
九、技术约束
不得修改哪些公共接口;
不得增加哪些依赖;
必须遵守哪些目录和分层;
必须使用哪些现有组件。
十、测试要求
列出正常场景、异常场景、边界场景和并发场景。
十一、执行步骤
先分析现有代码并输出实现计划。
计划中必须列出:
- 需求理解;
- 可能存在的歧义;
- 修改文件;
- 数据库变更;
- 接口变更;
- 风险;
- 测试方案。
在计划确认以前,不得直接修改代码。
重点不是把提示词写得特别长,而是让 AI 清楚知道:
哪些事情必须做;
哪些事情不能做;
什么是成功;
什么是失败;
如何证明实现正确。
七、规约工程最常见的误区
误区一:规约越多越好
一个修改按钮颜色的任务,没有必要写完整架构设计。
规约应该与风险匹配:
低风险修改:说明预期结果和禁止修改范围。
普通业务功能:补充业务规则、接口和验收条件。
核心业务功能:补充状态机、幂等、并发、事务和异常恢复。
资金与数据安全功能:增加审计、安全、容灾和回滚规约。
误区二:只写正常流程
真正拉开代码质量差距的,往往不是正常流程,而是:
重复请求
并发请求
部分成功
依赖超时
消息丢失
缓存失效
数据不一致
任务重复执行
误区三:规约只存在文档中
文档写了“邮箱唯一”,数据库却没有唯一索引,这个规约仍然不可靠。
好的规约应该同时体现在:
文档
类型
代码
数据库
测试
CI
监控
误区四:规约写完后不更新
代码已经改变,规约仍然描述旧行为,比没有规约更加危险。
因此,Pull Request 模板中可以增加:
是否修改了接口契约?
是否新增或修改错误码?
是否修改数据结构?
是否更新对应规约?
是否补充测试?
是否存在不兼容变更?
八、如何逐步完善现有项目?
不需要一次性重构整个项目,可以按照以下顺序逐步完善。
第一阶段:统一最基础的项目规则
先统一:
响应结构
错误码
日志字段
时间格式
金额单位
分页规则
状态枚举
数据库迁移方式
第二阶段:完善核心接口契约
选择最重要的接口补充:
OpenAPI
请求校验
响应结构
错误场景
幂等规则
第三阶段:将核心业务改为状态机
优先处理:
订单
支付
退款
审批
任务
报告
消息发送
第四阶段:用测试固化规约
将线上出现过的问题转化为回归测试:
每修复一个 Bug,就增加一个能够复现该 Bug 的测试。
测试通过,代表这条规约以后不能再被破坏。
第五阶段:接入 CI 检查
在 CI 中自动执行:
代码格式检查
静态检查
单元测试
数据库迁移检查
OpenAPI 兼容性检查
契约测试
安全扫描
最终让违反规约的代码无法合并,而不是依赖代码审查人员每次人工发现。
九、总结
代码中的规约工程,本质上是把团队中的“默认理解”和“口头约定”,转换成明确、可执行、可验证的工程约束。
它不是要求开发人员在编码前写大量文档,而是要求每个功能都尽可能回答清楚:
业务允许什么?
业务禁止什么?
什么情况下成功?
什么情况下失败?
输入和输出是什么?
数据必须满足什么条件?
重复和并发请求怎么处理?
依赖服务失败怎么办?
最终如何验证实现正确?
一个成熟的项目,不应该只依赖某个资深开发人员“知道应该怎么写”。
这些知识应该沉淀到:
- 项目规约;
- 接口契约;
- 类型系统;
- 状态机;
- 数据库约束;
- 错误码;
- 自动化测试;
- CI 检查;
- 架构决策记录。
对于 AI 编程也是如此。
不要只告诉 AI:
帮我把这个功能写出来。
而应该告诉它:
功能的边界是什么;
必须遵守什么规则;
哪些地方不能修改;
成功和失败如何定义;
用什么测试证明结果正确。
代码生成得快,不代表软件开发得好。
真正可靠的开发,是让需求、规约、代码和测试保持一致,让每一个重要规则都不再依赖猜测。