《Java 消息中间件·事件驱动架构》6.3 消费者驱动的契约

消费者契约不是生产者的单元测试,而是消费者声明「我依赖这些字段」,再由 CI 验证生产者是否遵守。本文用纯 Java 实现契约断言,完整展示「生产者改了字段,测试红了」的四场景输出,并给出一个可嵌入 Maven verify 阶段的契约测试骨架。

谁是契约的发起方

传统思路里,schema 归生产者定义,消费者被动接受。这导致一个结构性问题:生产者加了一个字段,单元测试全绿,但某个消费者按旧字段名写查询,一上线就崩。测试没坏,线上坏了——因为生产者的测试里没有消费者的视角。

消费者驱动契约(Consumer-Driven Contract,CDC)把这个关系倒过来:消费者先声明「我依赖什么字段、什么类型」,生产者在 CI 里跑这些声明,通不过就不允许合并。 契约不是消费者给生产者提需求,而是消费者把自己的「依赖假设」写成了可执行的断言。

在事件驱动架构里,契约表达为消费者对事件 schema 的一组期望:

  • 我依赖 loanId(string),它是投影表的主键;
  • 我依赖 borrowedAt(timestamp),列表页按它排序;
  • 我不处理 memberId,但它存在没问题;
  • 我不接受 borrowedAt 变成 startAt,这会直接破坏我的查询。

最后一项最容易被忽略。生产者可能觉得「只是换个更准确的字段名」,但消费者的 SQL、索引、缓存键全都硬编码了这个名字。改名在兼容判定表上是破坏性变更(6.1),CDC 的任务就是让消费者把这个依赖显式写出来,让 CI 自动挡住。

纯 Java 契约断言

不需要 Spring Cloud Contract、不需要 Pact Broker,一个纯 Java 的 Contract + verify 就能在 CI 里工作。核心设计:

// ConsumerContractTest.java — 消费者驱动的契约纯 Java 断言

import java.util.*;

public class ConsumerContractTest {

    record SchemaField(String name, String type, boolean required, String description) {}

    static class Contract {
        final String consumerName;
        final Set<SchemaField> requiredFields;
        final Set<String> forbiddenFields;
        final Map<String, String> typeExpectations;

        Contract(String consumerName,
                 Set<SchemaField> requiredFields,
                 Set<String> forbiddenFields,
                 Map<String, String> typeExpectations) {
            this.consumerName = consumerName;
            this.requiredFields = requiredFields;
            this.forbiddenFields = forbiddenFields;
            this.typeExpectations = typeExpectations;
        }

        public static Builder builder(String consumerName) {
            return new Builder(consumerName);
        }

        static class Builder {
            private final String name;
            private final Set<SchemaField> req = new LinkedHashSet<>();
            private final Set<String> forb = new HashSet<>();
            private final Map<String, String> types = new LinkedHashMap<>();

            Builder(String name) { this.name = name; }
            Builder requires(String name, String type, String desc) {
                req.add(new SchemaField(name, type, true, desc));
                types.put(name, type);
                return this;
            }
            Builder optional(String name, String type, String desc) {
                req.add(new SchemaField(name, type, false, desc));
                types.put(name, type);
                return this;
            }
            Builder forbids(String name) {
                forb.add(name);
                return this;
            }
            Contract build() { return new Contract(name, req, forb, types); }
        }
    }

    record Result(boolean pass, List<String> failures) {}

    static Result verify(Map<String, String> producerSchema, Contract contract) {
        List<String> failures = new ArrayList<>();

        for (SchemaField f : contract.requiredFields) {
            if (f.required && !producerSchema.containsKey(f.name())) {
                failures.add("[MISSING] required field '" + f.name() + "' ("
                    + f.type() + ") — " + f.description());
            }
        }

        for (Map.Entry<String, String> exp : contract.typeExpectations.entrySet()) {
            String name = exp.getKey();
            String expected = exp.getValue();
            if (producerSchema.containsKey(name)
                    && !producerSchema.get(name).equals(expected)) {
                failures.add("[TYPE_MISMATCH] field '" + name + "': expected "
                    + expected + ", got " + producerSchema.get(name));
            }
        }

        for (String f : contract.forbiddenFields) {
            if (producerSchema.containsKey(f)) {
                failures.add("[FORBIDDEN] field '" + f
                    + "' should not be present; it was removed/renamed");
            }
        }

        return new Result(failures.isEmpty(), failures);
    }

