Rust Proto 设计决策
与任何库一样,Rust Protobuf 的设计考虑了 Google 内部 Rust 使用以及外部用户的需求。在这种设计空间中选择一条路径意味着,在某些情况下,某些选择对于某些用户来说可能不是最佳的,即使它是整个实现的最佳选择。
本页面涵盖了 Rust Protobuf 实现做出的一些重大设计决策以及导致这些决策的考虑因素。
旨在“由”其他 Protobuf 实现(包括 C++ Protobuf)支持
Protobuf Rust 并非 protobuf 的纯 Rust 实现,而是在现有 protobuf 实现(我们称之为:内核)之上实现的 Rust 安全 API。
这一决策的最大因素是,能够以零成本将 Rust 添加到已使用非 Rust Protobuf 的现有二进制文件中。通过使实现与 C++ Protobuf 生成的代码 ABI 兼容,可以跨语言边界 (FFI) 以普通指针形式共享 Protobuf 消息,从而避免了在一种语言中进行序列化,将字节数组跨边界传递,然后在另一种语言中进行反序列化的需要。这也通过避免为每种语言的相同消息在二进制文件中嵌入冗余的模式信息,从而减少了这些用例的二进制文件大小。
Google 将 Rust 视为一个机会,可以逐步为现有棕地 C++ 服务器的关键部分实现内存安全;语言边界的序列化成本将阻止 Rust 在许多重要且性能敏感的案例中取代 C++。如果我们追求一个没有这种支持的绿地 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 提供支持,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 用法。
视图/可变代理类型
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 可能会选择创建我们自己的类型,即使存在具有相同名称的相应标准类型,并且当前的实现甚至可能只是封装标准类型,例如 protobuf::UTF8Error。
使用这些类型而不是标准类型使我们将来在优化实现方面拥有更大的灵活性。尽管我们当前的实现使用 Rust 标准 UTF-8 验证,但通过创建我们自己的 protobuf::Utf8Error 类型,它使我们能够更改实现以使用我们从 C++ Protobuf 中使用的经过高度优化的 C++ UTF-8 验证实现,该实现比 Rust 的标准 UTF-8 验证更快。
ProtoString
Rust 的 str 和 std::string::String 类型严格保持它们只包含有效 UTF-8 的不变性,但 C++ 的 std::string 类型不强制执行任何此类保证。string 类型的 Protobuf 字段旨在只包含有效 UTF-8,并且 C++ Protobuf 确实使用了正确且高度优化的 UTF8 验证器。然而,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 用法细节的演变,这一决定是否正确。