Rust Proto 设计决策
与任何库一样,Rust Protobuf 的设计同时考虑了 Google 内部对 Rust 的使用需求以及外部用户的需求。在这种设计空间中选择一条路径意味着,某些选择对于某些用户在某些情况下可能不是最优的,即使它对整体实现来说是正确的选择。
本页介绍 Rust Protobuf 实现所做出的一些较大的设计决策以及导致这些决策的考虑因素。
设计为由其他 Protobuf 实现(包括 C++ Protobuf)“支持”
Protobuf Rust 并非 protobuf 的纯 Rust 实现,而是在现有 protobuf 实现之上构建的安全 Rust API,我们将这些实现称为:内核 (kernels)。
做出这个决定的最大因素是,希望在已使用非 Rust Protobuf 的现有二进制文件中添加 Rust 时能实现零成本。通过使实现与 C++ Protobuf 生成的代码 ABI 兼容,可以在语言边界 (FFI) 之间以普通指针的形式共享 Protobuf 消息,从而避免了在一个语言中序列化,将字节数组传递过边界,然后在另一个语言中反序列化的需要。这也通过避免在二进制文件中为每种语言的相同消息嵌入冗余的模式信息,从而减少了这些用例的二进制文件大小。
Google 认为 Rust 是一个机会,可以逐步为已有的 brownfield C++ 服务器的关键部分带来内存安全;在语言边界进行序列化的成本会阻碍在许多重要且对性能敏感的场景中采用 Rust 来替代 C++。如果我们追求一个不具备此支持的 greenfield Rust Protobuf 实现,最终将阻碍 Rust 的采用,并要求这些重要场景继续使用 C++。
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 Protobuf 的设计旨在支持多种备用实现(包括多种不同的内存布局),同时暴露完全相同的 API,从而允许相同的应用程序代码在重新编译后可以由不同的实现支持。这一设计约束极大地影响了我们的公共 API 决策,包括用于 getter 的类型(本文档后面会讨论)。
没有纯 Rust 内核
鉴于我们将 API 设计为可由多个后端实现支持,一个自然的问题是,为什么目前唯一支持的内核都是用内存不安全的 C 和 C++ 语言编写的。
虽然 Rust 作为一种内存安全的语言可以显著减少暴露于严重安全问题的风险,但没有一种语言能对安全问题免疫。我们作为内核支持的 Protobuf 实现已经过严格审查和模糊测试,其程度足以让 Google 放心在自己的服务器和应用中,用这些实现对不受信任的输入进行非沙箱化解析。
一个用 Rust 编写的全新二进制解析器,在目前这个阶段,被认为比我们现有的 C++ Protobuf 或 upb 解析器更有可能包含严重漏洞,后者已经过广泛的模糊测试、测试和审查。
支持纯 Rust 内核实现的长期观点有其合理性,包括开发者可以避免在构建时需要 Clang 来编译 C 代码。
我们预计 Google 将在未来的某个时间点支持一个具有相同暴露 API 的纯 Rust 实现,但目前没有具体的路线图。我们没有计划推出第二个官方的 Rust Protobuf 实现,该实现通过避免由 C++ Proto 和 upb 支持所带来的限制来提供“更好”的 API,因为我们不希望分裂 Google 内部的 Protobuf 使用生态。
View/Mut 代理类型
Rust Proto API 是用不透明的“代理”(Proxy) 类型设计的。对于一个定义了 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++ 的 std::string 类型不强制执行任何此类保证。类型为 string 的 Protobuf 字段旨在只包含有效的 UTF-8,并且 C++ Protobuf 确实使用了一个正确且高度优化的 UTF-8 验证器。然而,C++ Protobuf 的 API 表面并未设置为严格强制其 string 字段始终包含有效 UTF-8 作为一个运行时不变量,相反,在某些情况下,它允许将非 UTF-8 数据设置到 string 字段中,验证只会在稍后序列化时发生。
为了能够将 Rust 集成到使用 C++ Protobuf 的现有代码库中,同时允许零成本的边界穿越且没有 Rust 中未定义行为的风险,我们不幸地必须避免在 string 字段的 getter 中使用 str/String 类型。取而代之的是使用 ProtoStr 和 ProtoString 类型,它们是等效的类型,只是在极少数情况下可能包含无效的 UTF-8。这些类型让应用程序代码可以选择是希望按需执行验证以将字段视为 Result<&str>,还是直接操作原始字节以避免任何运行时验证。所有的 setter 路径仍然设计为允许您传递 &str 或 String 类型。
我们意识到像 str 这样的词汇类型对于惯用的用法非常重要,并打算随着 Rust 使用细节的演变,持续关注这一决定是否正确。