代码中的规约工程:如何让日常开发从“口头约定”变成“可执行规则”

1 阅读19分钟

代码中的规约工程:如何让日常开发从“口头约定”变成“可执行规则”

在日常开发中,我们经常遇到这样的情况:

产品说:“增加一个订单取消功能。”

开发理解的是:

把订单状态改成已取消。

但真正开发时才发现:

  • 已支付订单能不能取消?
  • 已发货订单如何处理?
  • 重复取消返回成功还是错误?
  • 取消时是否需要释放库存?
  • 优惠券是否需要退回?
  • 消息发送失败是否影响取消结果?
  • 接口超时后,客户端能不能重试?
  • 管理员取消和用户取消是否遵循相同规则?

如果这些内容没有提前明确,开发人员只能边写边猜。使用 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:

帮我把这个功能写出来。

而应该告诉它:

功能的边界是什么;
必须遵守什么规则;
哪些地方不能修改;
成功和失败如何定义;
用什么测试证明结果正确。

代码生成得快,不代表软件开发得好。

真正可靠的开发,是让需求、规约、代码和测试保持一致,让每一个重要规则都不再依赖猜测。