天天看點

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

作者:位元組跳動技術範兒

對于一個 Rust RPC 架構來說,根據 IDL 做代碼生成是為了讓使用者更友善地使用架構。而生成代碼的品質以及周邊能力都會對使用者的開發體驗有着非常非常直接的影響。

是以,位元組跳動 CloudWeGo 開發了 Pilota 這樣的一個架構,來為使用者生成良好的代碼。

在 2023 年 5 月 28 日 舉行的「GOTC 全球開源技術峰會 - Rust 論壇」上,位元組跳動服務架構研發工程師劉翼飛介紹了 Pilota 這款代碼生成工具,今天我們就為大家介紹這次分享的内容。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享
Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

為什麼需要代碼生成工具

首先,我們為什麼需要代碼生成工具?

因為在 RPC 的世界裡,使用者通過 IDL 這種形式來描述一個服務的接口。

比如說這裡有個 service,service 還提供了一些方法,需要描述這個方法的入參的類型和傳回結構的類型,使用者是通過這個 IDL 去描述的。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

在實際代碼中,我們不能直接去使用這樣的 IDL,是以就需要把 IDL 轉換成 Rust 代碼,交給使用者架構使用。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

這樣的翻譯過程,就是我們的代碼生成工具需要做的事情,即把一份 IDL 翻譯為 Rust 代碼。

同時,還需要做一些編解碼邏輯的生成。

比如說我們對定義的一個 Message 的 trait,需要提供 encode decode 方法,這個 encode 就是把自身結構化的資料編碼到一段二進制資料,而 decode 就是從一段二進制資料中得到一份結構化的資料,這些編解碼邏輯也需要代碼工具去進行具體生成。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

有了我們的代碼生成工具之後,就可以非常友善去調用一次 RPC,隻需要在 built.rs 檔案的那邊去寫上依賴了哪個 IDL 檔案,我們代碼生成工具會給它生成一段代碼,之後隻需要在你的業務邏輯裡通過 include! 宏把生成好的代碼引入,就可以非常友善的構造出一個 client,就像完成一次普通的函數調用一樣來完成一次 RPC 調用。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享
Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

這種方式讓 RPC 調用就變得非常簡單。

生成 Rust 代碼的挑戰

那麼,生成代碼的時候會遇到什麼挑戰?

其實很多時候可能等會想這有什麼複雜的,隻需要把一個 IDL 給轉換成 AST,然後逐行翻譯而已,我們隻需要簡單實作一個 Parser 就行了。其實我們最開始也是這麼想,我們的第一版的代碼生成工具就是用這個思路去做的,非常簡單。

但是後來我們遇到非常多的問題。

比如說我們在 IDL 裡存在一個循環依賴問題,我們存在 struct a 依賴了 struct b。然後 struct b 又依賴了 struct a。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享
Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

如果直接翻譯到 Rust 代碼編譯,編譯器就會報錯,告訴你這兩個 struct 的大小無法計算。

Rust 的世界裡是不允許去這樣去描述循環依賴的,是以必須要通過一些手段來解除這個大小的問題。

比如說在這裡給這些 Field 上加上 Box, 因為 Box 其實就是一個指針,這個結構的大小就是一個指針的大小,那麼 size 計算的這個循環依賴就被解開了。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

那麼我們是如何解決這個問題的呢?

其實非常簡單,就是我們會生成一個圖,IDL 的每一個類型都會變成圖裡的一個節點,節點之間的邊就是引用的feild。比如說結構 a 的話,它是通過這個 a.b 這個去使用了結構 b,然後類型 b 的話通過 b.a 來使用結構 a。

然後現在我們可以新上一個圖,然後我發現這個圖裡面它是存在環的,我們需要檢測出這個圖的環,然後把這個環的東西上全部再加上一個 Box,我們就可以解決這個問題。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

這時候使用者提出了一個新的需求,這說你能不能幫我生成的那些 Rust 常用的 derive macro,比如 Hash 和 Eq。

但是我們是否可以對所有的類型全部都加上 Hash 這個 derive macro 呢?

