Python原生Protobuf库Protobuf-py发布,性能无妥协
速览
Protobuf-py是一个专为Python打造的Protocol Buffers库,旨在提供无妥协的性能和功能完整性。该库解决了Python中Protobuf序列化的常见痛点,提升了数据处理效率。对于需要高效数据交换的AI、数据工程等场景具有实际意义。
AI 深度解读
背景
Protocol Buffers(Protobuf)是 Google 开发的高效结构化数据序列化协议,广泛应用于数据管道、机器学习系统、AI 智能体、基础设施脚本、RPC 服务和开发者工具中。然而,在 Python 生态中,Protobuf 的使用体验长期以来并不理想:开发者需要在「完整实现」和「Python 风格」之间做妥协。Google 官方维护的 protobuf Python 包虽然功能完整、经过实战考验,但其 API 和生成的代码深受 C++/Java 设计影响,使用起来感觉像是在操作一个其他语言运行时的绑定层;而社区项目 betterproto 虽然提供了更 Pythonic 的体验,却放弃了相当一部分规范(例如仅支持 proto3,不支持 proto2、editions、扩展、自定义选项等)。grpcio 作为 RPC 层也存在类似问题:功能强大且被广泛使用,但围绕它进行构建却非常痛苦。
Buf 团队此前已经通过 connect-py(一个同时支持 Connect 和 gRPC 的 ConnectRPC 实现)尝试改善 RPC 层,但他们发现仅靠传输层不够——良好的 RPC 栈仍然依赖于底层的消息运行时。Python 缺少一个既完整又 Pythonic 且足够高效的 Protobuf 运行时作为基础。protobuf-py 正是在这一背景下诞生的:一个完全为 Python 构建的、完整的 Protobuf 运行时,不妥协于任何方面。
核心内容
protobuf-py 是一个完全从头编写的、用于 Python 的 Protocol Buffers 库。它通过了 Protobuf conformance suite 中所有二进制和 JSON 测试用例(包括 proto2、proto3 和 editions),支持扩展(extensions)、自定义选项(custom options)、未知字段(unknown fields)、动态消息(dynamic messages)和 well-known types。它生成可读的、类型化的 Python 代码,无运行时依赖,仅需纯 Python 3.10+ 即可运行。当安装了可选的 Rust 加速器后,其生产工作负载的速度与 Google Python 包底层的 C 引擎 upb 相当。
为什么 Google 的包用起来感觉如此?
安装 Google 的 protobuf 包后,底层引擎通常是 C 语言编写的 upb。消息数据存储在 C 的 arena 中,而 Python 对象只是指向该 arena 的句柄。每次读取字段都需要跨越 C 边界,找到值,然后物化(materialize)一个 Python 对象返回。这种设计是跨语言共享引擎的好方法,但它留下了诸多痕迹:
- 生成的
_pb2.py文件难以阅读,因为其中几乎没有什么 Python 代码——类在导入时通过配置 C 引擎的方式构建,“转到定义”只能看到一串序列化的描述符字节。 - 像
SerializeToString、HasField、WhichOneof、CopyFrom等方法构成了一个为 C++ 易用性设计的 Python API。例如,对 proto3 标量字段调用HasField会抛出异常;WhichOneof返回的是字符串,你需要再将其传给getattr才能获取它已经定位到的值。 - 生成的导入语句使用绝对路径,一旦将文件嵌套进包中就会失效。PyPI 上甚至存在一个名为
fix-protobuf-imports的独立工具,唯一目的就是重写 Google 生成的输出。 - 类型注册在一个全局进程中作用域的池中,因此导入同一
.proto文件的两个不同构建版本会在运行时抛出异常。 - 这些并非随机的绑定缺陷,而是 Python API 为与主要由 C++/Java 组成的代码库保持一致而设计的必然结果。换言之,性能和别扭的 API 是捆绑在一起的。
protobuf-py 做了什么?
protobuf-py 将消息数据保留在 Python 中。消息是带有 __slots__ 的普通对象,其字段是普通的 Python 值:int、string、list、子消息。可选的 Rust 加速器负责加速需要高性能的操作(主要是解析和序列化),并将结果直接写入 Python 对象。一旦解析完成,读取字段就只是访问一个 Python 属性。
因为数据是 Python 原生对象,生成的代码是真正的代码。protoc-gen-py 会生成可以阅读的类,例如:
class User(Message):
name: str
age: int
tags: list[str]
address: Address | None
使用起来就像操作语言中的任何其他对象一样:
- Oneof 成为可以模式匹配的值,类型检查器能缩小每个分支。
- 枚举是真正的
IntEnum成员。 pyright、mypy和ty可以直接读取生成的输出,无需类型桩包。- 生成的导入语句使用相对路径,可以放在任何目录中。
- 类型通过显式的
Registry解析,而非全局池。
完整而非友好子集
其他 Protobuf 库虽然比 Google 的易用,但通常以放弃一半规范为代价。例如 betterproto 目前虽然是最 Pythonic 的选项,但它仅支持 proto3,不支持 proto2、editions、扩展或自定义选项。protobuf-py 则覆盖了整个规范:它处理 proto2、proto3、editions、扩展、自定义选项、groups、往返过程中的未知字段保留、packed 和 unpacked 重复字段,以及 well-known types 的完整 ProtoJSON 编码。它通过了 Google 用于认证自有运行时的 conformance suite,二进制和 JSON 测试全部通过,且空失败列表被检查进仓库,CI 在失败列表不再为空时会失败。
关键性能对比
一个只解析消息然后丢弃的基准测试会显得 upb 不可战胜,因为它将工作推迟到读取时。但生产代码通常只会解析消息一次,然后分支判断字段、提取少量值、复制消息,最后序列化修改后的版本返回。
upb 在每次读取时都会支付 Python 转换成本。protobuf-py 在第一次读取之后支付零次成本,因为解析过程已经生成了一个普通的 Python 对象。成本在边界处转向了另一端:将数据保留在 Python 中意味着 protobuf-py 在开始时做了更多工作。当代码对消息进行足够多的操作时,这些成本就会被赚回来。
文章给出了一个真实案例:为社交媒体网站用户主页构建响应,同时对比纯粹的序列化/反序列化步骤。数字以相对于 upb 的吞吐量(操作/秒)展示,越高越快:
- 在孤立的序列化/反序列化中,
upb确实领先,符合预期。 - 在端到端场景(服务在生产中实际运行的代码)中,
protobuf-py胜出。每一次upb需要将字段翻译回 Python 的读取操作,都是protobuf-py已经完成的操作。在整个请求过程中,这种差异积累成了足以击败 C 引擎的运行时。
该基准测试的负载以文本
