JSON 格式的版本化与 API 演化策略

JsonTool
订阅
充电
|

有一次我在接口里把一个看起来“无伤大雅”的字段从 number 改成了 string,想着前端“肯定能兜底”。结果老版本 App 在关键页面直接白屏。那天之后,我开始很认真地对待“怎么在不影响用户的前提下,给 JSON 和 API 升级”。

这篇就把我自己踩过的坑和后来形成的一套做法写下来。

你到底在“给谁”做版本

  • 接口版本(API Version):对“资源形态 + 协议”的整体版本,比如 /v1/users/v2/users
  • 数据/Schema 版本(Schema Version):对“返回体结构”的版本,比如 user 的字段集合、类型、约束。
  • 事件版本(Event Version):消息/事件流里的版本(Kafka/队列)。它和 API 版本是同辈,不是父子。

三者可以独立演进,但要能相互映射:某个 API 版本对应哪些 Schema 版本、某个事件是用的哪套 Schema。

兼容性铁律(能让你少被夜里喊起床)

  1. 加字段一般是安全的(可选、带默认、客户端可忽略)。
  2. 不要改类型,不要复用语义userId 永远是用户 ID,别哪天借壳成组织 ID)。
  3. 不要删除字段,先“影子期”(双写一段时间,观测稳定后再移除)。
  4. 错误码稳定可机读code 保持稳定,message 给人看随便变)。
  5. 旧客户端要“宽容读取”Tolerant Reader:忽略未知字段、给缺失字段默认值)。

API 版本化的四种常见做法

1)URL Path:/api/v1/...

  • 好处:可见、易路由、易做灰度。
  • 代价:路径一旦多,维护多套路由;容易把资源语义版本搅在一起。
  • 适用:BFF、对外 API 网关、需要多版本长期共存。

2)HTTP Header:Accept-Version: 2X-API-Version: 2

  • 好处:URL 不变,CDN 可基于 Header 缓存,接口文档更干净。
  • 代价:客户端要配合;网关/负载需支持按 Header 路由。
  • 适用:统一网关/反向代理在手的团队。

3)媒体类型版:Accept: application/vnd.company.user+json;version=2

  • 好处:遵循内容协商;对“同一路径多形态”很优雅。
  • 代价:认知门槛稍高;监控、Mock 工具要适配。
  • 适用:API 平台化、强调 HTTP 语义的团队。

4)负载内声明:"schemaVersion": "1.2.0"

  • 好处数据自描述,落地到日志/离线也能知道版本。
  • 代价:路由靠外层(URL/Header),负载版本用于解析/校验/回放。
  • 适用:配合上面任一方式使用,尤其是消息/事件场景。

经验:接口版本用于路由与生命周期管理;Schema 版本用于解析与回放。 两条线并行,比一条线“背两份锅”稳得多。

JSON Schema 的管理与命名

  • 一个资源一个 Schema 命名空间
    https://json.itzhai.com/schema/user/1-2-0$id
  • 语义化版本号MAJOR.MINOR.PATCH
    • PATCH:修文档、描述、示例等非结构改动。
    • MINOR向后兼容的结构改动(加可选字段、放宽枚举)。
    • MAJOR:破坏性改动(删/改类型/改语义)。
  • 与“宽容读取”协作:生产解析时允许 unevaluatedProperties,但写入/事件生产要按 Schema 严格校验,防脏数据外溢。

示例(简化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"$id": "https://json.itzhai.com/schema/user/1-2-0",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "User v1.2.0",
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"mobile": { "type": "string", "pattern": "^\\+?[0-9]{7,15}$" },
"tags": {
"type": "array",
"items": { "type": "string" },
"default": []
},
"schemaVersion": { "const": "1.2.0" }
},
"required": ["id", "name", "schemaVersion"]
}

非破坏性演进的几种套路

  • 新增可选字段:加就完了,客户端读不到用默认值。

  • 扩展枚举:在 Schema 里放宽到“枚举 + 兜底(oneOf + pattern)”,客户端用白名单 + 默认分支。

  • 引入扩展袋:留个 metaext 对象,放灰度字段,方便回滚。

  • 分页升级:从 offset/limitcursor,先双轨返回:

    1
    2
    3
    4
    5
    {
    "data": [ ... ],
    "paging": { "offset": 200, "limit": 20 },
    "pageInfo": { "endCursor": "abc", "hasNextPage": true }
    }

真要“破坏性”怎么办

以把 phone:string 升级为 mobile:object 为例:

第 0 周:准备

  • 新增字段 mobile(对象),老字段 phone 保留。
  • 服务端双写:写请求里若有 phone,补写 mobile;若只有 mobile,也反向补写 phone(确保回滚安全)。
  • 响应体同时返回两者,并在文档标注弃用

第 2~6 周:观测 & 引导

  • 埋点看有多少客户端还在读 phone

  • 新增 Deprecation/Sunset Header(或文档公告):

    1
    2
    3
    Deprecation: true
    Sunset: Wed, 30 Oct 2025 23:59:59 GMT
    Link: </docs/migration/mobile>; rel="deprecation"
  • SDK/示例先改,推动集成方升级。

第 8~12 周:收口

  • v2 路由:仅返回 mobile,彻底移除 phone
  • 老版本继续跑到 Sunset 日期,读仍双读写仍双写,确保过渡。
  • Sunset 到期:停止双写,删除兼容代码。

关键点:双写先于双读,且整个周期里任何时刻都能回滚

错误与变更的“稳定接口”

  • 错误码code 稳定、文档化,message 可变更。

    1
    { "code": "USER_NOT_FOUND", "message": "user not found" }
  • 变更公告:按 API 版本生成 CHANGELOG.md,面向开发者;面向业务方用“迁移指南+截止时间”。

测试与自动化

  • 契约测试:Consumer-Driven Contract(如 Pact),确保“谁在消费,谁有话语权”。
  • Schema 校验:CI 里对样例响应跑 JSON Schema 校验;还原线上真实样本回归一遍。
  • 回放测试:对事件/日志,取生产样本回放到新 Schema,统计不兼容率。

事件与离线的版本化

  • 事件自带 schemaVersion,并把 $id 写进消息头(或体内)。
  • 向后兼容的消费器:新老 Schema 都能解析(Tolerant Reader)。
  • Schema Registry:集中托管版本与兼容关系(哪怕先做成一个“穷版”的静态库 + 校验脚本,也比口口相传强)。

常见误区

  • 这个字段只有我们自己用,改了问题也不大” 。一周后发现某个埋点系统把它当分区键了。
  • 我们回收老版本很快” 。统计一看,海外某市场的 App 三个月都没法强更。
  • 先删再说” 。回滚时才想起:没有影子数据可回填。

一个轻量的实践清单

  • 变更定性:兼容/破坏?对应 MINOR/MAJOR
  • 路由方案:URL/Header/媒体类型?是否需要临时并行版本?
  • Schema:新 $idschemaVersion、示例与校验用例补齐
  • 双写/双读开关:配置化,默认可灰度
  • 监控:老字段读写占比、失败率、回滚预案
  • 公告:Deprecation/Sunset 与迁移指南、截止日期
  • 契约与回放测试:在 CI 里跑、在预发里跑
  • 下线:数据回收、代码清理、文档归档

写到这儿,核心其实就一句话:版本化不是“多条路径共存”,而是“用最小破坏把变化安全地送到对端”。你把兼容性、观测和回滚设计好了,后面加字段、换分页、甚至重构资源,都不至于影响用户。