接口契约的多人维护:谁改谁通知

去年九月,一个订单查询接口改了返回结构,把金额字段从数字改成了字符串。改的人在自己的服务里测过,前端也联调过,唯独漏了一个下游的对账服务。那个服务用这个字段做累加,字符串加数字,跑了一晚上,第二天早上对账差了四万多条。

事后追责没有意义,因为没人违规。改的人走了评审,评审的人看了代码,代码没有问题。问题在于契约这件事,被默认成了"改的人自己知道"。

这几年我们陆续把接口契约从口头约定、文档页面,改成了一份能被机器比对的文件。下面写清楚这件事怎么做,以及为什么"谁改谁通知"必须落到机制上,而不是落到人的自觉上。

一、契约是在什么时候坏的

1.1 三个失守现场

头一个是字段含义悄悄变了。字段名没变,类型没变,但语义从"下单金额"变成了"实付金额",调用方按老含义算,数字全错,而且不报错。

第二个是可选变必填。文档上还写着选填,代码里已经加了校验。调用方不传,接口返回四百,前端看到的是"参数错误",查到半夜才发现是对方改了。

第三个是错误码扩张。新增了一个业务错误码,没有进文档。调用方把所有非零都当成系统异常,触发了一轮误告警。

1.2 文档为什么总落后于代码

因为文档和代码是两份东西,靠人同步。人只要忙起来,同步就会停。停一次没关系,停三个月之后,文档就成了一份历史材料,没人再信它,于是大家改去看代码。

看代码这件事在小范围内可行,跨团队就不可行。你不可能要求每个调用方去读你的实现,尤其当实现是一个几百行的转换逻辑时。

1.3 契约的成本由谁承担

改的人承担的成本很低,改一行字段类型,五分钟。调用方承担的成本很高,可能是一晚上的数据错误,加上第二天的对账。

成本不对等,行为就会失衡。这不是道德问题,是结构问题。要让改动变慎重,只有两个办法:让改的人提前看到影响面,或者让不改的人提前知道要改。

二、把契约变成可比对的文件

2.1 契约放哪里

我们把契约放在代码仓库里,和接口实现同一个仓库、同一个提交。好处是改接口必须改契约,不改就过不了评审;坏处是每个服务都要维护一份,初期有人嫌麻烦。

文件格式用的是描述结构的文本文件,字段名、类型、是否必填、枚举值、示例,都在里面。不追求完备,只要求在"结构"这一层是准确的。文字描述可以简略,结构不能错。

目录上按服务分文件夹,每个文件夹下一份当前契约、一份历史目录。历史不删,只追加,因为比对需要有基线。有人问过为什么不放在共享文档里,原因很直接:共享文档没有版本号,也没有提交记录,改了什么、谁改的,查不到。

2.2 版本号怎么定

版本号分两段:主版本和次版本。加了字段、扩了枚举、加了可选参数,走次版本,调用方不用改。删字段、改类型、把可选变必填、收窄枚举,走主版本,必须通知。

主版本不是每次都要升,判断标准只有一个:会不会让现有调用方出错。会,就是主版本。这个标准写进评审清单,评审的人只需要回答一个问题。

2.3 兼容与不兼容的界线

有些改动介于两者之间,容易吵。比如把一个字段从必填改成可选,算兼容还是不算?我们的判定是兼容,因为老调用方照旧传值,行为不变。

再比如新增一个枚举值,调用方如果写了穷举分支,多一个值会走到默认分支。我们判定为兼容,但要求在所有枚举字段上留默认分支,这一条写进了调用规范。

界线一定要提前定,事后定就会变成逐个争论。定完之后写进文档,谁有异议,在评审上提,不在事故后提。

三、版本比对脚本

3.1 比对什么

比对不需要理解业务,只需要比较结构。四类变化要抓出来:字段被删、类型变了、可选变必填、枚举被收窄。这四类会直接让调用方出错,其余的变化只记录,不阻断。

脚本跑在提交前的钩子里,也跑在每日巡检里。两边用同一份代码,避免出现"本地过了、流水线不过"的情况。

3.2 一段比对脚本

python 复制代码
import json, sys
from pathlib import Path

def flatten(schema, prefix=""):
    """把结构摊平成 {字段路径: 定义},嵌套对象递归展开"""
    out = {}
    for name, spec in (schema.get("properties") or {}).items():
        path = f"{prefix}.{name}" if prefix else name
        out[path] = {
            "type": spec.get("type"),
            "required": name in (schema.get("required") or []),
            "enum": tuple(spec.get("enum") or ()),
        }
        if spec.get("type") == "object":
            out.update(flatten(spec, path))
    return out

def diff(old, new):
    a, b = flatten(old), flatten(new)
    breaking, additive = [], []
    for path, spec in a.items():
        if path not in b:
            breaking.append("字段删除 %s" % path)
            continue
        cur = b[path]
        if spec["type"] != cur["type"]:
            breaking.append("类型变更 %s: %s -> %s" % (path, spec["type"], cur["type"]))
        if cur["required"] and not spec["required"]:
            breaking.append("可选变必填 %s" % path)
        if set(spec["enum"]) - set(cur["enum"]):
            breaking.append("枚举收窄 %s" % path)
        if set(cur["enum"]) - set(spec["enum"]):
            additive.append("枚举扩展 %s" % path)
    for path in b:
        if path not in a:
            additive.append("字段新增 %s" % path)
    return breaking, additive

if __name__ == "__main__":
    old = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
    new = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
    breaking, additive = diff(old, new)
    for line in breaking:
        print("[BREAK]", line)
    for line in additive:
        print("[ADD]", line)
    sys.exit(1 if breaking else 0)

