契约匹配详解
跨程序分析的核心问题:本程序改的这个东西,和对方程序用的是不是同一个东西? 六类契约各有自己的对齐规则,下面逐一说清楚。
HTTP 接口
按规范化路径模板匹配。
规范化做了什么
/api/users/{id} → /api/users/{} (变量名丢掉,只留占位)
/api/users/{userId} → /api/users/{} (同上,两个程序变量名不同也能对上)
http://host/api/users/1 → /api/users/1 (去掉协议与主机)
/api/users/?page=2 → /api/users/ (去掉查询串)
/api//users/ → /api/users (折叠重复斜杠、去尾斜杠)
客户端写法差异的处理
客户端很少照抄模板,常见三种写法都要能对上:
| 客户端写法 | 扫描到的路径 | 匹配方式 |
|---|---|---|
getForObject("/api/users/42") |
/api/users/42 |
模板变量填值 |
getForObject("/api/users/" + id) |
/api/users |
前缀匹配(拼接截断) |
Feign 接口 @GetMapping("/api/users/{id}") |
/api/users/{} |
直接相等 |
第三种情况(拼接)最容易漏。"/api/users/" + id 只能静态扫出字面量部分 /api/users,
所以匹配时还要判断”这个前缀是否正好是某个模板的前缀且剩余部分只有一个变量”。
支持哪些消费方写法
- Spring MVC 的
@GetMapping/@PostMapping等(提供方) - Feign 的
@FeignClient(消费方) RestTemplate/WebClient/HttpClient里的 URL 字面量(消费方)- OpenAPI / Swagger 规范文件里的路径
数据库字段
契约的单位是 表.字段(table.column),不是表。
但「表.字段同名」仍然只是候选。一条数据库变更要跨程序成立,还要通过三道准入:同一数据库对象、变更可被观察、类型兼容。完整规则与已知边界见 数据库影响判断。
先确认是同一数据库对象
系统从 spring.datasource.url / spring.datasource.*.url / spring.datasource.hikari.jdbc-url 解析数据库名,从 SQL 的 schema.table 与 DDL 的 schema.table 读取显式 schema。
- 两侧都已知、同层级且不同 → 排除,计入「因数据库不同被排除的候选」;
- 一侧未知 → 保留匹配,但置信度降低。读不到 URL 不是「不同库」的证据;
- H2 mem / HSQLDB / Derby / SQLite 视为未知:进程内数据库不共享数据;
- 数据库名与 schema 名不跨层级比较,避免「URL 指向 order_db」与「SQL 写 order.orders」被误判为不同库。
本程序侧:哪些改动才算数据库契约
| 改动 | 例子 | 契约单位 | 扩散 |
|---|---|---|---|
| 显式删列 | ALTER TABLE orders DROP COLUMN remark |
orders.remark |
读方、写方、映射方 |
快照推断字段消失(无 DROP COLUMN) |
改 CREATE TABLE 的列清单、删掉建表脚本 |
—— | 不扩散,只计数 |
| 表重命名 | ALTER TABLE orders RENAME TO orders_archive |
orders(表级,旧表名) |
读方、写方、映射方 |
| 列重命名 | ALTER TABLE orders RENAME COLUMN remark TO note |
orders.remark(旧列名) |
读方、写方、映射方 |
| 收窄 / 不兼容改类型 | VARCHAR(64) → VARCHAR(32)、INT → VARCHAR |
orders.remark |
读方、写方、映射方 |
| 放宽 / 兼容改类型 | VARCHAR(32) → VARCHAR(64)、INT → BIGINT |
—— | 不扩散,只计数 |
| 加列 | ALTER TABLE orders ADD COLUMN remark |
—— | 不扩散;SELECT * / 无列名 INSERT 记盲区 |
加必填列(NOT NULL 无默认值) |
ADD COLUMN tenant VARCHAR(32) NOT NULL |
orders.tenant |
命中省略该列的完整 INSERT(usage=writer);自动填充列除外 |
一条 ALTER 多个动作 |
ADD a INT, DROP b, MODIFY c BIGINT NOT NULL |
每个动作各自的列 / 表 | 逐动作判定,每个动作一条 DDL_CHANGED |
| 建表 | CREATE TABLE ... |
—— | 不扩散(新表不可能是既有依赖) |
| 删表 | DROP TABLE orders |
orders(表级) |
读方、写方、映射方 |
| 写入字段集合变化 | INSERT INTO orders(...) 的列清单、UPDATE ... SET 的列 |
新增的列 | 只命中读方;停止写入某列不命中(列仍存在) |
| 无列名的整表写入 | INSERT INTO t VALUES (...) |
t(表级) |
只命中读方;动态 SQL 不退回表级 |
| 实体字段新增 | JPA @Column 字段新增 |
—— | 不扩散 |
| 实体字段删除 / Java 类型变化 | JPA 字段删除、Long → long |
对应列 | 只命中映射方(低置信度) |
| 删除迁移脚本 | 删掉 V1__init.sql |
—— | 不扩散(已执行的 migration 不回滚) |
只改本程序读多少列,仍然不构成数据库契约变化。
列删除只认显式迁移动作。 只有迁移脚本里的 ALTER TABLE ... DROP COLUMN(或 RENAME COLUMN 的旧列名)
才算字段删除;「本程序停止写这一列」和「快照里 CREATE TABLE 不再列这一列」都不会让已部署的列消失,
因此不向对方读写方传播,只在「对对方不可观察的变更」中计数并给出示例。
命中条件:对方在哪算用了这个字段
字段级命中看对方程序里有没有这个字段的使用点:
| 对方的使用点 | 关系 | usage |
|---|---|---|
| 对方的 SQL 语句读这个字段 | READS_COLUMN |
reader |
| 对方的 SQL 语句写这个字段 | WRITES_COLUMN |
writer |
| 对方的实体字段映射这个字段 | MAPS_COLUMN |
mapping |
命中条目记 DB_COLUMN,契约写 table.column,置信度 0.85;数据库身份有一侧未知时降到约 0.72。
本程序改的是哪一类,决定命中对方谁:
- 写入字段集合变化 → 只命中读方。 本程序写不写
orders.remark,不影响对方怎么写它自己的值;但对方正在读这一列,读到的内容会变。 - 删列 / 收窄 / 不兼容改类型 → 读方与写方都命中。 列被删掉或改窄,对方读它、写它都会出问题。
- 删表 → 退回表级匹配,命中读写该表的代码,记
DB_TABLE。 - 加列 / 建表 / 删除迁移脚本 / 类型放宽 → 不命中。 加列只对
SELECT *或无列名 INSERT 形成盲区;建表不可能已经是对方的依赖;删除脚本不回滚已执行的 migration;类型放宽不改变已有值。 - 表重命名 → 命中旧表名的表级契约。 旧表名消失,对方读、写、映射旧表的代码都受影响;新表名不可能已经是依赖,不扩散、不计数。
NOT NULL且无默认值的加列 / 可空性收窄 → 命中写方。 完整的 INSERT 省略该列会被数据库拒绝(MySQL 严格模式 / PostgreSQL / Oracle),契约仍是table.column、usage=writer。UPDATE/DELETE不命中;SELECT *与动态 SQL 仍进盲区。列可空、有默认值,或由数据库自动填充(AUTO_INCREMENT/GENERATED/IDENTITY)时不算必填。- 实体字段删除 / Java 类型变化 → 只命中映射方,低置信度。 实体映射不是数据库 schema,删字段不等于删列。
字段盲区:没读到 ≠ 没影响
字段级匹配要求能从 SQL 里解析出具体列名。两种情况解析不出来:
- 对方写的是
SELECT *——列清单由运行时的表结构决定,静态分析拿不到; - 语句形态解析不了,例如动态拼接的 SQL、方言函数、非标准语法、
<include>片段。
这类语句不再命中字段级契约,但会被计数,并在报告的「分析边界与盲区」里标注为字段盲区:
字段盲区 peer-service · ReportMapper.xml:31
SELECT * FROM orders — 列清单无法静态确定,未参与字段级匹配
它同时说明两件事:这条 SQL 确实碰了本程序改的表,但工具判断不了它碰没碰那个字段。把盲区读成「没影响」是最容易犯的错误,所以它出现在盲区清单里,而不是被静默丢掉。
盲区口径是「读写列都没解析出来」或「语句被标记为动态 SQL」。一条已经作为 writer 命中的纯写语句不会重复计入盲区;必填列规则命中的 INSERT 同样不重复计入。SELECT * 与动态 SQL 不命中必填列,仍计入盲区。
一张对照表
| 本程序的改动 | 是否扩散 | 命中对方谁 |
|---|---|---|
| 多查 / 少查一个字段 | 否 | —— |
只改 WHERE / JOIN / 排序 |
否 | —— |
| 写入字段集合新增或消失 | 是 | 读该字段的代码(reader) |
| 表重命名 | 是(表级,旧表名) | 读、写、映射旧表的代码 |
| 列重命名 | 是(旧列名) | 读、写、映射旧列名的代码 |
加必填列(NOT NULL 无默认值) |
是 | 省略该列的完整 INSERT(writer) |
| 实体字段映射新增 | 否 | —— |
| 实体字段映射删除 / Java 类型变化 | 是(低置信度) | 映射方 |
| DDL 加字段 | 否(计数) | 盲区:SELECT * / 无列名 INSERT |
| DDL 删字段 / 收窄 / 不兼容改类型 | 是 | 读方、写方、映射方 |
| DDL 可空性收窄(NULL → NOT NULL) | 是 | 写方(读方不受影响) |
| DDL 默认值变化 | 否(计数) | 盲区:无列名 INSERT |
| DDL 放宽 / 兼容改类型 | 否(计数) | —— |
CREATE TABLE |
否 | —— |
DROP TABLE |
是(表级) | 读写该表的代码 |
| 删除迁移脚本 | 否 | —— |
配置项
按 key 匹配,并且向上追溯父级前缀:
本程序改动 config:app.limit
↓ 候选
config:app.limit ← 精确匹配
config:app ← 父级前缀
↓
对方绑定 @ConfigurationProperties(prefix = "app") 的类 ← 命中
反过来也成立:改 app 前缀下的任意 key,会影响绑定该前缀的类。
@Value 注解在字段上和方法参数上都会被扫描。字段注入是最常见的写法,
只扫方法会漏掉大量消费方。
Maven 依赖
如果对方 pom.xml 声明了对本程序某个模块 artifactId 的依赖,就是编译期契约。
本程序模块 artifactId: common
↓
对方 pom.xml 里有 <dependency><artifactId>common</artifactId>
↓
编译期契约命中
这类命中只报告”存在依赖关系”,不产生具体影响条目——因为依赖本身不是某个符号的使用点, 需要靠其他契约(类型引用、方法调用)才能定位到代码。
消息主题
按规范化标识匹配,覆盖三种中间件:
| 中间件 | 契约标识 |
|---|---|
| Kafka | Topic 名 |
| RabbitMQ | Exchange + Queue + Routing Key |
| RocketMQ | Topic + Tag |
消息 Schema
同一中间件下,消息体的结构指纹一致即视为同一契约:
- 字段名、字段类型、必填性
- 枚举取值集合
- JSON DTO / Avro / Protobuf 文件
Schema 变化特别隐蔽:字段类型从 int 改 long,生产者能跑,消费者也能跑,
但反序列化可能丢精度。这类变更需要靠契约匹配才能发现。
匹配失败怎么办
匹配不到不代表没影响,只代表静态分析找不到连接点。报告里会区分两种情形:
| 情形 | 报告表现 |
|---|---|
| 契约没被改动 | 不出现(共享接口没变,本来就不该报) |
| 契约改了但对方没使用点 | 显示”没有发现相关契约使用点” |
| 对方程序读取失败 | 该 peer 条目标注原因,其他 peer 不受影响 |
下一步:知道怎么匹配了,看 评分模型 了解匹配上之后如何算风险。