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 实现。每个生成器都知道如何为目标语言输出符合惯用法的代码。
运行时层是终端用户实际链接的部分,提供基类(MessageLite、Message)、序列化/解析逻辑、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.cc。ProtobufMain() 函数读起来出奇地简洁——它实例化一个 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 读取 CodeGeneratorResponse。plugin.h 中的 PluginMain() 函数为用 C++ 编写此类插件提供了开箱即用的入口点。
提示: 插件协议意味着你可以用任何语言编写 protobuf 代码生成器,完全不依赖 C++。你的二进制文件只需要能通过标准输入输出读写 protobuf 消息即可——这正是
protoc-gen-go用 Go 编写的原因。
代码库导航技巧
掌握以下几个实用规律,能让你在仓库中快速找到方向。
版本追踪通过 version.json 管理,各语言维护独立的版本号。截至本文撰写时,C++ 运行时为 7.35-dev,Java 为 4.35-dev,protoc 本身为 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 文件本身。