Rust Proto 設計決策

說明 Rust Proto 實作所做的一些設計選擇。

如同任何程式庫,Rust Protobuf 的設計考量了 Google 內部對 Rust 的使用需求以及外部使用者的需求。在該設計空間中選擇一條路徑意味著,即使某些選擇對整體實作而言是正確的,但在某些情況下,對於某些使用者而言,這些選擇可能並非最佳。

本頁涵蓋了 Rust Protobuf 實作所做的一些較大的設計決策,以及導致這些決策的考量因素。

設計為由其他 Protobuf 實作「支援」,包括 C++ Protobuf

Protobuf Rust 不是 protobuf 的純 Rust 實作,而是一個安全的 Rust API,實作於現有的 protobuf 實作之上,或者我們稱這些實作為:核心 (kernels)。

此決策的最大考量因素是為了實現將 Rust 加入已使用非 Rust Protobuf 的現有二進制檔案時的零成本。透過使實作與 C++ Protobuf 產生的程式碼 ABI 相容,可以將 Protobuf 訊息以純指標的形式跨語言邊界 (FFI) 共享,從而避免了在一個語言中序列化、跨邊界傳遞位元組陣列,然後在另一種語言中反序列化的需要。這也減少了這些用例的二進制檔案大小,因為避免了為每種語言的相同訊息在二進制檔案中嵌入冗餘的結構描述資訊。

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 核心的決策顯著影響了我們的公開 API 決策,包括 getter 上使用的類型(將在本文件中稍後討論)。

沒有純 Rust 核心

鑑於我們將 API 設計為可由多個後端實作來實作,一個自然的問題是,為什麼目前唯一支援的核心是以記憶體不安全的 C 和 C++ 語言編寫的。

雖然 Rust 作為一種記憶體安全語言可以顯著減少暴露於嚴重安全問題的風險,但沒有任何語言可以免於安全問題。我們作為核心支援的 Protobuf 實作已經過仔細檢查和模糊測試,以至於 Google 可以放心地使用這些實作來執行我們自己的伺服器和應用程式中不受沙箱保護的非受信任輸入的解析。目前以 Rust 編寫的全新二進制剖析器,普遍認為比現有的 C++ Protobuf 剖析器更有可能包含嚴重漏洞。

長期支援純 Rust 實作有其合理的論點,包括開發人員在開放原始碼中使用我們的實作時遇到的工具鏈困難。

合理假設 Google 將在稍後的日期支援純 Rust 實作,但我們目前尚未對此進行投資,也沒有具體的路線圖。

View/Mut 代理類型

Rust Proto API 設計為具有不透明的「代理 (Proxy)」類型。對於定義 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++ Heap 上。當您稍後存取子訊息時,將沒有已分配的 Rust 物件對應於該子訊息,因此在那一刻沒有 Rust 實例可以借用。

透過使用代理類型,我們能夠按需建立在語義上充當借用的 Rust 代理類型,而無需預先為這些實例預先分配任何 Rust 記憶體。

非標準類型

可能具有直接對應標準類型的簡單類型

在某些情況下,Rust Protobuf API 可能會選擇建立我們自己的類型,其中存在具有相同名稱的對應 std 類型,其中目前的實作甚至可能只是簡單地包裝 std 類型,例如 proto::UTF-8Error

使用這些類型而不是 std 類型,使我們在未來優化實作方面具有更大的彈性。雖然我們目前的實作今天使用 Rust std UTF-8 驗證,但透過建立我們自己的 proto::Utf8Error 類型,我們能夠更改實作以使用我們從 C++ Protobuf 中使用的高度最佳化的 C++ UTF-8 驗證實作,它比 Rust 的 std UTF-8 驗證更快。

ProtoString

Rust 的 strstd::string::String 類型維持嚴格的不變性,即它們僅包含有效的 UTF-8,但 C++ Protobuf 和 C++ 的 std::string 類型通常不強制執行任何此類保證。string 類型的 Protobuf 欄位旨在僅包含有效的 UTF-8,但是此強制執行存在許多漏洞,其中 string 欄位最終可能在執行階段包含無效的 UTF-8 內容。

為了實現 C++ 和 Rust 之間零成本的訊息共享,同時最大限度地減少昂貴的驗證或 Rust 中未定義行為的風險,我們選擇不對 string 欄位 getter 使用 str/String 類型,而是引入了類型 ProtoStrProtoString,它們是等效類型,但除了在極少數情況下可能包含無效的 UTF-8。這些類型讓應用程式碼選擇是否希望按需執行驗證以取得 &str,或對原始位元組進行操作以避免任何驗證。

我們知道像 str 這樣的詞彙類型對於慣用用法非常重要,並且打算密切關注隨著 Rust 使用細節的發展,此決策是否正確。