Read OSS

一致性测试与 CI:让 10+ 种语言保持同步

中级

前置知识

  • 第 1 篇:架构与导航指南(整体仓库结构)

一致性测试与 CI:让 10+ 种语言保持同步

一个承诺跨语言兼容性的序列化格式,其可信度完全取决于测试套件的质量。protobuf 支持超过 10 种语言实现,每种语言都有各自不同的运行时架构——从带有反射和 arena 的 C++(第 3-4 篇)到基于 upb 的动态语言(第 5 篇),再到 Rust 的双内核系统(第 6 篇)。如何确保它们对每一个线上比特的语义理解完全一致?

答案就是一致性测试框架:这是一个专门构建的系统,用于在二进制、JSON 和文本格式三种线上表示形式下,验证每种语言实现是否符合规范。本文将深入探讨测试架构、子进程通信协议、用于追踪已知差异的失败列表机制,以及驱动整个系统运转的 CI 基础设施。

一致性测试架构

一致性框架位于 conformance/ 目录,围绕两个核心抽象构建。ConformanceTestSuite 负责定义测试用例,ConformanceTestRunner 则针对特定语言实现来执行这些测试。

测试套件在设计上具有良好的可扩展性。头文件展示了预期的使用模式:

class MyConformanceTestSuite : public ConformanceTestSuite {
 public:
    void RunSuiteImpl() {
        // INSERT ACTUAL TESTS.
    }
};

测试覆盖三种线上格式:

类别 说明
BINARY_TEST 经由 protobuf 二进制线上格式的往返测试
JSON_TEST 经由 JSON 线上格式的往返测试
TEXT_FORMAT_TEST 经由文本格式的往返测试

每个测试用例指定一种格式的输入载荷、另一种(或相同)格式的预期输出,以及操作应当成功还是失败。这种跨格式测试至关重要,因为 JSON 与二进制格式在语义上存在差异(例如字段名与字段编号、默认值的处理方式不同)。

classDiagram
    class ConformanceTestSuite {
        +RunSuiteImpl()*
        +RunTest(request, response)
        -failure_list_: FailureListTrieNode
    }
    
    class ConformanceTestRunner {
        <<interface>>
        +RunTest(request, response)*
    }
    
    class ForkPipeRunner {
        "Subprocess communication"
        +RunTest(request, response)
    }
    
    class InProcessRunner {
        "Direct function call"
        +RunTest(request, response)
    }
    
    ConformanceTestSuite --> ConformanceTestRunner
    ConformanceTestRunner <|-- ForkPipeRunner
    ConformanceTestRunner <|-- InProcessRunner

测试套件使用 FailureListTrieNode 来高效匹配已知失败项,支持通配符模式,例如 Recommended.*.JsonInput.BoolFieldDoubleQuotedFalse

子进程测试协议

一致性协议定义在 conformance.proto 中,采用简单的请求/响应模式。每种语言需要实现一个"被测程序"(testee),用于读取 ConformanceRequest 消息并输出 ConformanceResponse 消息。

请求中包含以下信息:

  • 某种格式的载荷(protobuf 二进制、JSON、JSPB 或文本格式)
  • 请求的输出格式
  • 用于解析的消息类型
  • 测试类别
sequenceDiagram
    participant Runner as Test Runner (C++)
    participant Testee as Language Testee<br/>(e.g., Python)
    
    Runner->>Testee: Fork + pipe
    
    loop For each test case
        Runner->>Testee: [4-byte length] + ConformanceRequest
        Note over Testee: Parse input payload<br/>Re-serialize to output format
        Testee->>Runner: [4-byte length] + ConformanceResponse
        Note over Runner: Compare response<br/>against expected result
    end
    
    Runner->>Testee: Close stdin (EOF)

通信采用简单的长度分隔协议:4 字节小端序长度前缀,后跟序列化的 protobuf 消息。这种设计刻意保持简单——任何能读取 stdin 并写入 stdout 的语言都可以实现一个一致性被测程序。

ConformanceResponse 可以表示以下状态:

  • 成功:包含以请求格式序列化的输出
  • 解析错误:输入无效(负面测试用例的预期结果)
  • 序列化错误:解析成功,但序列化失败
  • 运行时错误:发生了意外错误
  • 跳过:被测程序不支持此测试类别

Runner 发送的第一个请求比较特殊:其 message_type = "conformance.FailureSet",被测程序以其已知失败测试列表作为响应。这使 runner 能够区分预期失败和回归问题。

失败列表与已知不合规项

每种语言都维护一个失败列表文件,记录已知会失败的测试。C++ 对应的 conformance/failure_list_cpp.txt 展示了这种模式:

# This is the list of conformance tests that are known to fail for the C++
# implementation right now.  These should be fixed.

Recommended.*.JsonInput.BoolFieldDoubleQuotedFalse    # Should have failed to parse
Recommended.*.JsonInput.FieldNameDuplicate             # Should have failed to parse
Recommended.*.JsonInput.StringFieldSingleQuoteBoth     # Should have failed to parse

