Read OSS

Protocol Buffers 源码导览:认识整体架构

中级

前置知识

  • 对 Protocol Buffers 的基本概念及 .proto 文件的工作方式有所了解
  • 能够阅读 C++ 头文件

Protocol Buffers 源码导览:认识整体架构

Protocol Buffers 是目前影响力最广的开源项目之一。Google 内外几乎所有大型分布式系统都通过 protobuf 来序列化数据。这个同时负责编译器和运行时的仓库体量庞大——仅 C++ 代码就超过 20 万行,Java、Python、Ruby、PHP、Rust、C#、Objective-C、Kotlin 等语言也各有相当规模的实现。如果你曾好奇 protoc 是如何把 .proto 文件变成可用代码的,或者运行时是如何在纳秒级别完成消息解析的,这个系列将带你逐层拆解。

本文是整个系列的地图。我们会建立基本的思维模型,浏览目录结构,理解为何存在两套 C 运行时,并追踪主入口点。读完之后,你打开仓库中的任意一个文件,都能清楚地知道它在整体架构中的位置。

三层架构

每一次 protobuf 交互都流经三个概念层。理解这三层,是读懂这份代码库最关键的一步。

flowchart TB
    subgraph Schema["Schema Layer"]
        A[".proto files"] --> B["Descriptor Graph<br/>(FileDescriptor, Descriptor, FieldDescriptor)"]
    end
    subgraph Codegen["Code Generation Layer"]
        B --> C["protoc + Language Generators"]
        C --> D["Generated Source Files<br/>(.pb.h/.pb.cc, .java, .py, etc.)"]
    end
    subgraph Runtime["Runtime Layer"]
        D --> E["Generated Code + Runtime Library"]
        E --> F["Serialize / Parse / Reflect"]
    end

Schema 层定义了与语言无关的类型系统。.proto 文件首先被解析为 FileDescriptorProto,随后在 DescriptorPool 中构建为不可变的 FileDescriptor。这个以 src/google/protobuf/descriptor.h 为根的描述符图,是所有 message、字段、枚举和 service 在内存中的权威表示。

代码生成层读取描述符,输出特定语言的源文件。protoc 编译器负责调度,将请求分发给已注册的 CodeGenerator 实现。每个生成器都知道如何为目标语言输出符合惯用法的代码。

运行时层是终端用户实际链接的部分,提供基类(MessageLiteMessage)、序列化/解析逻辑、arena 内存分配以及反射能力。生成的代码继承自这些基类,并接入运行时的解析表。

正是这种分层设计使得 protobuf 能够保持语言中立:Schema 层由所有语言共享,每种语言则各自拥有独立的生成器和运行时。

目录结构导览

仓库体量虽大,但顶层布局与三层架构高度吻合,结构相当清晰。

目录 所属层 用途
src/google/protobuf/ Schema + Runtime C++ 核心运行时、描述符系统、arena、解析逻辑
src/google/protobuf/compiler/ Code Generation protoc CLI、解析器及所有内置语言生成器
upb/ Runtime (C) µpb——供动态语言使用的轻量级 C 运行时
hpb/ Runtime (C++) 基于 µpb 封装的 C++ 易用层
upb_generator/ Code Generation µpb mini-table 代码生成器
hpb_generator/ Code Generation HPB C++ wrapper 代码生成器
java/, python/, ruby/, php/, rust/, csharp/, objectivec/ Runtime 各语言运行时库
conformance/ Testing 跨语言一致性测试套件
editions/ Schema Editions 特性定义及测试数据
docs/ Documentation 设计文档、upb 指南
pkg/ Build/Packaging 发行版打包、文件列表生成
.github/workflows/ CI 20 余个 GitHub Actions 工作流

C++ 编译器和运行时都位于 src/google/protobuf/ 下。其中 compiler/ 存放前端部分(词法分析器、解析器、CLI)以及各语言生成器的子目录(compiler/cpp/compiler/java/compiler/python/ 等)。

提示: compiler/ 子目录包含的是所有内置语言的生成器,而不只是 C++。当你看到 compiler/rust/generator.h 时,那是 Rust 代码生成器——同样用 C++ 编写,同样属于 protoc 二进制文件的一部分。

双运行时策略:C++ 与 µpb

这个仓库最出人意料的地方之一,是它同时包含两套独立的 C/C++ 运行时。理解其背后的原因至关重要。

flowchart LR
    subgraph Full["C++ Runtime (src/google/protobuf/)"]
        direction TB
        ML[MessageLite] --> M[Message]
        M --> R[Reflection]
        M --> AR[Arena Allocation]
        M --> TC[TcParser]
    end
    subgraph Micro["µpb Runtime (upb/)"]
        direction TB
        UM[upb_Message] --> MT[upb_MiniTable]
        UM --> UA[upb_Arena]
        UM --> UD[upb_Decode / upb_Encode]
    end
    Full -.->|"Used by: C++ apps"| U1[C++ Users]
    Micro -.->|"Wrapped by: PHP, Ruby, Python"| U2[Dynamic Languages]

C++ 运行时是面向用户的完整功能库,提供反射、懒加载字段、动态消息、线程安全的 arena 以及高性能的 TcParser,是 C++ 应用直接链接的目标。

