本节目标:把「什么算破坏性变更」从直觉变成可核对的清单;理解版本化策略的三种做法各自牺牲了什么;掌握 expand-contract 三步法让字段改名不打断旧客户端;学会用弃用头与调用指标安全下线旧版本;并知道消费者驱动契约测试在什么阶段值得引入。
16.3 契约版本演进与兼容
前两节解决的是「契约怎么固化」。但契约一旦固化,就产生了一个新问题:它还能不能改? 你不可能因为前端已经上线,就永远不允许后端重命名字段。真正的工程目标不是「不改契约」,而是让改动在旧客户端仍在运行的前提下生效。
这一节的判据只有一条:改动是否会让一个未同步升级的旧客户端出错。会,就是破坏性变更,必须走版本化流程;不会,就是兼容变更,可以直接发布。
16.3.1 兼容性的定义
先说清楚方向。客户端与服务端之间存在一个部署时间差:服务端可以随时发版,而移动端 App 的用户可能几个月不更新。因此兼容性必须是单向的:
- 向后兼容(Backward):新服务端能服务旧客户端。这是必须保证的。
- 向前兼容(Forward):旧服务端能处理新客户端发来的请求。灰度期间发布顺序颠倒时会短暂出现。
Postel 定律给了实践上的一个平衡点:发送时保守,接收时宽容。落到 TypeScript 里就是:服务端输出严格按 schema 裁剪(上一节讲过 .output() 与 Fastify 的 response schema),而解析入参时对未知字段保持容忍,不要用 z.object({...}).strict() 把新增字段变成 400。
// 宽容的入参:多余字段被剥离,而不是报错
const UpdateUser = z.object({
userName: z.string().min(1).optional(),
email: z.string().email().optional(),
})
// 严格模式会在旧客户端多发一个字段时直接 400,灰度期极易踩雷
把两个方向放到同一条时间线上看更清楚:
t0 服务端 v1 + 客户端 v1 —— 基准状态
t1 服务端新增可选响应字段 —— 向后兼容,客户端无需升级
t2 客户端开始发送新字段 —— 向前兼容,旧服务端必须忽略未知字段
t3 客户端全面使用新字段,服务端删旧字段 —— 破坏性变更,必须走版本化流程
真正需要版本化流程的只有 t3 这一步。多数团队之所以版本号爆炸,是因为把 t1、t2 也当成了破坏性变更,结果每改一个字段就开一个新版本。
16.3.2 破坏性变更清单
下面这张表可以直接当作 code review 的检查项。它的价值在于:很多被当作「小改动」的变更其实是破坏性的。
| 变更 | 是否破坏性 | 原因与对策 |
|---|---|---|
| 新增可选响应字段 | 否 | 旧客户端忽略未知字段即可 |
| 新增必填请求字段 | 是 | 旧客户端不会发它;应改为可选并给默认值 |
| 删除响应字段 | 是 | 旧客户端读到 undefined |
| 重命名字段 | 是 | 等价于「删 + 加」,须走 expand-contract |
收窄类型(string → number) | 是 | 旧客户端按字符串解析会失败 |
放宽类型(number → string | number) | 是 | 类型放宽不等于消费者能处理 |
| 枚举新增成员 | 是 | 旧客户端的 switch 没有默认分支就崩 |
| 枚举删除成员 | 是 | 历史数据里仍可能返回该值 |
| 修改字段语义(同类型不同含义) | 是 | 最隐蔽,类型系统完全看不出来 |
| 修改默认值 | 视情况 | 若客户端依赖默认行为,即为破坏性 |
| 修改错误码 / 错误结构 | 是 | 客户端的分支判断会失效 |
| 调整数组顺序 | 视情况 | 若客户端依赖首元素,即为破坏性 |
| 增加分页上限 | 是 | 客户端按 size=100 请求可能被拒 |
| 新增可选请求字段 | 否 | 服务端需有默认值 |
| 提高超时 / 限流阈值 | 否 | 只影响容量,不影响契约 |
其中枚举新增成员最容易被低估。TypeScript 里对一个联合类型做穷尽检查时,新增成员会让客户端的编译失败——这其实是好事,但它要求客户端重新构建并发布。移动端做不到,所以服务端必须假设旧客户端仍会把未知枚举值当成「其他」处理。
type Status = 'active' | 'suspended' | 'banned'
function badge(status: Status): string {
switch (status) {
case 'active': return '正常'
case 'suspended': return '停用'
case 'banned': return '封禁'
}
}
服务端新增 'pending' 后,Web 端的这个函数会编译失败,逼你升级——这正是不加 default 的价值。但对于已经发出去的 App,服务端不能指望它重新编译,因此面向不可控消费者的解析必须写兜底分支:
function badgeSafe(status: string): string {
switch (status) {
case 'active': return '正常'
case 'suspended': return '停用'
case 'banned': return '封禁'
default: return '未知' // 旧客户端遇到新枚举值时的唯一安全出口
}
}
两者并不矛盾:自家前端用穷尽检查(无 default),跨版本边界用兜底分支(有 default)。判断依据是这个消费者是否受你的发布流程控制。
16.3.3 版本化策略对照
一旦确认是破坏性变更,就要决定「新版怎么暴露给消费者」。三种主流做法:
| 策略 | 形态 | 优点 | 代价 |
|---|---|---|---|
| URL 路径版本 | /v2/users | 直观、易缓存、易路由 | URL 语义被污染;版本与资源耦合 |
| Header 版本 | Accept-Version: 2 | URL 稳定 | 调试不便、CDN 缓存键需额外配置 |
| 内容协商 | Accept: application/vnd.acme.v2+json | 最符合 HTTP 语义 | 工具链支持差、上手成本高 |
| 字段级演进 | 无版本号,只做兼容变更 | 消费者无感 | 要求极严格的兼容纪律 |
对绝大多数团队,务实的选择是:优先字段级演进,实在不行再用 URL 路径版本。Header 与内容协商更适合有专门 API 网关的团队;相关权衡在 API 版本化策略 与 API 契约治理 里有更展开的讨论。
如果选了 URL 路径版本,正确的做法是让两个版本共享 context、中间件与数据访问层,只在 router 层面分叉,而不是 fork 整个服务:
import { appRouterV1, appRouterV2 } from './routers'
await app.register(fastifyTRPCPlugin, {
prefix: '/v1/trpc',
trpcOptions: { router: appRouterV1, createContext },
})
await app.register(fastifyTRPCPlugin, {
prefix: '/v2/trpc',
trpcOptions: { router: appRouterV2, createContext },
})
这样 v2 的差异被压缩在少数几个 procedure 里,日志、鉴权、限流、错误格式全部自动一致。反面做法是复制一份 server-v2/ 目录——两次版本之后,任何一次安全修复都要改三处,几乎必然漏掉一处。
一个重要提醒:版本号不是越多越好。维护 v1/v2/v3 三条并行分支意味着三倍的测试、文档与缺陷修复成本。经验法则是「线上活跃版本不超过两个」,并且每次开新版本时必须同时定下旧版本的下线时间。
16.3.4 expand-contract:三步无痛演进
破坏性变更之所以破坏,是因为它在同一时刻改变了新旧两端看到的东西。expand-contract 的思路是把它拆成三次兼容的发布:
- Expand(扩展):同时提供新旧两种形态,旧的继续工作。
- Migrate(迁移):推动消费者切换到新形态,并观测旧形态的调用量。
- Contract(收缩):确认旧形态调用量归零后,删除它。
以「userName 改名为 username」为例。第一步,响应里两个字段都返回:
// v1 服务端:expand 阶段
user: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const row = await ctx.db.user.findUniqueOrThrow({ where: { id: input.id } })
return {
id: row.id,
userName: row.name, // 旧字段,标记弃用
username: row.name, // 新字段,与旧字段同源
email: row.email,
}
}),
})
第二步,用类型把「弃用」显式表达出来,让自家前端在编译期就能看到迁移提醒:
/** @deprecated 请改用 username,v2 起将移除 */
userName: string
username: string
带 @deprecated 的字段在编辑器中会显示删除线,配合 1.3 代码规范与提交门禁(ESLint / Biome / husky)
里的 deprecation/deprecation 规则可以变成 CI 警告。这一步解决的是「自家前端」,外部消费者则靠文档与通知。
第三步才是收缩。收缩前必须有一份数据支撑,而不是靠感觉:
// 统计仍在使用旧字段的客户端
ctx.log.info({ reqId: ctx.reqId, apiVersion: req.headers['accept-version'] ?? 'v1' })
把 apiVersion 做成指标(见 17.2 指标与告警
),就能在仪表盘上看到「v1 调用量在过去 30 天为 0」,这时删除 userName 才是安全的。
数据库侧同样遵循这三步,只是换成了「加列 → 双写回填 → 删列」,细节可参考 7.3 迁移、事务与连接池 。应用层与数据库层的演进节奏应当一致,否则会出现「代码已删旧字段、库里还是旧列名」的错位。
16.3.5 用判别联合表达多版本
当新旧版本的差异足够大、无法靠「双写字段」抹平时,可以在类型层面把版本显式建模成判别联合,再由一个映射器负责转换:
type UserV1 = { version: 'v1'; userName: string; email: string }
type UserV2 = { version: 'v2'; username: string; email: string; avatarUrl?: string }
type UserWire = UserV1 | UserV2
// 内部统一使用最新形态
type User = UserV2
function toInternal(wire: UserWire): User {
switch (wire.version) {
case 'v1':
return { version: 'v2', username: wire.userName, email: wire.email }
case 'v2':
return wire
}
}
switch 没有 default 分支,因此将来新增 v3 时,这个函数会编译失败——编译器替你找到了所有需要更新的转换点。这是判别联合在契约演进里最有价值的用法,与 10.1 WebSocket 消息协议判别联合
中处理多版本消息协议的手法完全一致。
反过来说,不要用可选字段堆叠来模拟版本:
// 反模式:三个字段互相矛盾,类型无法约束
type UserBad = { userName?: string; username?: string; email: string }
这种形状下,userName 与 username 的合法性取决于一个看不见的上下文,任何消费者都得写运行期判断。判别联合把上下文变成了字段,类型系统才有东西可检查。
16.3.6 弃用与下线
弃用不是一句「这个接口不再维护」,而是一组可观测、有期限的动作。HTTP 层有两个标准头可用:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: <https://api.acme.dev/v2/users>; rel="successor-version"
Deprecation 表示该版本已进入弃用期,Sunset 给出确切的下线时刻,Link 指向替代版本。在 Fastify 里用 onSend 钩子统一注入,避免每个 handler 手写:
app.addHook('onSend', async (req, reply, payload) => {
if (isDeprecated(req.url)) {
reply.header('Deprecation', 'true')
reply.header('Sunset', 'Wed, 31 Dec 2026 23:59:59 GMT')
reply.header('Link', '</v2/users>; rel="successor-version"')
}
return payload
})
下线流程应当写进团队规范,按固定顺序执行:
- 公告下线时间与替代方案,附迁移指南;
- 返回弃用头,并在文档站把该版本标记为 deprecated;
- 观测调用量指标,识别剩余调用方(
User-Agent或 API Key 归属); - 到期前逐一联系仍在使用方,必要时延长窗口;
- 调用量归零后再删除代码,最后清理数据库里的兼容列。
跳过第 3 步是最常见的失误:你以为没人用的接口,往往正是某个报表任务每天凌晨在调。
16.3.7 消费者驱动契约测试
前两节讲的生成与本节讲的演进,都依赖同一个假设:文档与实现一致。要验证这个假设,最有效的手段是消费者驱动契约(CDC)测试:由消费者声明它依赖的字段,服务端在 CI 里验证自己仍能满足这些声明。
Pact 是这一路线的代表工具。它的价值点在于把「契约」从文档变成了可执行的断言:
// consumer.pact.spec.ts —— 消费者侧声明
import { PactV3 } from '@pact-foundation/pact'
const provider = new PactV3({ consumer: 'web', provider: 'acme-api' })
it('GET /users/:id 返回 userName', async () => {
provider
.given('用户 u_1 存在')
.uponReceiving('获取用户')
.withRequest({ method: 'GET', path: '/users/u_1' })
.willRespondWith({
status: 200,
body: { id: 'u_1', userName: 'Ada', email: 'ada@acme.dev' },
})
await provider.executeTest(async (mock) => {
const res = await fetch(`${mock.url}/users/u_1`)
expect((await res.json()).userName).toBe('Ada')
})
})
契约文件会被推到 broker,服务端流水线拉取并回放验证。服务端侧的验证脚本只有几行:
// provider.verify.spec.ts —— 服务端侧回放验证
import { Verifier } from '@pact-foundation/pact'
await new Verifier({
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: process.env.PACT_BROKER_URL,
publishVerificationResult: true,
providerVersion: process.env.GIT_SHA,
}).verifyProvider()
它把 broker 上所有消费者的契约拉到本地逐一回放,任何一条不满足就让服务端 CI 失败。这样「服务端删掉 userName」会在服务端自己的流水线里被拦下,而不是等到线上或等消费者投诉。
CDC 的成本不低(需要 broker、需要维护 given 状态、需要协调两个团队的流水线),因此判断标准是:当消费者与服务端不在同一个仓库、且发布节奏不同步时,才值得引入。同仓库的全栈项目用 4.2 集成测试与 Testcontainers
起一个真实服务做端到端测试更划算。延伸阅读可参考 契约测试
与 API 测试策略
。
16.3.8 三个高频坑
坑一:把「加字段」当成绝对安全。 加字段本身安全,但如果客户端用了 z.object({...}).strict() 解析响应,或用了 exactOptionalPropertyTypes 加结构化校验,新字段会让它直接抛错。对策是响应侧也要宽容,同时把「新增字段」写进变更日志。
坑二:忘记客户端缓存。 客户端可能缓存了旧 schema 生成的类型与 mock 数据,服务端改了字段后本地测试通过、线上失败。这也是上一节强调「生成物进版本库并由 CI 校验」的原因。
坑三:用 v2 掩盖语义变更。 如果 v2 的 /users 只是把 userName 换成了 username,那没问题;但如果同时把「按创建时间排序」改成了「按活跃时间排序」,用户会以为升级只是改个名字。语义变更必须在变更日志里单独、显式地说明,因为它是唯一类型系统完全无法发现的破坏性变更。
小结
本节的核心结论是:契约演进的本质是时间管理,而不是版本管理。
- 判据是「未同步升级的旧客户端会不会出错」,会就是破坏性变更,必须走版本化流程;
- 破坏性变更清单里最易被低估的三项是枚举新增成员、字段语义变更、以及错误码结构变更;
- 版本化策略优先字段级演进,其次 URL 路径版本;线上活跃版本不宜超过两个,且开新版必须同时定下线时间;
- expand-contract 把破坏性变更拆成「扩展 → 迁移 → 收缩」三次兼容发布,应用层与数据库层应同步演进;
- 判别联合(而非可选字段堆叠)是类型层面表达多版本的唯一可靠手段,
switch缺省分支会让新增版本触发编译错误; - 弃用是一组可观测动作:
Deprecation/Sunset/Link头、调用量指标、到期前逐一联系使用方; - CDC 测试只在跨团队、跨仓库、发布节奏不同步时才值得引入,同仓库项目用集成测试更划算。
到这里,第 16 章的三条契约路线已经走完:tRPC 走共享类型、codegen 走中立 schema、版本演进负责改动之后的事。下一章我们离开「接口」,转向「系统可观测性」——当服务真的跑起来之后,怎么知道它是否健康。
阅读导航:上一节:16.2 OpenAPI / GraphQL Codegen · 下一节:17.1 OpenTelemetry 追踪 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。