每一行是一个测试名称模式(支持 * 通配符),后跟解释失败原因的注释。命名规范 Recommended.*.JsonInput.BoolFieldDoubleQuotedFalse 表明这是一个"推荐"(非必须)测试,适用于任意消息类型,测试双引号布尔假值的 JSON 输入行为。

flowchart TD
    A["Conformance Test Runs"] --> B{Test passes?}
    B -->|Yes| C{Was it in<br/>failure list?}
    B -->|No| D{Was it in<br/>failure list?}
    C -->|Yes| E["⚠️ Unexpected pass!<br/>Remove from failure list"]
    C -->|No| F["✅ Pass"]
    D -->|Yes| G["✅ Expected failure"]
    D -->|No| H["❌ Regression!<br/>CI fails"]

工作流程如下:

  1. 向一致性套件添加新测试
  2. 若某语言未能通过,将该测试名称加入该语言的失败列表
  3. CI 照常通过,因为这是预期中的失败
  4. 当语言实现修复后,从失败列表中移除该测试名称
  5. 若某个不在失败列表中的测试开始失败,CI 报错——这是一次回归

这套机制让 protobuf 团队可以在所有实现就绪之前,提前添加有前瞻性的测试(例如严格的 JSON 解析),而不会阻塞 CI 流水线。同时,它也为每种语言的合规状态提供了清晰的全貌。

提示: 如果你正在为新语言实现 protobuf 库,不妨从实现一致性被测程序开始。测试套件会立即告诉你哪些线上格式行为是错误的。失败列表模式让你可以循序渐进地逼近完全合规。

CI/CD 基础设施

CI 基础设施以每种语言独立的 GitHub Actions 工作流组织,由 test_runner.yml 统一调度。

测试 runner 在以下情况触发:

  • 推送到 main:提交后验证
  • Pull request:提交前验证
  • 每小时定时:捕捉偶发失败和环境变化
  • 手动触发:用于调试

每种语言都有独立的工作流文件:

工作流 文件
C++ test_cpp.yml
Java test_java.yml
Python test_python.yml
Ruby test_ruby.yml
PHP test_php.yml
C# test_csharp.yml
Objective-C test_objectivec.yml
Rust test_rust.yml
HPB test_hpb.yml
upb test_upb.yml
Bazel test_bazel.yml
flowchart TD
    TRIGGER["Push / PR / Schedule"] --> RUNNER["test_runner.yml"]
    RUNNER --> SAFE{"Safe source?<br/>(internal branch)"}
    SAFE -->|Yes| JOBS["Spawn per-language jobs"]
    SAFE -->|No| LABEL{"'safe for tests'<br/>label?"}
    LABEL -->|Yes| JOBS
    LABEL -->|No| SKIP["Skip tests"]
    
    JOBS --> CPP["test_cpp.yml"]
    JOBS --> JAVA["test_java.yml"]
    JOBS --> PY["test_python.yml"]
    JOBS --> RUST["test_rust.yml"]
    JOBS --> MORE["...other languages"]
    
    CPP --> BAZEL["Bazel test //..."]
    JAVA --> BAZEL
    PY --> BAZEL

测试 runner 针对 fork 来源的 pull request 实施了安全保护策略。来自仓库内部的 PR 可直接运行测试,而来自 fork 的 PR 则需要添加"safe for tests"标签,以防止恶意代码执行和算力滥用——标签在被消费后会立即移除,因此每次新提交都需要重新审批。

并发控制机制可防止重复运行:

concurrency:
  group: ${{ github.event_name }}-${{ github.workflow }}-${{ github.head_ref || github.ref }}
  cancel-in-progress: true

这意味着,当新的提交被推送到 PR 分支时,之前提交正在进行的测试运行会被自动取消。

全貌回顾

一致性测试与 CI 系统,是 protobuf 兑现多语言承诺的底气所在。若没有它,微小的偏差会不断积累——某个 JSON 解析器悄悄接受了略不规范的输入,某个二进制编码器对边界情况的处理方式有所不同——最终导致在一种语言中正常工作的消息,在另一种语言中无声地失败或损坏数据。

这套体系的核心组合是:

  • 定义精确测试语义的正式协议(conformance.proto
  • 记录已知差距的各语言失败列表
  • 即时捕捉回归问题的自动化 CI
  • 跨格式测试(二进制、JSON、文本)

正是这些机制共同保证了:当你在 Python 中序列化一条 protobuf 消息,再在 Rust(或 Java、C++、PHP)中反序列化时,得到的结果始终一致。纵观本系列的全部内容——从编译器流水线(第 2 篇)、描述符系统(第 3 篇)、性能优化栈(第 4 篇)、upb 运行时(第 5 篇),到代码生成器(第 6 篇)——一致性测试套件始终是正确性的最终裁判。

这就是 protobuf monorepo——一个卓越的工程杰作,它在单一仓库中协调着一个编译器、两个运行时、10+ 种语言实现,以及一套全面的测试框架。深入理解其架构,不仅能让你掌握 protobuf 的内部原理,更能从中获得一份关于如何构建和维护大规模多语言基础设施项目的宝贵案例。