    static Contract projectionConsumer() {
        return Contract.builder("catalog-projection")
            .requires("loanId", "string", "主键,投影表行标识")
            .requires("bookId", "string", "图书关联,分组/过滤用")
            .requires("borrowedAt", "timestamp", "借出时间,列表页排序")
            .requires("dueAt", "timestamp", "到期时间,逾期提醒计算")
            .optional("occurredAt", "timestamp", "事件时间,排序备用")
            .forbids("borrowedAtOld")
            .build();
    }

    public static void main(String[] args) {
        // ... 四个场景见下方实测输出 ...
    }
}

验证逻辑分三条:必填字段必须存在、类型必须匹配、已废弃字段不能出现。失败信息包含 [MISSING]、[TYPE_MISMATCH]、[FORBIDDEN] 三种标签,CI 日志里一眼就能定位问题类别。

实测四场景:从绿到红

本机实测(Temurin 25.0.5+8 / 2026-10-29):javac --release 21 ConsumerContractTest.java && java ConsumerContractTest

场景 A:生产者不变,契约通过

=== 场景 A:生产者 v1.0.0,契约通过 ===
契约方: catalog-projection
producer fields: [loanId, bookId, memberId, borrowedAt, dueAt, occurredAt]
required fields: [loanId, bookId, borrowedAt, dueAt]
RESULT: PASS (failures=0)

生产者的 schema 覆盖了契约要求的四个必填字段,类型一致,borrowedAtOld 不存在。契约测试绿。

场景 B:生产者改了字段名,契约红了

这是 CDC 最典型的价值:生产者把 borrowedAt 改成 startAt,自认为「类型没变,兼容」,但消费者依赖的是具体字段名。

=== 场景 B:生产者 v1.2.0,borrowedAt → startAt,契约红了 ===
契约方: catalog-projection
producer fields: [loanId, bookId, memberId, startAt, dueAt, occurredAt]
required fields: [loanId, bookId, borrowedAt, dueAt]
RESULT: FAIL (failures=1)
  [MISSING] required field 'borrowedAt' (timestamp) — 借出时间,列表页排序

[MISSING] 标签明确告诉开发者:消费者的投影查询会因为这个字段缺失而失败。这不是类型问题,是名字问题——而名字问题在 6.1 的兼容判定表上是「改名 = 双向破坏」。

场景 C:生产者加可选字段,契约不受影响

=== 场景 C:生产者 v1.1.0,加可选字段 reason,契约不受影响 ===
producer fields: [loanId, bookId, memberId, borrowedAt, dueAt, occurredAt, reason]
RESULT: PASS (failures=0)

reason 不在契约的 required 列表里,消费者忽略未知字段是 JSON 解析的默认行为。6.1 的兼容判定表上这是 FULL 兼容,CDC 验证通过。

场景 D:生产者改类型,契约红了

=== 场景 D:borrowedAt timestamp→long,契约红了 ===
producer fields: [loanId, bookId, memberId, borrowedAt, dueAt, occurredAt]
RESULT: FAIL (failures=1)
  [TYPE_MISMATCH] field 'borrowedAt': expected timestamp, got long

[TYPE_MISMATCH] 比 [MISSING] 更隐蔽。字段名还在,消费者的编译能通过,但运行时用 Timestamp.valueOf() 解析 long 会抛异常。CDC 在构建阶段就拦住它,比线上报错便宜得多。

从测试到 CI 门禁

上面的 main 方法在本地跑四个场景,但 CI 里不需要跑场景对比,只需要跑「当前生产者 schema 对所有消费者契约的验证」。一个可嵌入 mvn verify 的最小骨架:

public class ContractVerification {
    static final List<Contract> ALL_CONTRACTS = List.of(
        ConsumerContractTest.projectionConsumer(),
        // notificationConsumer(), // 通知服务
        // overdueConsumer()       // 逾期调度
    );

