《Java 服务端通信工程》1.3 契约先行与演进边界

契约不只是 .proto:字段号、错误原因码、metadata 键、超时预算和版本规则都算契约。本节给出 comm-api 的生成配置、兼容与破坏性改动清单、用 Descriptors 写契约基线和 JUnit 断言的方法,以及一次变更从提案到发布的流程。

承接的问题

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,测试里对比文件内容。新增字段会产生一行新增,改编号会同时出现删除和新增,评审不可能漏看。

一次契约变更的流程

从提案到发布,固定六步,顺序不能反:

  1. 写变更说明。 说明为什么改、影响哪些调用方、是否兼容、回滚方案。只写「加个字段」不算说明。
  2. 改 .proto 和常量。 字段用新编号,删除用 reserved,错误码和 metadata 键同步更新。
  3. 跑兼容检查。 结构断言、基线 diff、必要时用旧版本客户端跑一遍新服务端。
  4. 重新生成并编译。 mvn -pl comm-api clean install,确认所有模块编译通过。
  5. 补契约测试。 新字段的默认值、新错误码的映射、新 metadata 的透传,各写一条。
  6. 灰度发布。 先让服务端同时支持新旧行为,再切客户端,最后清理旧字段。

第 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

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收