µpb(micro protobuf,位于 upb/)则是一个极简的 C 内核。它的设计优先考虑二进制体积和稳定的 C ABI,而非功能丰富性。正如 docs/upb/vs-cpp-protos.md 中所解释的:

  • C++ protobuf 是一个用户级库,专为 C++ 应用直接使用而设计
  • µpb 则主要是为其他语言提供封装——作为 C 内核,供各语言构建自己的 protobuf 库

PHP、Ruby 和 Python 都通过 FFI 调用 µpb 作为序列化内核。这也解释了为何这些语言目录会与 upb/ 并列存在——它们包含的正是将 µpb 封装成各语言惯用 API 的粘合代码。

两者的代码体积差异相当悬殊。对于一个解析并序列化 descriptor.proto 的二进制文件,µpb 的 .text 段仅有 26 KiB,而完整 C++ 运行时则高达 983 KiB。

入口点:protoc 与插件系统

protoc 二进制文件的入口在 src/google/protobuf/compiler/main.ccProtobufMain() 函数读起来出奇地简洁——它实例化一个 CommandLineInterface,注册所有内置生成器,然后调用 cli.Run()

flowchart TD
    A["ProtobufMain()"] --> B["Create CommandLineInterface"]
    B --> C["AllowPlugins('protoc-')"]
    C --> D["Register 11 built-in generators"]
    D --> E["cli.Run(argc, argv)"]
    E --> F{"--X_out flag?"}
    F -->|"Built-in"| G["Invoke registered CodeGenerator"]
    F -->|"Unknown"| H["Find protoc-gen-X plugin binary"]
    H --> I["Pipe CodeGeneratorRequest via stdin"]
    I --> J["Read CodeGeneratorResponse from stdout"]

11 个内置生成器如下:

标志 生成器 语言
--cpp_out CppGenerator C++
--java_out JavaGenerator Java
--kotlin_out KotlinGenerator Kotlin
--python_out Generator Python
--pyi_out PyiGenerator Python stubs
--php_out Generator PHP
--ruby_out Generator Ruby
--rbs_out RBSGenerator Ruby 类型定义
--csharp_out Generator C#
--objc_out ObjectiveCGenerator Objective-C
--rust_out RustGenerator Rust

对于未内置的语言——如 Go、Dart 或任何第三方语言——protoc 通过插件协议来处理。当遇到无法识别的 --foo_out 标志时,它会在 PATH 中查找 protoc-gen-foo,将 CodeGeneratorRequest 序列化后写入其 stdin,再从 stdout 读取 CodeGeneratorResponseplugin.h 中的 PluginMain() 函数为用 C++ 编写此类插件提供了开箱即用的入口点。

提示: 插件协议意味着你可以用任何语言编写 protobuf 代码生成器,完全不依赖 C++。你的二进制文件只需要能通过标准输入输出读写 protobuf 消息即可——这正是 protoc-gen-go 用 Go 编写的原因。

代码库导航技巧

掌握以下几个实用规律,能让你在仓库中快速找到方向。

版本追踪通过 version.json 管理,各语言维护独立的版本号。截至本文撰写时,C++ 运行时为 7.35-dev,Java 为 4.35-devprotoc 本身为 35-dev。版本之所以各自独立,是因为不同语言运行时的演进节奏并不相同。

双构建系统是这个仓库的一个重要特征。Bazel 是权威构建系统,定义于 MODULE.bazel,管理所有外部依赖(Abseil、rules_cc、zlib 等)。CMake 作为辅助系统存在,以便与更广泛的生态系统兼容。两者之间的桥梁是 pkg/BUILD.bazel 中的 gen_file_lists 规则——它从 Bazel 目标生成文件列表,再由 CMake 消费。

flowchart LR
    A["Bazel BUILD files<br/>(canonical)"] --> B["gen_file_lists rule<br/>pkg/BUILD.bazel"]
    B --> C["src_file_lists.cmake"]
    C --> D["CMakeLists.txt<br/>(secondary)"]

文件命名惯例保持一致:

  • _lite 后缀表示仅支持 MessageLite(不含反射)
  • internal/ 子目录存放内部实现细节
  • port_def.inc / port_undef.inc 是成对出现的宏守卫,用于包裹平台相关定义
  • src/google/protobuf/ 中的 *.proto 文件是 well-known type 和内部 schema

conformance/ 中的一致性测试是验证所有语言正确性的唯一可信来源。每种语言都有一个对应的失败列表文件(如 failure_list_python.txt),明确记录了已知的差异——这是一种简单却颇为有效的契约机制。

下一步

有了这张地图,你已经准备好深入探索了。下一篇文章将追踪 protoc 内部完整的编译流水线:从手写的词法分析器对 .proto 文件进行词法分析,到递归下降解析器构建 FileDescriptorProto 消息,再到 DescriptorPool 解析跨文件引用,最终通过 CodeGenerator 接口输出特定语言的源代码。我们还将拆解 descriptor.proto 这个美妙的自我指涉谜题——那个用来描述所有 proto 文件的 proto 文件本身。