    public static void main(String[] args) {
        Map<String, String> currentSchema = loadCurrentSchemaFromCatalog();
        boolean allPass = true;
        for (Contract c : ALL_CONTRACTS) {
            Result r = ConsumerContractTest.verify(currentSchema, c);
            System.out.println(c.consumerName + ": " + (r.pass() ? "PASS" : "FAIL"));
            for (String f : r.failures()) System.out.println("  " + f);
            if (!r.pass()) allPass = false;
        }
        if (!allPass) {
            System.exit(1); // mvn verify 看到非零退出码即失败
        }
    }

    static Map<String, String> loadCurrentSchemaFromCatalog() {
        // 从 events.yaml 读取当前版本的 schema,或从编译后的 record 反射导出
        // 2.3 的 CatalogSchemaDump 已经提供了反射导出逻辑,直接复用
        return Map.of(/* ... */);
    }
}

loadCurrentSchemaFromCatalog 的实现可以直接复用 2.3 的漂移检查:用反射把 record 的字段和类型导成 Map<String, String>,与契约的期望比对。这样「目录-代码-契约」三者同源,修改一处、CI 全量验证。

契约的边界

CDC 能拦住字段缺失和类型错误,但拦不住下面这些问题:

问题CDC 能否拦住靠什么补位
字段改名能([MISSING])—
类型变化能([TYPE_MISMATCH])—
字段语义漂移不能事件目录 + ADR 评审
值集变化(如枚举值改了)不能消费者单元测试 + 集成测试
事件顺序变化不能5.1 的版本号排序
性能退化(payload 过大)不能监控阈值告警

CDC 是契约治理的第一道门,不是唯一一道。字段语义漂移和值集变化,需要目录里的 ADR 和消费者的单元测试一起覆盖。把所有验证都压进 CDC,会让契约文件膨胀到无法维护。

多个消费者的协调

一个事件通常有多个消费者,它们的契约可能冲突。比如:

  • 投影消费者 A 要求 borrowedAt(timestamp)必须存在;
  • 分析消费者 B 要求 borrowedAt 改成 loanStartEpoch(long),便于时间序列数据库写入。

两条契约不能同时满足。CDC 的价值不是自动解决冲突,而是让冲突在构建阶段暴露,而不是在线上暴露。暴露后,要么生产者同时提供两个字段(兼容期),要么走新主题隔离(major 变更),要么消费者自己映射。

「生产者同时提供新旧字段」是最常见的过渡方案:

{
  "loanId": "L-001",
  "borrowedAt": "2026-10-20T09:00:00Z",
  "loanStartEpoch": 1729414800000
}

保留一个观察窗口,等所有消费者都迁移到 loanStartEpoch 后,再移除 borrowedAt。这个窗口的长度取决于最慢的消费者,可以从事件目录的 consumers[].since 字段推断。

契约测试 vs 集成测试

CDC 不是集成测试的替代品,两者互补:

  • CDC 验证「生产者 schema 是否满足消费者的静态期望」,不连 broker、不跑真实消费逻辑,毫秒级反馈;
  • 集成测试 验证「生产者发送的真实消息能否被消费者正确处理」,要连 broker、要发真实数据、要查数据库,秒级或分钟级反馈。

CI 阶段顺序:先跑 CDC(快),再跑单元测试,最后跑集成测试(慢)。CDC 失败意味着集成测试没必要跑,省时间。

小结

  • 消费者驱动契约让消费者把自己的依赖假设写成可执行断言,生产者在 CI 里验证,通不过即 fail build。
  • 本机实测四场景:v1.0.0 通过、改名导致 [MISSING] 失败、加可选字段通过、类型变化导致 [TYPE_MISMATCH] 失败。
  • 契约验证分三条:必填字段存在、类型匹配、已废弃字段不出现;失败信息带标签,CI 日志可读。
  • CDC 是契约治理的第一道门,语义漂移和值集变化需要目录 ADR 和消费者单元测试补位。
  • 多消费者冲突时,CDC 暴露冲突但不自动解决;常见过渡方案是「新旧字段双写 + 观察窗口」。

阅读导航:上一节 6.2 兼容性与 Schema 管理 | 下一节 7.1 Streams 的拓扑与处理器

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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