不行,因為在 Rust 的世界裡,像 float,它其實是沒有實作 Hash 這個 Trait 的。

如果我們對于這樣一個結構去實作給它加上一個 derive Hash,就會得到編譯器的報錯,因為 float64 這個類型并沒有實作 Hash ,是以就需要定一個規則,如果一個結構體裡面的所有字段都可以都實作的 Hash 的話,就需要那麼這個結構是可以 derive Hash。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

舉個例子,有 3 個結構 a,b,c,結構 a 依賴結構 b、結構 c,結構 b 有依賴的結構 a ,然後結構 c 裡面是有一個 double 類型的 feild,對應的 Rust 的類型是 float64。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享
Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

那麼這個時候我們開始要計算哪些結構可以被 Hash。我們首先來看一下結構 a 類型,我們會發現 Hash(A) 的成立取決于 Hash(B) 和 Hash(C) 這兩個命題同時成立,然後再來看一下 field b,那麼 Hash(B) 的成立就取決 Hash(A) 的成立。而 Hash(C) 的成立則取決于 Hash(double) 的成立。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

拿到這三個邏輯命題之後,就需要判斷這些邏輯命題是否成立了。

首先來看一下這個第一個問題 Hash(A),因為 Hash(A) 的成立取決于 Hash(B) 和 Hash(C) 的成立,然後 Hash(B) 的成立取決 Hash(A) 的成立。

如果就這樣非常簡單直白地通過遞歸去處理的話,會發現 a 依賴 b,然後 b 反過來依賴 a,遞歸是永遠不可能逃過去的。

此時就需要用一些手段了。處理 Hash(A) 要計算 Hash(B) 和 Hash(C),在計算 Hash(B) 時,因為要它依賴 Hash(A),而 Hash(A) 處于計算中,是以需要在計算 Hash(B) 的時候建立一個 lazy dependency,稍後計算,先不管它。

此時把 Hash(B) 替換成 Hash(A),再回到 Hash(A)的計算,我們會發現 Hash(A) 依賴于 Hash(C) 和 Hash(A) 的成立。再去處理一下 Hash(C),Hash(A) 成立其實依賴于 Hash(double) 的成立。但是其實 double 這個類型對應的 float32 并沒有實作 Hash,是以最後我們得到一個問題,就是 Hash(A) 的成立,這個命題等價于 false & hash(A), 因為有 false 存在,是以這個命題已經不成立了,是以 Hash(A) 這個命題是不成立的。

此時還有一個問題:現在 Hash(A) 已經計算完,那麼 Hash(B) 呢?因為剛才 Hash(B) 是計算到了一半,處于 lazy dependency 的中間态,需要把 Hash(B) 重新計算一遍。因為已經得出 Hash(A) 不成立,是以可以順利計算出 Hash(B) 不成立。

是以,Hash(A)、Hash(B)、Hash(C) 這三個命題都是不成立的。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

但如果換一個場景,假設 c 結構裡面的字段不是一個 double,它是一個 int32,再按剛才的方式計算一遍,發現 Hash(C) 是成立的,那麼可以得到 Hash(A) 的成立依賴于 Hash(A) 自身的成立。這個情況下我們怎麼處理?

在 Rust 的世界裡,如果一個 struct 有且僅有一個 field,而且這個 field 的類型還是自己本身的話,我們是可以對這個 struct derive hash的。是以,在這種情況下我們可以制定一個規則:如果一個命題隻依賴自己本身的成立,那這個命題是成立的。那麼 Hash(A) 也是成立的。

接下來我們就再回過來計算 Hash(B),因為 Hash(B),之前 lazy dependency Hash(A),那麼因為 Hash(A) 是成立的。然後我在計算 Hash(B) 時候也會得到一個成立的結果,那麼此時 Hash(A), Hash(B), Hash(C) 均成立。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

處理 IDL 的過程中可能還會遇到常量,因為在所有的 thrift 中你是可以通過 const 來去定義一個常量的。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

這個常量又該如何生成呢?

比如 thrift string 這個常量類型,會生成 &'static str 這個類型的常量,Rust 編譯器會對常量進行一些優化,是以我們的代碼生成工具是能夠去為使用者生成 &'static str 類型無疑是更好的。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

