Rust Proto 設計決策

解釋了 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 型別 SomeMsgSomeMsgView<'_>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 的 strstd::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 型別。相反,使用了 ProtoStrProtoString 型別,它們是等效型別,只是它們在極少數情況下可能包含無效的 UTF-8。這些型別允許應用程式程式碼選擇是否希望按需執行驗證以將欄位視為 Result<&str>,或者對原始位元組進行操作以避免任何執行時驗證。所有 setter 路徑仍然設計為允許您傳遞 &strString 型別。

我們知道,像 str 這樣的詞彙型別對於慣用用法非常重要,並打算密切關注隨著 Rust 用法細節的演變,這一決定是否正確。