《Java 服务端通信工程》12.3 契约测试与回滚

把版本兼容从口头约定变成 CI 门禁:用 Descriptors.FileDescriptor 对比上一版与当前 proto,检查字段号、类型、标签、枚举值和服务方法,并给出 golden 快照、JUnit 测试与破坏性变更回滚流程的具体实现。

承接的问题

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 故障模型与爆炸半径

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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