但是 thrift 可以允許使用者寫出非常自由的類型。比如通過 const 去指定一個 map 類型,然後 map 裡面有 list 這樣的一個類型。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

但是在 Rust 裡我們是沒辦法通過 const 去表達的,因為在 Rust 裡面 Hashmap、vector 這些類型都涉及到記憶體配置設定。是以需要通過 lazy static 來處理,先建立一個 static reference,裡面是生成了這樣一個構造,這個 Hashmap 和構造這個 vector 的一些構造邏輯。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

那麼會引入什麼問題呢?因為現在的設計裡面存在兩種類型表示,一種是 const,一種是 struct 裡面的。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

現在我們已經針對 const 裡面的 string 類型生成 &'static str。

但是如果我們在 struct 裡的string是怎麼處理的?在 struct 裡的 string 類型 field,它其實很有可能是需要被使用者用來構在一個 request 裡面,或者被服務端作為 response 出來,但是是以使用者需要非常輕松地去構造出這樣的一個field。

但是如果這裡的 string 處理成一個 &'static str 的情況,那麼使用者基本上沒法構造,因為 &'static str 構造的條件要求太高了。

是以此時需要定義一個規則,在 struct 這個 scope 裡面的話, string 裡面定義的 string 這個類型對應的 Rust 類型 string,那麼就會存在兩種類型表示的一個問題。

比如說現在這個“hello world” 這個 literal 但是如果在 const 個 scope 裡面,對應類型是 &'static str,這個表達式可以直接被使用。但是如果是在非 const 這種形式裡面,它需要被轉化成一個 string,是以需要通過 to_owned() 方法轉化成一個 string。但如果情況變得更加複雜,這裡不是一個 literal 而是一個符号,那麼就需要我們的代碼生成工具有一個自己的類型系統,來去處理不同類型之間的轉換的方式。

在一些實踐中,我們還遇到了代碼生成量過大的問題。

在我們的業務代碼裡面,對業務用的 IDL 檔案,可能有幾十個 thrift idl 檔案,然後我們的生成工具生成了 150 萬行 Rust 代碼,一次 cargo check 需要執行 10 分鐘。因為當時我們的代碼生成工具邏輯是會把所有的 IDL 生成的 Rust 代碼全部給放到一個檔案裡面去,是以導緻使用者的操作 check 過程非常慢。

但是在這過程中會發現一個問題,比如說這裡有一個入口檔案 a,它裡面有個 service,這是使用者真的要用到的一個 service,但是這個檔案應該是統計,現在我們發現這兩個檔案裡有 5 個結構,分别是 service、a,aa,b,bb。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

但是其實使用者隻關心入口處的 service,沒有被這個 service 所依賴的結構,使用者都是不關心的。

是以此時我們就可以做出一個優化手段,以這個 service 作為入口節點,掃描哪些結構是被依賴的、哪些結構需要被生成,以減少需要生成的結構。這樣優化之後,之前 150 萬行代碼隻剩下 10 萬行,編譯效率得到大幅度提升。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

在 CloudWeGo 下有一個 RPC 架構 Volo,提供給使用者調用 RPC 的能力,那麼 IDL 是怎麼被 Volo 使用的呢?

我們需要一個代碼生成工具來作為 IDL 和我們的 RPC 架構的一個橋梁,這個工具就是 Pilota,這是我們開源的一個代碼生成工具。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

Pilota 的設計架構

Pilota 的結構看起來很簡單,它的入口就是一個 Parser,Parser 的輸入是一個 IDL,輸出是一個 AST。經過 Naming Resolve 符号解析之後産生一個中間表示 IR,我們會基于這個中間表示去處理循環依賴、類型轉換、依賴收集等問題。最後繼續執行一下使用者自定義的 Plugin,把這個最終的結果交給 Backend 生産代碼。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

首先,我們來看一下 Parser 部分,現在我們 Parser 支援Thrift 和 Protobuf 兩種,它的輸出就是一個 AST。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