3.3 脚本里几个判断

只摊平对象,不摊平数组。数组里的元素结构变化,我们用整体类型比较,不去逐层展开,因为数组常常是同一结构的重复,展开会产生大量重复路径。

退出码当信号。有破坏性变更返回一,让流水线红掉;没有则返回零。红掉不等于不能合,而是要求提交说明里写清楚影响哪几个调用方,评审的人看到这句才点通过。

只做结构比对,不做语义比对。字段含义从"下单金额"变成"实付金额",脚本抓不到,这类改动只能靠人。所以我们没把脚本当成全部,它只负责挡掉机械性的错误。

3.4 结果怎么给到人

脚本输出的是字段路径,对开发者够用,对业务方不够。所以我们在流水线里加了一步:把受影响的调用方列出来。

调用方清单来自接口注册中心,谁订阅了这个接口,清单上就有谁。破坏性变更被检出的同时,页面会给出这串名字和对应的负责人,提交的人当场就知道要通知谁。

四、谁改谁通知的机制

4.1 责任人表

每个接口有一个责任人和一个备份人,写进契约文件头部的元信息里。责任人变更要提交一次改动,走同一条流水线,这样责任关系也有历史。

这张表的价值在于"找得到人"。事故里耗掉的时间,很大一部分不是在修,而是在找谁负责。

4.2 通知的三级

破坏性变更:提交时自动给所有订阅方发通知,同时给责任人发一条待确认,四十八小时没有异议才能合。兼容变更:只在周报里汇总,不打扰人。

介于两者之间的,比如新增必填的可选嵌套字段,我们走"点名通知":只发给实际用到这个字段的调用方,不发给全部订阅方。这样既不漏,也不至于让所有人天天收信。

4.3 并行期与灰度

主版本变更要求提供并行期。老版本保留至少一个月,新版本同步上线,调用方按自己的节奏切。并行期内两边都要有监控,任何一个版本的错误率抬升,都要能立刻回退。

并行期的成本不低,所以我们不鼓励频繁发主版本。判断标准还是那条:会不会让现有调用方出错。为了"结构更漂亮"改主版本,一般会被否掉。

五、一年之后

5.1 数字上的变化

破坏性变更导致的下游故障,从一年七起降到零起。接口相关的问询,从每周十几条降到三四条。契约文件的覆盖率从六成到全部在用的接口。

数字之外的变化更明显:联调时间短了。以前两个团队联调,先花半天对字段,现在直接照契约写,对不上的地方由脚本指出来。

5.2 还没解决的问题

跨公司的接口还是靠人。合作方不一定愿意维护契约文件,我们只能在自己这一侧写一份"对外契约",用它比对自家实现的变更,对方改了我们就不知道。

另一个问题是历史接口。有几十个老接口没有契约,补写的工作量大,而且要找人确认当年的语义。我们现在的做法是"改到才补",不主动全部补齐,因为补出来的东西未必准。

还有一个反复出现的争论:契约应该由谁评审。我们的答案是只评结构变更,不评业务实现。评审的人看三样东西:破坏性变更有没有通知、并行期够不够长、责任人有没有更新。三样都齐就通过,业务逻辑的对错不在这个场合讨论。

常见问题

问:契约文件由谁维护,开发还是架构?

答:由接口的提供方维护,架构只定规则和模板。谁写接口谁写契约,这是同一次提交里的两件事。架构介入的场合只有两个:规则有争议,或者跨团队的主版本变更需要协调时间。

问:契约和单元测试会不会重复?

答:不重复。单元测试验证实现是否符合预期,契约验证对外承诺是否变化。前者在改动时更新,后者在改动时被约束。一个常见的做法是把契约当成测试的输入,用同一份文件生成用例。

问:小团队两三个人,也要维护契约文件吗?

答:要,但可以极简。一个文件、四类字段、一个比对脚本,半天能搭起来。人少的时候契约的价值不在协调,而在记忆:三个月后你自己也会忘记某个字段为什么是可选的。

问:破坏性变更一定要发主版本吗?

答:如果调用方能在你发布之前全部改完,可以不走主版本,但要留下书面确认,确认内容包括发布时间和回退方式。没有这份确认,就按主版本处理。这条规则的目的是让"特殊情况"有痕迹,而不是禁止特殊情况。

相关推荐
细水长流6455 小时前
生产异常的群内闭环:从报警到认领
低代码
窗边看云5 小时前
侄女实操快印系统:非标规格,固化成报价规则
低代码
驰骋工作流8 小时前
一份 BPM 平台能力对标清单:25 个评估维度的横向观察驰骋BPM驰骋低代码低代码工作流引擎
低代码
ebok.12 小时前
低代码平台如何集成 AI:从开发侧到运行侧的技术实践与京微智枢方案
人工智能·低代码·ai·rxjava
窗边看云1 天前
波次拣货的分工:三个人怎么不撞车
低代码
ZKNOW甄知科技1 天前
燕千云迭代版本发布:智能Agent深入业务现场
运维·人工智能·低代码·机器学习·自然语言处理·自动化·aigc
她的男孩1 天前
条件分支两边审批一样,却发成两份待办:Forge Admin 审批设计器的公共节点合并
spring boot·低代码·工作流引擎
驰骋工作流2 天前
低代码平台的评估指标:从「上层建筑 / 基座」两层看驰骋BPM驰骋低代码低代码工作流引擎
低代码
驰骋工作流2 天前
菜单的类型:为什么「菜单」需要分类——结合驰骋 GPN_Menu 的设计驰骋BPM驰骋低代码低代码工作流引擎
低代码