Rust Proto 设计决策
与任何库一样,Rust Protobuf 的设计同时考虑了 Google 内部对 Rust 的第一方使用需求以及外部用户的需求。在设计空间中选择一条路径意味着,即使某些选择对于整个实现来说是正确的,但在某些情况下对某些用户而言并非最优。
本页面涵盖了 Rust Protobuf 实现所做出的一些较大设计决策以及导致这些决策的考量。
设计为由其他 Protobuf 实现“支持”,包括 C++ Protobuf
Protobuf Rust 并非 protobuf 的纯 Rust 实现,而是在现有 protobuf 实现之上构建的安全 Rust API,我们称这些实现为:内核。
做出这个决定的最大因素是,为了在一个已经使用非 Rust Protobuf 的现有二进制文件中添加 Rust 时实现零成本。通过使实现与 C++ Protobuf 生成的代码 ABI 兼容,可以在语言边界(FFI)之间以普通指针的形式共享 Protobuf 消息,从而避免了需要在一个语言中序列化,将字节数组传递到边界另一侧,然后在另一个语言中反序列化的过程。这也通过避免为每种语言的相同消息在二进制文件中嵌入冗余的模式信息,从而减少了这些用例的二进制文件大小。
Protobuf Rust 目前支持三种内核:
- C++ 内核 - 生成的代码由 C++ Protocol Buffers(“完整”实现,通常用于服务器)支持。该内核提供了与使用 C++ 运行时的 C++ 代码的内存中互操作性。这是 Google 内部服务器的默认选项。
- C++ Lite 内核 - 生成的代码由 C++ Lite Protocol Buffers(通常用于移动设备)支持。该内核提供了与使用 C++ Lite 运行时的 C++ 代码的内存中互操作性。这是 Google 内部移动应用的默认选项。
- upb 内核 - 生成的代码由 upb 支持,这是一个用 C 编写的高性能、小二进制体积的 Protobuf 库。upb 被设计为其他语言中 Protobuf 运行时的实现细节。这是开源构建中的默认选项,因为我们预计与已经使用 C++ Protobuf 的代码进行静态链接的情况会比较少见。
支持多种非 Rust 内核的决定极大地影响了我们的公共 API 决策,包括用于 getter 的类型(本文档后面将讨论)。
没有纯 Rust 内核
鉴于我们将 API 设计为可由多个后端实现,一个自然的问题是,为什么目前唯一支持的内核都是用内存不安全的 C 和 C++ 语言编写的。
虽然 Rust 作为一种内存安全的语言可以显著减少暴露于严重安全问题的风险,但没有一种语言能对安全问题免疫。我们作为内核支持的 Protobuf 实现已经经过了严格的审查和模糊测试,其程度足以让 Google 放心在自己的服务器和应用中,使用这些实现来对非沙箱环境下的不受信任输入进行解析。目前,一个用 Rust 编写的新二进制解析器,被认为比预先存在的 C++ Protobuf 解析器更有可能包含严重漏洞。
对于长期支持纯 Rust 实现存在合理的论据,包括为在开源中使用我们实现的开发者解决工具链困难的问题。
可以合理假设 Google 将来某个时候会支持纯 Rust 实现,但我们目前并未对此进行投入,也没有具体的路线图。
View/Mut 代理类型
Rust Proto API 是用不透明的“代理”类型设计的。对于一个定义了 message SomeMsg {}
的 .proto
文件,我们会生成 Rust 类型 SomeMsg
、SomeMsgView<'_>
和 SomeMsgMut<'_>
。一个简单的经验法则是,我们期望 View 和 Mut 类型在所有默认用法中替代 &SomeMsg
和 &mut SomeMsg
,同时仍然能获得您期望从这些类型中得到的借用检查/Send/等所有行为。
理解这些类型的另一个视角
为了更好地理解这些类型的细微差别,将它们按以下方式思考可能会有所帮助:
struct SomeMsg(Box<cpp::SomeMsg>);
struct SomeMsgView<'a>(&'a cpp::SomeMsg);
struct SomeMsgMut<'a>(&'a mut cpp::SomeMsg);
从这个视角你可以看到:
- 给定一个
&SomeMsg
,可以得到一个SomeMsgView
(类似于给定一个&Box<T>
,你可以得到一个&T
)。 - 给定一个
SomeMsgView
,不可能得到一个&SomeMsg
(类似于给定一个&T
,你无法得到一个&Box<T>
)。
就像 &Box
的例子一样,这意味着在函数参数上,通常最好默认使用 SomeMsgView<'a>
而不是 &'a SomeMsg
,因为这样可以让更大范围的调用者使用该函数。
原因
这种设计有两个主要原因:为了释放可能的优化好处,以及作为内核设计的内在结果。
优化机会的好处
Protobuf 作为一项核心且广泛使用的技术,使其异常地既容易被用户依赖于所有可观察的行为,同时相对较小的优化又能在规模化应用中产生非常大的净影响。我们发现,类型的更高不透明度能提供异乎寻常的杠杆作用:它们允许我们更审慎地决定暴露哪些行为,并为我们优化实现提供了更多空间。
SomeMsgMut<'_>
提供了 &mut SomeMsg
所不具备的这些机会:即我们可以懒加载地构造它们,并且其实现细节可以与所有权消息的表示不同。它还内在地允许我们控制某些我们无法限制或控制的行为:例如,任何 &mut
都可以与 std::mem::swap()
一起使用,如果将 &mut SomeChild
提供给调用者,这种行为将对您能够在父结构和子结构之间维持的不变性施加强大的限制。
内核设计的内在原因
代理类型的另一个原因更多地是我们内核设计的内在限制;当您有一个 &T
时,内存中必须存在一个真实的 Rust T
类型。
我们的 C++ 内核设计允许您解析一个包含嵌套消息的消息,并且只创建一个小的、分配在 Rust 栈上的对象来表示根消息,所有其他内存都存储在 C++ 堆上。当您稍后访问一个子消息时,将不会有已经分配的、对应于该子消息的 Rust 对象,因此那一刻也就没有 Rust 实例可以借用。
通过使用代理类型,我们能够按需创建在语义上充当借用的 Rust 代理类型,而无需为这些实例预先分配任何 Rust 内存。
非标准类型
可能有直接对应标准类型的简单类型
在某些情况下,Rust Protobuf API 可能会选择创建我们自己的类型,即使存在同名的相应 std 类型,并且当前实现甚至可能只是简单地包装了 std 类型,例如 protobuf::UTF8Error
。
使用这些类型而不是 std 类型,为我们将来优化实现提供了更大的灵活性。虽然我们当前的实现今天使用了 Rust std 的 UTF-8 验证,但通过创建我们自己的 protobuf::Utf8Error
类型,使我们能够将实现更改为使用 C++ Protobuf 中高度优化的 C++ UTF-8 验证实现,该实现比 Rust 的 std UTF-8 验证更快。
ProtoString
Rust 的 str
和 std::string::String
类型维持一个严格的不变性,即它们只包含有效的 UTF-8,但 C++ Protobuf 和 C++ 的 std::string
类型通常不强制执行任何此类保证。string
类型的 Protobuf 字段意在只包含有效的 UTF-8,并且 C++ Protobuf 使用了一个正确且高度优化的 UTF-8 验证器。C++ Protobuf 的 API 接口并未设置为严格强制一个运行时不变性,即 string
字段总是包含有效的 UTF-8(相反,它将任何验证推迟到序列化或后续的解析时进行)。
为了能够将 Rust 集成到使用 C++ Protobuf 的现有代码库中,同时最大限度地减少不必要的验证或在 Rust 中出现未定义行为的风险,我们选择不在 string
字段的 getter 中使用 str
/String
类型。我们引入了 ProtoStr
和 ProtoString
类型,它们是等效的类型,只是在极少数情况下可能包含无效的 UTF-8。这些类型让应用程序代码可以选择是希望按需执行验证以将字段视为 Result<&str>
,还是直接操作原始字节以避免任何运行时验证。
我们意识到像 str
这样的词汇类型对于地道用法非常重要,并打算随着 Rust 用法细节的演变,持续关注这个决定是否正确。