隻要 IDL 格式能夠被轉換到 Pilota 的 AST 表示,那麼就可以接入 Pilota 這套體系,上面提到的那些複雜問題都可以很輕松被解決。

當我們拿到 AST 之後,就需要進入 Naming Resolve 階段。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

其實 Pilota 的 AST 已經非常接近 Rust 的表示形式了,隻不過這裡可能會有一些特殊之處,因為存在一些同名符号,比如 Mod Test 和 Struct Test

在 Pilota 的 AST 裡是允許同名符号存在的,但是這樣會給符号解析帶來一些挑戰。為什麼要去允許這樣一個設計呢?

因為在 Protobuf 會有一個非常特殊的設計,就是 Nested Item,你可以在 message 裡面再去定義 message,但是在 Rust 裡面不能在 Struct 裡面再去定義 Struct,是以為了支援 Protobuf 這種特性就需要 Pilota 的 AST 可以表達這種形式。對于這種形式,在 Pilota 生成 Struct 同時 還會再生成一個同名的 Mod 用于相關的 nested item。

是以當同名符号出現,就會像 Rust 一樣,在 Naming resolve 這個過程使用不同的 namespace,因為 Rust 現在 Naming Resolve 過程中,它其實是會存在兩種 namespace,一個是 Type,還有一種是 Value Pilota 裡也有類似的設計,會劃分為三種不同的 namespace,分别是 type、value、Mod。

先看一下這個 AST 結構圖,這些檔案裡面所有的 item 全部周遊一次,給符号配置設定好 ID。

Pilota:為什麼一個代碼生成工具如此複雜丨GOTC Rust系列分享

配置設定完成這個 ID 之後的話,就需要計算出每個結構體,每個 field 裡面的 path 到底指向哪個 id。

但是同名的符号可能會引入歧義問題,那麼就需要需要根據 namespace 對不同的解析結果進行篩選。

在 Rust 中會有一些常用的第三方庫的 derive macro,比如 serde。那麼就需要 Pilota 提供一種靈活的能力來讓使用者自定義生成代碼裡的 attributes。

在 Pilota 每個 item field varaint 都會有一個對應的 adjust 字段,使用者可以通過寫一個plugin,通路到 adjust 的這個結構,然後通過修改裡面 attributes 來自定義生成代碼中的 attributes 和使用 nested_item 字段來控制額外生成的 Rust 代碼比如 Impl Block。

Pilota 的未來

解決這些問題之後,Pilota 已經能滿足絕大部分場景的使用了。但 Pilota 也在探索一些不同的代碼生成方式。

有一些開發者表示生成的代碼量還是挺大的,即使經過這些優化之後,代碼量仍然有 5W-10W 左右,而且有的業務方也希望他們不想感覺到 IDL 的存在,但是在現在的體系裡使用者必須要通過 IDL 來生成代碼。但是能不能讓使用者換一種使用方式呢?

比如使用者希望根據三個 IDL 生成對應的服務代碼,那麼可以生成 3 個 crate 來友善使用者使用。

但是在這 3 個 crate 中可能會存在一些被同時使用的結構,就需要一個 coomon crate 來存放這樣的結構。這種生成方式會給我們帶來什麼好處呢?

首先可以充分利用編譯器的緩存,因為現在的 Rust 編譯的緩存其實是一個 crate 級别的,比如說原本可能生成了十幾萬行代碼,十幾萬行代碼在業務裡面被放到一個 crare。現在生成多個 crate,可能十幾萬行代碼已經被完全放到 6 個 crate 裡面,每個 crate 隻分擔了幾萬行代碼量,緩存粒度更細了,當一個 crate 的代碼發生更新,隻需要重新編譯這幾萬行代碼就行了。

在生成了這些 crate 之後,開發者可以使用 cargo workspace 的方式來管理,然後将這些 crate 釋出到 git上或者其他地方。那麼其他開發者可以直接使用已經生成好的代碼完成 RPC 調用,也不需要去關心 IDL 的存在了。

好的,這就是今天的分享了,謝謝大家。

GitHub 位址:

https://github.com/cloudwego/pilota