承接的问题
1.2 定义了错误码,但错误码写在哪、由谁维护、改了怎么通知调用方,还没有回答。同样的问题也适用于字段、metadata 键和默认超时。如果这些内容散落在各个服务的代码里,所谓「接口文档」很快会变成过期快照。
契约先行的做法是:把双方依赖的所有内容收敛到一个可以版本化、可以生成代码、可以自动检查的地方,先改契约,再改实现。这一节不讨论「要不要写文档」,而是给出一个可执行的边界:哪些东西属于契约、哪些改动是兼容的、怎么用代码把边界钉住。
契约的五个面
很多人把契约等同于 .proto。.proto 只覆盖了数据结构和方法签名,另外四个面同样会导致调用失败:
| 面 | 内容 | 载体 | 谁消费 |
|---|---|---|---|
| 数据结构 | 消息、字段号、类型、枚举值 | .proto | 客户端、服务端、网关 |
| 方法 | 服务名、方法名、调用模式 | .proto 的 service | 客户端、服务端 |
| 错误 | Status.Code 与 ErrorInfo.reason | 常量类 + 文档 | 客户端、网关、告警 |
| 上下文 | metadata 键、超时预算、重试语义 | 常量类 + 配置 | 拦截器、网关、运维 |
| 版本 | 包名、x-api-version、灰度标记 | 配置 + 路由规则 | 网关、发布系统 |
只锁 .proto 而不锁后四个面,会出现这些情况:字段没变,但服务端把 BOOK_NOT_FOUND 改成了 NOT_FOUND_ERR,客户端的分支失效;方法没变,但单跳 deadline 从 300 ms 改成 800 ms,总预算被击穿;字段没变,但 x-tenant-id 从必填变成可选,租户隔离出现缺口。
所以「契约先行」的第一条规则是:上面五类内容都必须有唯一的定义处,且改动要走同一个评审。
单一事实来源:comm-api
.proto 放在 comm-api/src/main/proto/library.proto,生成的 Java 代码只存在于构建产物,不提交到仓库,也不手工修改。构建配置固定如下:
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.7.1</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:4.33.2:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:1.76.0:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
几个容易出错的点:
protoc版本必须和protobuf-java运行时一致。protoc 4.33.2生成的代码调用 4.33.2 的运行时 API;运行时低一个版本,可能在启动时抛NoSuchMethodError。protoc-gen-grpc-java的版本必须和grpc-java一致。生成代码里的MethodDescriptor构造方式随版本变化,混用会在编译期或运行期失败。- 生成目录在
target/generated-sources,不要提交,也不要在 IDE 里编辑。改了生成代码,下一次mvn clean就丢,而且会和.proto不一致。 - 如果构建机是 Apple Silicon,
os.detected.classifier是osx-aarch_64;Linux CI 上是linux-x86_64。不要写死一个平台,否则换机器就找不到protoc。
comm-api 里除了 .proto,还放错误码和 metadata 键的常量。它们不是生成的,但同样属于契约:
package com.plume.linkd.v3.api;
public final class Contract {
public static final String SERVICE = "plume.linkd.v3.Library";
public static final String TENANT_HEADER = "x-tenant-id";
public static final String REQUEST_ID_HEADER = "x-request-id";
public static final String API_VERSION_HEADER = "x-api-version";
public static final String IDEMPOTENCY_HEADER = "x-idempotency-key";
public static final String TRACEPARENT_HEADER = "traceparent";
public static final String CANARY_HEADER = "x-canary";
public static final String AUTHORIZATION_HEADER = "authorization";
public static final long CLIENT_DEADLINE_MILLIS = 800;
public static final long LIBRARYD_DEADLINE_MILLIS = 300;
public static final int MAX_INBOUND_MESSAGE_BYTES = 4 * 1024 * 1024;
private Contract() {
}
}
这些常量看起来琐碎,但它们是跨模块编译期检查的抓手。网关引用 Contract.TENANT_HEADER,而不是手写字符串;测试引用 Contract.LIBRARYD_DEADLINE_MILLIS,而不是硬编码 300。改契约时只改一处,编译器会告诉你哪里需要跟着改。
错误原因码同样属于契约,放在 comm-api,由 comm-core 的 RpcError 引用:
package com.plume.linkd.v3.api;
import java.util.Set;
public final class ErrorReasons {
public static final String BOOK_NOT_FOUND = "BOOK_NOT_FOUND";
public static final String STOCK_CONFLICT = "STOCK_CONFLICT";
public static final String RATE_LIMITED = "RATE_LIMITED";
public static final String DEADLINE_EXCEEDED = "DEADLINE_EXCEEDED";
public static final String UPSTREAM_UNAVAILABLE = "UPSTREAM_UNAVAILABLE";
public static final String TENANT_FORBIDDEN = "TENANT_FORBIDDEN";
public static final Set<String> ALL = Set.of(
BOOK_NOT_FOUND, STOCK_CONFLICT, RATE_LIMITED,
DEADLINE_EXCEEDED, UPSTREAM_UNAVAILABLE, TENANT_FORBIDDEN);
private ErrorReasons() {
}
}
comm-api 不依赖 comm-core,所以 RpcError 可以引用 ErrorReasons,反过来不行。这个方向在前言里已经定过,新增常量时不要为了图方便把类放到 comm-core。
兼容与破坏性改动清单
.proto 的兼容性由「线上编码」和「生成代码」两个维度共同决定。下面这张表是评审时的第一道筛子:
| 改动 | 线上兼容 | 源码兼容 | 结论 |
|---|---|---|---|
| 新增字段(新编号) | 是 | 是 | 允许,旧端忽略未知字段 |
| 删除字段 | 是 | 否 | 允许,但必须 reserved 编号和名字 |
| 重命名字段 | 是 | 否 | 允许,线上按编号,源码要同步改 |
| 改字段编号 | 否 | 否 | 禁止,等于换了一个字段 |
int32 → int64 | 是(varint 兼容) | 是 | 有条件允许,负数编码不同,需确认取值范围 |
int32 → sint32 | 否 | 是 | 禁止,zigzag 编码不同 |
string → bytes | 是(UTF-8 校验差异) | 是 | 有条件允许,旧端可能校验 UTF-8 |
| 新增枚举值 | 是 | 是 | 允许,旧端落到 UNKNOWN |
| 删除枚举值 | 是 | 否 | 禁止,旧数据仍可能出现该值 |
改 optional / repeated | 否 | 是 | 禁止,编码与默认值语义都变 |
| 新增方法 | 是 | 是 | 允许,旧客户端不调用即可 |
| 改方法名 | 否 | 否 | 禁止,:path 变了,路由找不到 |
| 改调用模式 | 否 | 否 | 禁止,客户端与服务端的流模型不匹配 |
「线上兼容」是指旧客户端和新服务端、或新客户端和旧服务端能互通;「源码兼容」是指重新生成代码后,现有业务代码还能编译。两者不等价:删除字段线上能过,但业务代码里 getOldField() 会编译失败。
reserved 要同时保留编号和名字:
message Book {
reserved 5, 6;
reserved "publisher", "publish_year";
string isbn = 1;
string title = 2;
string author = 3;
int32 stock = 4;
string language = 7;
}
只写 reserved 5 不写名字,下一个人可能把编号 5 重新用成 language,线上旧数据会被解析成错误字段。只写名字不写编号,编号仍可能被复用。两个都写,才是完整的「这个位置曾经属于谁」。
用代码钉住契约
评审靠人,回归靠代码。comm-api 的测试里放两类断言:结构断言和基线快照。
结构断言直接读生成的描述符,不依赖具体实现:
package com.plume.linkd.v3.api;
import com.google.protobuf.Descriptors;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LibraryContractTest {
@Test
void bookFieldNumbersAreStable() {
Descriptors.Descriptor book = Book.getDescriptor();
Map<String, Integer> numbers = book.getFields().stream()
.collect(Collectors.toMap(Descriptors.FieldDescriptor::getName,
Descriptors.FieldDescriptor::getNumber));
assertEquals(Map.of("isbn", 1, "title", 2, "author", 3, "stock", 4), numbers);
}
@Test
void libraryServiceHasFourMethods() {
Descriptors.ServiceDescriptor service = Book.getDescriptor().getFile()
.findServiceByName("Library");
assertEquals(4, service.getMethods().size());
assertTrue(service.findMethodByName("GetBook").isServerStreaming() == false);
assertTrue(service.findMethodByName("SearchBooks").isServerStreaming());
assertTrue(service.findMethodByName("Chat").isClientStreaming());
assertTrue(service.findMethodByName("Chat").isServerStreaming());
}
@Test
void errorReasonsAreStable() {
assertEquals(java.util.Set.of("BOOK_NOT_FOUND", "STOCK_CONFLICT", "RATE_LIMITED",
"DEADLINE_EXCEEDED", "UPSTREAM_UNAVAILABLE", "TENANT_FORBIDDEN"),
ErrorReasons.ALL);
}
}
Book.getDescriptor() 是生成代码提供的静态方法;getFile() 拿到同一个 .proto 的文件描述符,再按名字查找服务和消息。这样不依赖外层类的名字:java_multiple_files = true 时外层类名由文件名或 java_outer_classname 决定,写死一个名字反而容易在重构后失效。
第三段断言看起来像自说自话,实际作用是防止有人在重构时把常量值改掉。更严格的写法是从 comm-core 的 RpcError 常量表读取,断言它与文档、与网关映射表一致。跨模块的契约测试放在 comm-gateway 的测试里,用真实的错误对象跑一遍映射。
基线快照把「当前契约长什么样」写成可 diff 的文本,提交到仓库。任何字段、方法、枚举的变化都会在 diff 里出现,评审时一眼能看到:
package com.plume.linkd.v3.api;
import com.google.protobuf.Descriptors;
import java.util.ArrayList;
import java.util.List;
public final class ContractBaseline {
public static List<String> snapshot() {
List<String> lines = new ArrayList<>();
Descriptors.FileDescriptor file = Book.getDescriptor().getFile();
for (Descriptors.ServiceDescriptor service : file.getServices()) {
lines.add("service " + service.getFullName());
for (Descriptors.MethodDescriptor method : service.getMethods()) {
lines.add("method " + method.getName()
+ " clientStreaming=" + method.isClientStreaming()
+ " serverStreaming=" + method.isServerStreaming()
+ " in=" + method.getInputType().getName()
+ " out=" + method.getOutputType().getName());
}
}
for (Descriptors.Descriptor message : file.getMessageTypes()) {
lines.add("message " + message.getName());
for (Descriptors.FieldDescriptor field : message.getFields()) {
lines.add("field " + message.getName() + "." + field.getName()
+ " number=" + field.getNumber()
+ " type=" + field.getType()
+ " repeated=" + field.isRepeated());
}
}
return lines;
}
public static void main(String[] args) {
snapshot().forEach(System.out::println);
}
}
预期输出(节选,字段顺序按 .proto 声明):
service plume.linkd.v3.Library
method GetBook clientStreaming=false serverStreaming=false in=GetBookRequest out=Book
method SearchBooks clientStreaming=false serverStreaming=true in=SearchBooksRequest out=Book
method WatchStock clientStreaming=false serverStreaming=true in=WatchStockRequest out=StockUpdate
method Chat clientStreaming=true serverStreaming=true in=ChatMessage out=ChatMessage
message GetBookRequest
field GetBookRequest.isbn number=1 type=STRING repeated=false
message Book
field Book.isbn number=1 type=STRING repeated=false
field Book.title number=2 type=STRING repeated=false
field Book.author number=3 type=STRING repeated=false
field Book.stock number=4 type=INT32 repeated=false
把 snapshot() 的输出写进 contract-baseline.txt,测试里对比文件内容。新增字段会产生一行新增,改编号会同时出现删除和新增,评审不可能漏看。
一次契约变更的流程
从提案到发布,固定六步,顺序不能反:
- 写变更说明。 说明为什么改、影响哪些调用方、是否兼容、回滚方案。只写「加个字段」不算说明。
- 改
.proto和常量。 字段用新编号,删除用reserved,错误码和 metadata 键同步更新。 - 跑兼容检查。 结构断言、基线 diff、必要时用旧版本客户端跑一遍新服务端。
- 重新生成并编译。
mvn -pl comm-api clean install,确认所有模块编译通过。 - 补契约测试。 新字段的默认值、新错误码的映射、新 metadata 的透传,各写一条。
- 灰度发布。 先让服务端同时支持新旧行为,再切客户端,最后清理旧字段。
第 6 步和第 12 章衔接。契约兼容不等于行为兼容:新增字段可能改变默认值,新增错误码可能让旧的告警规则失效。灰度阶段要观察错误率、延迟和日志里的未知原因码。
常见错误与边界
错误一:把 .proto 当文档,改完不重新生成。 仓库里的 .proto 和实际运行的代码不一致,联调时只能靠猜。生成代码必须由构建产生,不能用 IDE 插件生成后手工复制。
错误二:复用字段编号。 删除字段后把编号让给新字段,旧数据会被解析成新字段。线上不会立刻报错,而是出现「某些老记录的值莫名其妙」。reserved 是唯一正确的处理。
错误三:只做源码兼容,不做线上兼容。 重命名字段在源码层会编译失败,能及时暴露;改编号在源码层也可能编译通过,但线上数据错位。兼容检查要同时覆盖两个维度。
错误四:错误码只写在文档里。 文档不会参与编译,也不会被测试覆盖。原因码必须是常量,网关映射表必须引用常量,测试必须断言映射。
错误五:把 x-api-version 当成包名版本。 包名 plume.linkd.v3 是契约的大版本,x-api-version: v1/v2 是同一契约下的行为版本。两者混用会导致「同一个方法有两套不兼容语义」,客户端无法判断。
边界:.proto 的 optional 在 proto3 里有显式存在性。 给字段加 optional 会改变默认值判断:没有 optional 的 proto3 标量无法区分「未设置」和「设置为 0」。这不是线上格式的破坏,但会改变业务语义,评审时要单独说明。
边界:未知字段会被保留,但不是所有语言都保证。 Java 的 Protobuf 运行时默认保留未知字段,重新序列化时会带回去;某些语言的实现可能丢弃。跨语言链路不要把「未知字段保留」当成可靠契约。
小结
- 契约有五个面:数据结构、方法、错误、上下文、版本;只锁
.proto不够。 comm-api是唯一事实来源,protoc和protoc-gen-grpc-java的版本必须与运行时一致。- 改字段编号、改类型编码、改方法名、改调用模式都是破坏性变更;新增字段、
reserved删除、新增枚举值通常兼容。 - 用
Descriptors写结构断言,用基线快照做 diff,让契约变化在 CI 里可见。 - 一次变更走六步:说明、改契约、兼容检查、重新生成、契约测试、灰度发布。
reserved必须同时保留编号和名字;错误码和 metadata 键必须是常量,不能只写在文档里。
契约定住了,下一个问题是「这些字段在线上的字节到底长什么样」。第 2 章从 varint 和 tag 讲起,解释为什么字段号 1–15 更省字节,以及一次序列化为什么会丢掉默认值。
阅读导航:上一节 1.2 错误模型与状态码 | 下一节 2.1 Protobuf 编码与 schema
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。