承接的问题
12.1 给了兼容矩阵,12.2 给了灰度与回滚,但两者都依赖人执行。评审时漏看一个字段类型变更,灰度时又恰好没有覆盖到那个 method,问题就会留到线上。契约测试的目标是把「不能改」变成合并请求上的红灯:只要 proto 的线格式出现破坏性变更,CI 在打包前就失败。
这里要先说清楚测试的边界。descriptor 能检查结构:字段号、类型、标签、枚举值、服务方法、流式标记。它检查不了语义:stock 从「本数」改成「箱数」、错误码从 NOT_FOUND 改成 ABORTED、同一个字段从「含税」改成「不含税」,descriptor 完全看不出来。所以契约测试是两层:结构层用 descriptor 对比,行为层用固定请求/响应样本比对。本节重点写结构层,行为层给出落地方式。
核心机制一:descriptor 是契约的机器可读形式
每个生成的 protobuf Java 类都有一个静态 getDescriptor(),返回 Descriptors.FileDescriptor。它包含 proto 文件里的全部结构信息,而且不依赖网络、不依赖服务启动,单元测试里直接可读。把上一次发布版本的 proto 原样复制到 src/test/proto/previous/library.proto,只改两个 Java 选项,避免生成的类与主线冲突:
syntax = "proto3";
package plume.linkd.v3;
option java_package = "com.plume.linkd.v3.api.previous";
option java_outer_classname = "PreviousLibraryProto";
proto package 仍然是 plume.linkd.v3,这样 descriptor 里的 full name 与当前版本一致,比较时不会因为包名不同而全部判成「删除 + 新增」。Java package 换成 .previous,生成类名换成 PreviousLibraryProto,两个版本就能在同一个测试 JVM 里共存。
当前版本的 outer class 假设来自 library.proto,即 LibraryProto。测试里同时拿到两个 FileDescriptor,逐层遍历比较。比较的方向是旧 -> 新:旧版本里存在的 message、field、enum、service、method,在新版本里必须仍然存在且语义不变;新版本新增的东西不报错。
核心机制二:结构兼容检查
先写检查器。它返回人类可读的违规列表,空列表表示通过。这样 CI 失败时不需要去猜哪条规则挂了,日志里直接给出 field type changed: plume.linkd.v3.Book.stock#4 INT32 -> MESSAGE 这样的定位信息。
package com.plume.linkd.v3.contract;
import com.google.protobuf.Descriptors;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public final class SchemaGuard {
private SchemaGuard() {
}
public static List<String> check(Descriptors.FileDescriptor previous,
Descriptors.FileDescriptor current) {
List<String> out = new ArrayList<>();
Map<String, Descriptors.Descriptor> before = messages(previous);
Map<String, Descriptors.Descriptor> after = messages(current);
for (Map.Entry<String, Descriptors.Descriptor> e : before.entrySet()) {
Descriptors.Descriptor now = after.get(e.getKey());
if (now == null) {
out.add("message removed: " + e.getKey());
continue;
}
checkFields(e.getValue(), now, out);
checkEnums(e.getValue().getEnumTypes(), now.getEnumTypes(), out);
}
checkEnums(previous.getEnumTypes(), current.getEnumTypes(), out);
checkServices(previous, current, out);
return out;
}
private static void checkFields(Descriptors.Descriptor before,
Descriptors.Descriptor after,
List<String> out) {
Map<Integer, Descriptors.FieldDescriptor> current = new HashMap<>();
for (Descriptors.FieldDescriptor f : after.getFields()) {
current.put(f.getNumber(), f);
}
for (Descriptors.FieldDescriptor old : before.getFields()) {
Descriptors.FieldDescriptor now = current.get(old.getNumber());
String where = before.getFullName() + "." + old.getName()
+ "#" + old.getNumber();
if (now == null) {
out.add("field removed: " + where);
continue;
}
if (!now.getName().equals(old.getName())) {
out.add("field number reused: " + where + " -> " + now.getName());
continue;
}
if (now.getType() != old.getType()) {
out.add("field type changed: " + where + " "
+ old.getType() + " -> " + now.getType());
continue;
}
if (now.isRepeated() != old.isRepeated() || now.isRequired() != old.isRequired()) {
out.add("field cardinality changed: " + where + " "
+ cardinality(old) + " -> " + cardinality(now));
}
if (old.getType() == Descriptors.FieldDescriptor.Type.MESSAGE
&& !now.getMessageType().getFullName()
.equals(old.getMessageType().getFullName())) {
out.add("message type changed: " + where);
}
if (old.getType() == Descriptors.FieldDescriptor.Type.ENUM
&& !now.getEnumType().getFullName()
.equals(old.getEnumType().getFullName())) {
out.add("enum type changed: " + where);
}
}
}
private static String cardinality(Descriptors.FieldDescriptor f) {
if (f.isRepeated()) {
return "repeated";
}
if (f.isRequired()) {
return "required";
}
return f.hasPresence() ? "optional" : "singular";
}
private static void checkEnums(List<Descriptors.EnumDescriptor> before,
List<Descriptors.EnumDescriptor> after,
List<String> out) {
Map<String, Descriptors.EnumDescriptor> current = new HashMap<>();
for (Descriptors.EnumDescriptor e : after) {
current.put(e.getFullName(), e);
}
for (Descriptors.EnumDescriptor old : before) {
Descriptors.EnumDescriptor now = current.get(old.getFullName());
if (now == null) {
out.add("enum removed: " + old.getFullName());
continue;
}
Map<Integer, String> values = new HashMap<>();
for (Descriptors.EnumValueDescriptor v : now.getValues()) {
values.put(v.getNumber(), v.getName());
}
for (Descriptors.EnumValueDescriptor v : old.getValues()) {
String name = values.get(v.getNumber());
if (name == null) {
out.add("enum value removed: " + old.getFullName()
+ "." + v.getName() + "=" + v.getNumber());
} else if (!name.equals(v.getName())) {
out.add("enum number reused: " + old.getFullName()
+ "#" + v.getNumber() + " " + v.getName() + " -> " + name);
}
}
}
}
private static void checkServices(Descriptors.FileDescriptor before,
Descriptors.FileDescriptor after,
List<String> out) {
Map<String, Descriptors.ServiceDescriptor> current = new HashMap<>();
for (Descriptors.ServiceDescriptor s : after.getServices()) {
current.put(s.getFullName(), s);
}
for (Descriptors.ServiceDescriptor old : before.getServices()) {
Descriptors.ServiceDescriptor now = current.get(old.getFullName());
if (now == null) {
out.add("service removed: " + old.getFullName());
continue;
}
Map<String, Descriptors.MethodDescriptor> methods = new HashMap<>();
for (Descriptors.MethodDescriptor m : now.getMethods()) {
methods.put(m.getName(), m);
}
for (Descriptors.MethodDescriptor m : old.getMethods()) {
Descriptors.MethodDescriptor n = methods.get(m.getName());
String where = old.getFullName() + "/" + m.getName();
if (n == null) {
out.add("rpc removed: " + where);
continue;
}
if (!n.getInputType().getFullName()
.equals(m.getInputType().getFullName())) {
out.add("rpc input changed: " + where);
}
if (!n.getOutputType().getFullName()
.equals(m.getOutputType().getFullName())) {
out.add("rpc output changed: " + where);
}
if (n.isClientStreaming() != m.isClientStreaming()
|| n.isServerStreaming() != m.isServerStreaming()) {
out.add("rpc streaming changed: " + where);
}
}
}
}
private static Map<String, Descriptors.Descriptor> messages(
Descriptors.FileDescriptor file) {
Map<String, Descriptors.Descriptor> out = new TreeMap<>();
for (Descriptors.Descriptor d : file.getMessageTypes()) {
collect(d, out);
}
return out;
}
private static void collect(Descriptors.Descriptor d,
Map<String, Descriptors.Descriptor> out) {
out.put(d.getFullName(), d);
for (Descriptors.Descriptor nested : d.getNestedTypes()) {
collect(nested, out);
}
}
}
几个判断的顺序不能乱。先看字段号是否还在,再看名字是否被复用,最后才比类型。如果字段号被删掉又被另一个字段占用,current.get(number) 会命中新字段,此时名字不同,必须报「number reused」,而不是继续比类型——否则一条 int32 -> string 的日志会掩盖「删了旧字段又复用号」这个更严重的问题。
Protobuf 4.33 的 FieldDescriptor 已不提供 getLabel(),改用 isRepeated()、isRequired() 和 hasPresence() 判断基数与 presence。isRepeated() 抓不到 optional 关键字带来的 presence 差异,因为普通标量与 optional 标量都不重复;这类变化要靠 hasPresence() 或行为测试补。枚举只比较旧值是否还在、编号是否被换名占用,新增枚举值放行,因为 proto3 的未知枚举值会保留。
核心机制三:golden 快照与 CI 门禁
SchemaGuard 只拦破坏性变更,放行新增字段。新增字段仍然需要人看一眼:字段名是否拼错、类型是否选错、注释是否解释清楚。golden 快照解决「可见性」问题:把当前 descriptor 渲染成稳定文本,和仓库里的快照逐字比较,message 与 service 的任何变化都会让测试失败,评审者必须看 diff;枚举的破坏性变化由 SchemaGuard 单独拦截。
渲染顺序必须稳定:message 与嵌套 message 按 full name 排序,字段按编号排序,method 按名字排序。否则有人在 proto 里调整一下顺序,快照就全红,评审者会习惯性地点「更新快照」,门禁就失效了。
package com.plume.linkd.v3.contract;
import com.google.protobuf.Descriptors;
import java.util.Comparator;
import java.util.Locale;
public final class SchemaSnapshot {
private SchemaSnapshot() {
}
public static String render(Descriptors.FileDescriptor file) {
StringBuilder out = new StringBuilder(4096);
out.append("package ").append(file.getPackage()).append("\n\n");
file.getMessageTypes().stream()
.sorted(Comparator.comparing(Descriptors.Descriptor::getFullName))
.forEach(m -> renderMessage(m, out));
file.getServices().stream()
.sorted(Comparator.comparing(Descriptors.ServiceDescriptor::getFullName))
.forEach(s -> renderService(s, out));
return out.toString();
}
private static String cardinality(Descriptors.FieldDescriptor f) {
if (f.isRepeated()) {
return "repeated";
}
if (f.isRequired()) {
return "required";
}
return f.hasPresence() ? "optional" : "singular";
}
private static void renderMessage(Descriptors.Descriptor m,
StringBuilder out) {
out.append("message ").append(m.getFullName()).append('\n');
m.getFields().stream()
.sorted(Comparator.comparingInt(Descriptors.FieldDescriptor::getNumber))
.forEach(f -> {
out.append(" field ").append(f.getNumber()).append(' ')
.append(f.getName()).append(' ')
.append(f.getType().name().toLowerCase(Locale.ROOT)).append(' ')
.append(cardinality(f));
if (f.getType() == Descriptors.FieldDescriptor.Type.MESSAGE) {
out.append(' ').append(f.getMessageType().getFullName());
}
if (f.getType() == Descriptors.FieldDescriptor.Type.ENUM) {
out.append(' ').append(f.getEnumType().getFullName());
}
out.append('\n');
});
m.getNestedTypes().stream()
.sorted(Comparator.comparing(Descriptors.Descriptor::getFullName))
.forEach(n -> renderMessage(n, out));
out.append('\n');
}
private static void renderService(Descriptors.ServiceDescriptor s,
StringBuilder out) {
out.append("service ").append(s.getFullName()).append('\n');
s.getMethods().stream()
.sorted(Comparator.comparing(Descriptors.MethodDescriptor::getName))
.forEach(m -> out.append(" rpc ").append(m.getName())
.append(" (").append(m.isClientStreaming() ? "stream " : "")
.append(m.getInputType().getFullName()).append(") returns (")
.append(m.isServerStreaming() ? "stream " : "")
.append(m.getOutputType().getFullName()).append(")\n"));
out.append('\n');
}
}
测试类同时跑两层。行为层先用一个固定的 Catalog 假实现,给 GetBook 一个固定 ISBN,断言返回的字段值和错误码;结构层跑 SchemaGuard 与快照:
package com.plume.linkd.v3.contract;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.plume.linkd.v3.api.LibraryProto;
import com.plume.linkd.v3.api.previous.PreviousLibraryProto;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
final class ContractTest {
@Test
void noBreakingStructuralChange() {
List<String> violations = SchemaGuard.check(
PreviousLibraryProto.getDescriptor(), LibraryProto.getDescriptor());
assertTrue(violations.isEmpty(), () -> String.join("\n", violations));
}
@Test
void descriptorSnapshotIsReviewed() throws Exception {
String actual = SchemaSnapshot.render(LibraryProto.getDescriptor());
Path actualFile = Path.of("target/contract/library.snapshot.actual");
Files.createDirectories(actualFile.getParent());
Files.writeString(actualFile, actual, StandardCharsets.UTF_8);
String expected = Files.readString(
Path.of("src/test/resources/contract/library.snapshot"),
StandardCharsets.UTF_8);
assertEquals(expected, actual,
"descriptor changed: review target/contract/library.snapshot.actual");
}
}
CI 里只需要跑测试,不需要额外脚本:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
mvn -q -Dtest=ContractTest test
失败分两种。noBreakingStructuralChange 失败,说明字段号、类型、枚举或 RPC 被破坏性修改,不允许更新快照绕过,必须回到 12.1 的兼容矩阵重做设计。descriptorSnapshotIsReviewed 失败但结构检查通过,说明只是新增字段或注释变化,评审者看 target/contract/library.snapshot.actual 的 diff,确认无误后把它复制回 src/test/resources/contract/library.snapshot,和 proto 改动放在同一个提交里。
快照文件必须进版本库,且只允许在「有 proto 变更」的 PR 里更新。可以加一条 CI 规则:如果快照变了但 src/main/proto 没变,直接失败,防止有人手工改快照来掩盖问题。
在 linkd-v3 里落地
落地顺序分四步,和发布节奏对齐。
第一步,把当前已发布版本的 proto 冻结到 src/test/proto/previous/library.proto。这个文件只读,任何修改都要走单独的「基线升级」提交,并在提交说明里写明上一版对应的服务版本号。基线升级本身也要跑一次 SchemaGuard,确认新旧基线之间没有未记录的破坏性变更。
第二步,把 SchemaGuard、SchemaSnapshot 和 ContractTest 放进 comm-api 模块的测试目录。comm-api 是唯一持有 proto 的模块,契约测试放在这里,comm-server 和 comm-gateway 通过依赖拿到同一份生成类,不会出现「两个模块各自编译一份 proto」的漂移。
第三步,生成初始快照并提交。初始快照的内容就是当前 descriptor 的渲染结果,提交前人工核对一遍字段号和类型,确认它和 12.1 的表格一致。之后每次 proto 变更,快照 diff 就是评审材料的一部分。
第四步,把测试挂到 CI 的 verify 阶段,和单元测试同一个 job。契约测试不需要启动服务、不需要网络、不需要数据库,执行时间在秒级,没有理由放到夜间任务。合并请求页面上,失败信息要直接展示违规列表,而不是让人去翻构建日志。
回滚流程也要写进运行手册。如果破坏性变更已经合并并发布,第一动作是把服务二进制回滚到上一个版本,而不是在线上改 proto。旧二进制不认识新字段时会忽略它们,但如果变更涉及字段类型或语义,旧二进制可能解析出错误值,所以回滚后要检查写入路径的数据是否已被新格式污染。数据污染需要单独的修复脚本,契约测试无法替代数据回滚。
如果破坏性变更已经以 v2 service 的形式上线,回滚走 12.2 的灰度开关:把 CanaryPolicy 换成 OFF,新流量回到 v1,v2 进程保留观察。等确认没有 v2 写入的数据后再下线 v2 服务,并保留 reserved 字段号,避免下一版误复用。
常见错误与边界
错误一:只跑快照测试,不跑结构检查。 快照失败时,最省事的做法是「更新快照」,于是破坏性变更被静默放行。结构检查必须先跑,它失败时不允许更新快照。
错误二:把冻结基线放在主 proto 目录。 两个 library.proto 同时参与编译,生成类冲突,或者后编译的覆盖先编译的。基线必须放在 src/test/proto,并用不同的 java_package 与 java_outer_classname。
错误三:比较时按字段名匹配。 字段名可以改,字段号才是线格式的身份。按名字匹配会把「改名」误判成「删除 + 新增」,也会漏掉「号被复用」。必须先按编号匹配,再比名字。
错误四:快照渲染顺序不稳定。 依赖 getFields() 的源文件顺序,proto 里挪一行就全量 diff,评审者很快就不看了。所有集合都要显式排序。
边界一:descriptor 检查不了语义。 单位、精度、枚举含义、错误码映射、默认值行为,都需要行为契约测试。为每个公开 RPC 准备一组固定请求和期望响应,断言字段值与错误 reason,才能覆盖「结构没变但意思变了」。
边界二:proto3 的 presence 变化藏得深。 给字段加 optional 会改变「未设置」和「设置为默认值」的区分,但 isRepeated() 与 isRequired() 都看不出差别,要单独比较 hasPresence()。涉及 presence 的变更要单独评审,并在行为测试里覆盖「不传」与「传 0」两种情况。
边界三:跨仓库的契约测试需要消费者驱动。 本节只覆盖服务端自己的新旧版本。如果 libraryd 或其他团队直接依赖这份 proto,应该由消费者提供他们实际使用的字段与调用样本,服务端用这些样本跑测试;只靠服务端自测,容易漏掉「我们以为没人用」的字段。
边界四:快照不是文档。 它只保证结构可见,不解释为什么改、兼容期多久、何时下线。字段注释、deprecated 标记、迁移说明仍然要写在 proto 和发布说明里。
小结
- descriptor 是契约的机器可读形式;冻结上一版 proto 到测试目录,比较旧 -> 新,能在编译期拦住字段号、类型、标签、枚举和 RPC 的破坏性变更。结构检查负责「不许改」,golden 快照负责「改了什么要看见」;快照渲染必须排序稳定,且只能在有 proto 变更的 PR 里更新。
- 契约测试放在
comm-api,秒级执行,挂到 CI 的verify阶段;结构检查失败时不允许用更新快照绕过。descriptor 管不了语义,单位、presence、错误码、跨仓库消费者需要行为测试和消费者驱动测试补位;回滚先回二进制,再处理数据。
到这里,版本演进的三件事齐了:12.1 定义什么能改,12.2 控制谁能先用,12.3 在合并前把破坏性变更挡住。下一章转向混沌演练,先建立故障模型,再决定注入什么、爆炸半径多大。
阅读导航:上一节 12.2 灰度路由与流量切分 | 下一节 13.1 故障模型与爆炸半径
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。