如何基于 upb 为新语言构建 protobuf 运行时:FFI 前置条件与表驱动解析的设计选择
【免费下载链接】protobufProtocol Buffers - Google's data interchange format项目地址: https://gitcode.com/GitHub_Trending/pr/protobuf
如果你的语言想拥有自己的 protobuf 实现,protobuf 仓库提供了一条被 Ruby、PHP、Python 运行时实际采用的路径:把upb(一个用 C 编写的 protobuf 内核)作为运行时核心,用 FFI 包一层,再为语言写一个代码生成器。这篇文章围绕这条路径讲清楚三件事:语言运行时必须满足的前置条件、schema 加载与数据访问的两个关键设计选择(Reflection 与 MiniTables),以及 arena 内存管理如何与语言 GC 对接。资料来源是仓库内的 docs/upb/wrapping-upb.md(官方构建指南)、docs/upb/design.md(upb 设计说明)和 upb/README.md。
先明确要实现的两个组件
官方指南把一套 protobuf 实现拆成两部分:
- 代码生成器:编译期运行,把
.proto文件转成你语言(指南中以zlang为例)的源码文件; - 运行时组件:实现 wire format,提供表示 protobuf 数据和元数据的数据结构。
其中代码生成器不需要生成任何 C 代码(例如foo.c)。虽然 upb 本身是 C 写的,但它的解析器和序列化器是完全表驱动的(table-driven),没有任何逐 proto 生成 C 代码的需求或收益:即使 schema 数据是在运行时从嵌入foo.z的字符串加载的,upb 也能达到全速解析。这一点是 upb 相对 C++ 实现的关键差异——C++ proto 传统上依赖foo.pb.cc里的生成解析器才能达到全速,schema 运行时加载时解析器会有约 10 倍的速度损失(指南原文给出的对比)。
因此你的工作集中在两块绿色区域:代码生成器protoc-gen-zlang,以及语言与 upb 之间的 FFI 胶水层。
FFI 前置条件:语言运行时必须具备的能力
在动手之前,先核对你的语言运行时是否满足以下三项。前两项是硬要求,第三项可选。
- FFI(Foreign Function Interface):语言必须能通过 FFI 调用 C API。形式不限:可以是“native extensions”(写一段 C 代码来实现语言中的新方法),也可以是直接 FFI(通过某个库从语言直接调用 C 函数)。
- Finalizer、析构函数或 Cleaner:运行时必须能在对象被 GC 回收或以其他方式销毁时,触发对某个 C 函数的调用。不关心具体机制叫什么名字,关键是“最终会被调用”。原因是 upb 在 C 空间分配内存,finalizer 是确保内存被释放、不泄漏的唯一途径。
- 弱值 HashMap(可选):指南将其列为“不是强要求但有时有用”的项——一个值弱引用的全局 hashmap,用作
upb_msg* -> wrapper对象缓存(要求值是弱引用,键不是)。指南同时注明:是否继续采用这个模式尚存疑问。
确认 upb 的定位与平台参数
选 upb 作为内核前,需要接受它的定位(来自 upb/README.md 和 docs/upb/design.md):
- C API 和 ABI 都不稳定,upb 不作为通用 C 库直接对外提供,也没有版本发布;C API 是“非常低层、不安全、频繁变化”的。你的语言运行时必须随 upb 一起重新编译,不能假设二进制兼容。
- 平台参数:C99;32 位或 64 位 CPU(假设 4 或 8 字节指针);使用指针标记(pointer tagging),但避免其他实现定义行为;目标是永不触发未定义行为(以 ASAN、UBSAN 等测试);无全局状态、完全可重入。
- 能力边界:upb 支持与 C++ 实现相当的解析速度但代码量小一个数量级;支持生成 API(C)、reflection、二进制与 JSON wire format、text format 序列化,以及 oneofs、maps、unknown fields、extensions 等标准特性。不支持:text format 解析;深度 descriptor 校验(不如
protoc全面)。
如果你的语言运行时无法满足第 2 项 FFI 前置条件(GC 回收时无法触发 C 函数),upb 的内存管理就无法闭环,这条路径不成立。
设计选择一:Reflection 还是 MiniTables
这是指南指出的“第一个关键设计决策”:生成的代码通过 reflection 还是 MiniTables 访问消息数据。判断依据是语言的动态程度——“越动态的语言越倾向 reflection,越静态的语言越倾向 MiniTables”。
docs/upb/design.md 用下表概括了两种 schema 表示:
| MiniTables | Reflection | |
|---|---|---|
| 包含内容 | 仅字段号和类型 | .proto文件全部数据,包括所有名称 |
| 用途 | 二进制 wire format | JSON / TextFormat |
| wire 表示 | MiniDescriptor | Descriptor |
| 类型名 | upb_MiniTable、upb_MiniTableField等 | upb_MessageDef、upb_FieldDef等 |
| 注册表 | upb_ExtensionRegistry(扩展用) | upb_DefPool |
注意一个不对称关系:reflection 包含 MiniTables(有 reflection 就自动有 MiniTables),但没有把 MiniTable 转回 reflection 的途径。只加载 MiniTables 的“upb lite”代码和运行时内存开销都小于使用 reflection 的“upb full”。
动态语言选 Reflection
Reflection 式访问最适合方法分派通过字符串和哈希表查找解析的高度动态语言解释器:你可以实现__getattr__(Python)或method_missing(Ruby)这类接收方法名字符串的特殊方法,再用 upb reflection 按名字查字段——直接用 upb 的哈希表替代语言侧的哈希表。
# 文档中的示意代码(指南注明实际实现应放在 C 中以保证速度) class FooMessage: def __getattr__(self, name): field = FooMessage.descriptor.fields_by_name[name] return field.get_value(self)采用 reflection 的代价(指南明确列出):
- 必须在运行时加载完整 reflection,生成的代码需要嵌入序列化后的 descriptor(即
descriptor.proto的序列化消息),有体积开销,且把所有 message/field 名称暴露在二进制中; - 字段访问的关键路径上会强制一次哈希表查找。对方法调用本身就有此开销的语言无感,但对静态分派语言则是额外负担。
走到极端,类创建可以完全动态化:生成代码只剩“嵌入 descriptor + 一次加载调用”。指南展示了 Python 生成的main_pb2.py(_descriptor_pool.Default().AddSerializedFile("<...>")加载序列化 descriptor)以及配套的可读性.pyi存根文件。
要使用 reflection 式访问,接口落在两个头文件:
- 加载与访问 descriptor 数据:upb/reflection/def.h
- 访问消息数据:upb/reflection/message.h
静态语言选 MiniTables
MiniTables 是比 reflection 小得多的“lite”schema 表示:省略名称、options 和.proto中的几乎所有其他内容,只保留解析/序列化二进制格式所需的信息。它们通过MiniDescriptor加载——一种字节导向、只使用可打印字符的格式,嵌入生成代码字符串时不需要转义;指南给出的体积节省数字是相对普通 descriptor 约 60 倍。
MiniTables 适合编译期解析方法调用的语言。指南给了两级访问器示例(均为文档示例,展示设计取舍):
// 文档示例:最大化优化的生成访问器。 // 常量 "24" 在编译期从 upb 获得,与特定 schema/编译器版本强耦合。 class FooMessage { public long getBarField() { sun.misc.Unsafe.getLong(this.ptr, 24); } }// 文档示例:更松耦合的访问器,"2" 是字段号, // 内部在 MiniTable 中查找字段号并读取值。 class FooMessage { public long getBarField() { upb.glue.getLong(this.ptr, 2); } }代价方面,指南明确:MiniTables不支持 JSON 或 TextFormat 的解析/序列化,因为它不知道字段名。文档提到理论上可以把 reflection 数据“旁路生成”到单独的代码文件里、按需拉入,但相关 API 尚不存在。
要使用 MiniTable 式访问,接口落在两个头文件:
- 加载与访问 MiniDescriptor 数据:upb/mini_descriptor/decode.h
- 访问消息数据:upb/message/accessors.h
设计选择二:schema 怎么加载
几乎所有 upb 操作都要求先有 schema,加载 schema 是使用 upb 的第一步(docs/upb/design.md 指出这源于 wire format 本身:二进制格式不区分 string 和 sub-message,不可能无 schema 解析)。
MiniTable 的三种加载方式
- 从 C 生成代码:upb 代码生成器可以输出
.upb_minitable.c文件,把 MiniTable 作为全局常量放进.rodata(或.data.rel.ro);Bazel 中用upb_minitable_proto_library()规则生成并链接。 - 从 MiniDescriptor:运行时用
upb_MiniTable_Build()把 MiniDescriptor 转成 MiniTable。 - 从 reflection:已有 reflection 数据时,用
upb_MessageDef_MiniTable()从upb_MessageDef取对应的 MiniTable。
选型准则(design.md 原文归纳):
- 若你的语言已在用 reflection,方式 3 是显然的选择;
- 若语言参与标准二进制链接模型(通常用
ld链接),优先方式 1(“静态加载”):无需运行时初始化、启动更快,并支持跨语言共享同一份 proto(共享要求两边用完全相同的 MiniTable)。缺点是要求每个.proto生成一个.upb.c并链接其传递闭包,非 Bazel 构建系统下更费力; - 方式 2 的优势是不需要为每个消息链接 C 代码,对许多语言工具链更友好,是“不跨 FFI 边界之外加载 MiniTable 的便捷途径”。
方式 2 的实际接口在 upb/mini_descriptor/decode.h 中:
// Builds a mini table from the data encoded in the buffer [data, len]. // 出错时返回 NULL 并设置 status。成功后,调用者必须对所有 // message 或 proto2 enum 字段调用 upb_MiniTable_SetSub*(), // 把表链接到对应的子表。 UPB_NODISCARD upb_MiniTable* _upb_MiniTable_Build( const char* data, size_t len, upb_MiniTablePlatform platform, upb_Arena* arena, upb_Status* status); UPB_NODISCARD UPB_API_INLINE upb_MiniTable* upb_MiniTable_Build( const char* data, size_t len, upb_Arena* arena, upb_Status* status);返回NULL且 status 被置位即为失败路径;成功路径还必须完成子表链接,这是头文件注释里写明的调用者义务。
动态加载时的链接(link)步骤由使用者负责(静态加载由ld自动完成):
- 逐个链接:
upb_MiniTable_SetSubMessage()/upb_MiniTable_SetSubEnum(); - 批量链接:
upb_MiniTable_Link(),配合upb_MiniTable_GetSubList()(后者可在代码生成器中用来列出所有必须传入的子消息与子枚举)。
design.md 展示了 Dart 生成代码的典型模式(文档示例:desc是嵌入的 MiniDescriptor 字符串,link()接收子消息与子枚举两个列表):
const desc = r'$3334'; _accessor = $pb.instance.registry.newMessageAccessor(desc); _accessor!.link( [M2.$_accessor, M3.$_accessor, M4.$_accessor], [E.$_accessor], );一个实现细节:MiniDescriptor 字符串在代码生成器里通过upb_MessageDef_MiniDescriptorEncode()获得,文档明确“用户永远不需要手工编码 MiniDescriptor”。另外,某些场景下应用可以选择延迟甚至跳过子类型注册,作为 tree shaking 策略的一部分。
Reflection 的两种加载方式
- 从 C 生成代码:生成器输出
foo.upbdefs.c,嵌入 descriptor 并导出把数据加入用户upb_DefPool的函数; - 从 descriptor:运行时直接调用
upb_DefPool_AddFile(),再用upb_DefPool_FindMessageByName()取消息。
两者不只是便利关系:方式 1 同时链接了静态 MiniTable(省少量 CPU/RAM),且加载出的 descriptor 能反射基于生成.upb.cMiniTable 构建的消息;方式 2 在堆上从零构建 MiniTable,无法反射那些生成 MiniTable 的消息。PHP、Ruby、Python 这类动态语言的常见模式是方式 2 + 嵌入生成代码的 descriptor——design.md 展示了 Python 生成代码示例(AddSerializedFile()基本是upb_DefPool_AddFile()的薄封装)。
upb_DefPool是构建并持有一组 def 的顶层容器(类比 C++ 的DescriptorPool);文档要求必须保证upb_DefPool比它拥有的任何 def 对象存活更久。
内存管理:arena 与语言 GC 的对接
upb 全部内存管理都走 arena(upb_Arena):arena 通过底层分配器(通常malloc/free)取内存块,内部用线性 bump allocator 满足分配;单个对象不能单独释放,只能整体释放 arena。这是解析性能的关键——解析大 payload 会快速创建消息、repeated 数组和 map 树,arena 还能把释放消息树的成本从O(n)降到O(lg n)。典型用法(design.md 示例):
upb_Arena* arena = upb_Arena_New(); int* x = upb_Arena_Malloc(arena, sizeof(*x)); int* y = upb_Arena_Malloc(arena, sizeof(*y)); // x 和 y 不能分别 free,只能整体释放 arena upb_Arena_Free(arena);所有 upb 分配函数都带upb_Arena*参数,例如创建消息:
UPB_API upb_Message* upb_Message_New(const upb_MiniTable* mini_table, upb_Arena* arena);单 arena 内不存在对象间悬垂指针(同时释放)。跨 arena 链接时由使用者负责保证目标 arena 先于引用它的对象存活;若做不到这一点,upb 提供fuse:
// 融合 a 和 b 的生命周期:任一 arena 的内存块 // 在两个 arena 都被 upb_Arena_Free() 释放前都不会释放。 UPB_API bool upb_Arena_Fuse(const upb_Arena* a, const upb_Arena* b);fuse 是不可逆的生命周期合并,成本约 150ns、近似O(1)(真实复杂度是逆 Ackermann 函数)。注意每个 arena 自身占内存,所以反复创建并 fuse 新 arena 并非免费——CPU 代价温和,内存代价实打实。
与 GC 集成的策略
在有自动内存管理的语言中,目标是让 arena 完全隐藏在幕后。指南给出的通用策略:给所有 C 对象(包括 arena 本身)包 wrapper 对象,并保证 arena wrapper 在该 arena 内所有 C 对象都不可达之前不被 GC。
指南用一个 Python 包装的示例图说明了三类指针的角色:
- raw ptr:不携带所有权;
- unique ptr:唯一所有权,owner 在析构/finalizer/cleaner 中释放目标,一个对象只能有一个;
- shared (GC) ptr:共享所有权,引用全部消失才释放;GC 语言中即参与 GC 的引用(Python 用引用计数,其他 VM 可能是 mark and sweep)。
具体形态是:语言侧的 Message wrapper 持有到底层 C 消息的 raw pointer,同时持有一个到Python Arena wrapper的 shared pointer——只有当所有消息 wrapper 都被销毁,Python Arena 才会不可达,upb arena 最终被释放。这正是 FFI 前置条件中 finalizer 的用途:arena wrapper 的 finalizer 触发upb_Arena_Free()。
无堆与自定义分配器
upb_Arena默认用malloc/free,但底层分配可定制:
// 用给定初始块创建 arena(n 可为 0)。后续块从 |alloc| 分配; // |alloc| 为 NULL 时是固定大小 arena,不能增长。 UPB_API upb_Arena* upb_Arena_Init(void* mem, size_t n, upb_alloc* alloc);初始块[mem, n]耗尽前满足所有分配;alloc传NULL时 arena 的内存就只有初始块——这使 upb 可以在完全没有堆的场景使用。由此推论(design.md 明确要求):upb_Arena_Malloc()是可能失败的操作,若存在使用固定大小 arena 的可能,所有upb_Message_New()之类的分配操作都应检查失败。
验证与已知限制
upb 自身以“完整通过 protobuf conformance 测试套件”为设计目标(upb/README.md 将其列入 feature 清单)。仓库内保留了 upb 的 conformance 入口 upb/conformance/conformance_upb.c(其中通过upb_MessageDef_MiniTable()取 MiniTable,再走upb_Decode/upb_Encode)以及已知偏差清单 upb/conformance/conformance_upb_failures.txt——前者展示了 upb 内核“加载 schema → 解析 → 序列化”的标准调用形态,可以照此组织你语言运行时里解析/序列化的最小验证;后者说明 conformance 结果是按“通过 + 显式记录的失败项”管理的,而不是全绿才算数。
最后汇总本文涉及的硬性限制,作为设计评审的检查项:
- upb 的C API 与 ABI 均不稳定,不发布独立版本,运行时必须跟随 upb 一起构建;
- upb不支持 text format 解析,descriptor 校验深度不如
protoc; - 走 MiniTables 路线则没有 JSON/TextFormat 能力,且“旁路生成 reflection”的 API 尚不存在;
- 反射路线要求
upb_DefPool生命周期覆盖其持有的全部 def;跨 arena 链接必须自行保证无悬垂指针或用upb_Arena_Fuse()融合生命周期; - 使用固定大小/无堆 arena 时,所有分配调用都必须处理失败返回。
完成以上选型(FFI 能力核对、reflection/MiniTables 二选一、schema 加载方式、arena-GC 对接方案)后,你的工作就是实现指南中标绿的两块:protoc-gen-zlang代码生成器与 zlang/upb 的 FFI 胶水层。仓库中 Ruby(ruby/)、PHP(php/)、Python(python/)目录是三条已落地路线的参照实现,可在写 FFI 胶水时对照阅读。
【免费下载链接】protobufProtocol Buffers - Google's data interchange format项目地址: https://gitcode.com/GitHub_Trending/pr/protobuf
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考