Sunset 头 + OpenAPI 差异检查:把 API 废弃变成 CI 里的卡点
版本不只是路径里写个 /v1。真正管用的是把「废弃」变成可执行的承诺。
三个响应头:废弃标记、后继地址、最后期限
网关统一加响应头:
Deprecation: true
Link: ; rel="successor-version"
Sunset: Sat, 31 Dec 2025 23:59:59 GMT
这三个头各自负责一件事:
Deprecation: true告诉调用方这个版本开始废弃;Link头指路新版本,rel="successor-version"表明这是接替者;Sunset给最后期限。
只在文档里写「即将废弃」没有约束力。文档没人读,读了也不一定排期。Sunset 日期是可执行的承诺——到期后网关直接拒绝请求,客户端才有动力升级。这里的关键是「到期后真的拒绝」,如果日期到了还继续放流量,这个头就退化成了另一种文档。
CI 里的第一道闸:OpenAPI 变更检查
光靠响应头不够,还得保证新版本发布前没把老版本的契约改坏。检查清单靠人肉容易漏,我一般会在 CI 里跑一个 OpenAPI 变更检查脚本,对比两个版本的定义文件:
import sys
import yaml
def load_spec(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def request_schema(spec: dict, path: str, method: str):
# 只取 JSON Body 的 schema,够做第一轮评审辅助
body = (spec.get("paths", {})
.get(path, {})
.get(method, {})
.get("requestBody", {}))
if not body:
return None
content = body.get("content", {}).get("application/json", {})
return content.get("schema")
def find_risks(old: dict, new: dict) -> list[str]:
risks = []
old_paths = set(old.get("paths", {}).keys())
new_paths = set(new.get("paths", {}).keys())
# 删除路径属于主版本级破坏,必须提醒
for p in sorted(old_paths - new_paths):
risks.append(f"path removed: {p}")
methods = ["get", "post", "put", "patch", "delete"]
for p in sorted(old_paths & new_paths):
for m in methods:
old_schema = request_schema(old, p, m)
new_schema = request_schema(new, p, m)
if not old_schema or not new_schema:
continue
old_required = set(old_schema.get("required", []))
new_required = set(new_schema.get("required", []))
for field in sorted(new_required - old_required):
risks.append(f"new required field {field} on {m} {p}")
old_fields = set(old_schema.get("properties", {}).keys())
new_fields = set(new_schema.get("properties", {}).keys())
for field in sorted(old_fields - new_fields):
risks.append(f"field removed {field} on {m} {p}")
return risks
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit("usage: check_api_compat.py old.yaml new.yaml")
risks = find_risks(load_spec(sys.argv[1]), load_spec(sys.argv[2]))
for r in risks:
print("RISK:", r)
运行方式是 python check_api_compat.py old.yaml new.yaml,依赖 PyYAML。
脚本做的是集合差运算,三类判断对应三种最典型的破坏性变更:
- 路径被删:旧调用方直接 404,属于主版本级破坏,必须拦下来;
- 新增必填字段:老客户端不会传这个字段,请求会开始被拒;
- 字段被删:老客户端读了半天读不到值,行为静默变化。
它只做第一道闸,不是兼容性保证
这三类之外,脚本一律看不见:类型收窄(string 变 integer、int32 变 int64)、枚举值变化、响应体改动、默认值语义变化,都不在它的检查范围内。它只取 JSON Body 的 requestBody schema,连 query 参数和 header 都没碰。
所以输出「无风险」不代表真的兼容,只能把确定有问题的变更挡在发布前。它的价值在于位置:让「接口兼容性」从评审会上的口头讨论,变成流水线里会失败的卡点。review 时人会累、会赶进度,脚本不会。
版本化策略小结
- 不要提前版本化。没有真实的破坏性变更需求,就别先切版本;
- 最多维护两个活跃版本,多于两个的维护成本会迅速吃掉收益;
- 弃用需要三件套:公告 + Sunset 头 + 到期后返回
410 Gone。少了最后一步,前面两步都没牙; - 非破坏性变更(加字段、加可选参数、加端点)不发新版本,直接加;
- 破坏性变更(删/改字段、改类型、改 URL 结构、改认证方式)必须发新版本。
版本化形式的选择上,URL 路径版本化(/v1/users)比 Header 版本化更显式、更容易被调用方发现。路径写在日志、curl 命令和浏览器地址栏里都能一眼看到,Header 里的版本号经常被漏掉。