當我有一個想法,並且這個想法很有意思,正好戳中我技能的盲區時,我便有一種強大的要將其實驗一番的衝動。自從上周做一個「前端中的後端」的想法出爐後,這周我幾乎寢食難安,隨時隨地都在想這件事,所以後來乾脆擼起袖子開幹,畢竟 Linus 大神告誡我們:
一旦開幹,就有些摟不住了,每日正常工作開會帶娃做飯之餘,我幾乎是 7-12-7 地將其一點點折騰出來,為了優化每一分時間,我甚至把哄小貝睡覺的時間從平均一個小時縮減到 25 分鐘(訣竅是:唱搖籃曲的時候不斷地假裝打哈欠 —— 哈欠是會傳染的)。
這種沉浸式的,集中精神全力以赴做一件事的感覺讓我很快樂。在這個過程中,我第一次正式寫 swift,就被迫在 Data,UsafeRawBufferPoiner 和 UnsafePointer<UInt8> 之間遊躥,不得不深入到 xcodebuild / swift package / xcframework 的細節去把一切東西自動化地在 CI 中完整地串聯起來。當你真正深入去做一件事情的時候,你會發現,你的認知和實際情況相差很大 —— 比如:和我花在 swift package 上編譯 static library 所花的巨大精力相比,在Rust 上構建 FFI 代碼的過程簡直就像閒庭信步,真是大大出乎了我的意料。
當我最終在 xcode 裡測試通過 swift 和 rust 交互的整個流程,並且將其運行在 github action(使用 ubuntu 而不是 osx)做了一個相對完整的 CI 後,可想而知,我有多麼興奮:
更令人興奮的是,在整個過程中,我學到了:
如何更好地定製化 prost build,讓生成的 rust 的 protobuf 代碼能夠完美兼容不夠嚴謹的 JSON 數據。
如何生成 rust 代碼的 flamegraph,來更好地剖析代碼中的低效的部分,然後結合 citerion 做 benchmark,來優化和提升代碼運行的效率 —— 通過這個過程,我把一個不起眼的函數的效率提升了幾乎一倍。
如何使用 Mozilla 提供的 ffi-support,讓跨語言調用時即便 Rust 側 panic,整個應用程式也不會崩潰。
如何更好地拆分 rust crate,讓 unit test 變得更加簡單。
如何更合理地使用 unsafe。這是我第一個真正使用 unsafe Rust 的項目。嗯,不少心得。
如何使用 tokio/future runtime,使其可以把任務從調用的線程(swift 線程)轉交給一組 Rust 的線程,並通過 callback 返回。這個其實很簡單的工作,由於我一開始思路錯了,導致走了很多彎路。
如何寫包含 unit test,formatter,linter 的嚴肅的 swift 代碼(嗯,我之前為了學語言寫過 playground 代碼和 swift UI,但沒有正經寫過包含單元測試的 Swift 代碼)。
如何使用 swift protobuf 和在 swift 上做 performance benchmark。
如何使用 swift package manager,以及如何在 xcode 裡連結靜態庫。
如何把靜態庫打包成 xcframework(很遺憾,arm 的靜態庫目前還無法成功打包進去)。
如何優雅地撰寫複雜的 Makefile。
這些學到的內容也許值得寫好幾篇文章,就看我有沒有時間,以及有沒有心情了。在做這個 POC 的時候,我糾結過,是用一套公開的 API 來撰寫一個開源的 POC 項目,還是特定對於 Tubi 的業務做一個更貼近生產環境的閉源 POC 項目。幾經思考之後,我決定還是做成一個閉源 POC 項目,因為這樣可以更好地通過已有的業務來更好地評估「前端中的後端」這件事情的難度以及意義。等一切坑都趟平後,我會在做 quenya client 端代碼自動生成時,將這個流程及代碼生成結合起來,做一套通過 OpenAPI spec 生成 Rust 代碼,用於 FFI 的 protobuf 定義,以及對應的 swift/kotlin/typescript 的 binding 的代碼。這將是另外一個故事了。
好,廢話不多說。我們來具體講講實現過程中我關於架構,設計,以及具體編碼過程中的一些思考。我寫的項目名字叫 olorin:olorin 是 Gandalf 的另外一個名字,就像 Gandalf 聯合起護戒小分隊一樣,我希望這個項目可以將 iOS/android/web/osx/windows 很好地聯合起來。
架構和設計如果你看過上一篇文章,那麼你還大概記得這樣一個架構:
以及一個設想中的 API 的實現流程:
olorin 的實現幾乎完全按照這個架構完成:
更為具體的流程見下圖:
這裡面,FFI 接口是至關重要的,它包括下面幾個函數:
service_init
Rust 側的初始化。Swift 代碼提供一個用於初始化的 protobuf 字節流的指針和長度,Rust 側創建對應的運行時,然後返回給 Swift 一個句柄,供以後的請求使用。這個請求一般是 app 啟動時調用。Swift 可以提供一些基本的伺服器請求參數,比如設備 ID,平臺,用戶 ID,要請求的伺服器域名(prod/staging/dev)等信息。Rust 代碼會利用設備 ID 和用戶 ID(如果存在)在本地存儲裡查找是否有之前儲存的用戶狀態,如果有,就加載到 State 中;如果沒有,就創建新的 State。
service_dispatch/service_dispatch_block這兩個函數一個用於異步請求,一個用於同步請求。同步請求會阻塞 Swift 代碼所在的線程;而異步請求則在不同的線程執行,完成之後調用 Swift 側提供的 callback,提交結果。
請求的時候會提供之前獲取的句柄,來找到對應的 Rust 運行時及狀態。此外,還要提供請求所包含的 protobuf 字節流的指針和長度。因為所有的請求都走這一個接口,所以它被封裝成為 protobuf 的一個 oneof message,如下所示(有刪減):
這種通過使用 oneof 來統一調用接口的方法,我是跟 Tendermint 的 ABCI 學的,非常好用。這樣,我們在處理請求的時候,就可以根據其類型進行相應的 dispatch 了:
之所以提供一個同步和一個異步的接口,完全是為了客戶端靈活而設置的。我自己沒有做過生產環境的客戶端,不知道哪種方式最適合客戶端使用,所以乾脆都提供了。好在對於 Tokio 來說,不過是 spawn 和 block_on 的區別而已。
我看了 Firefox sync 的部分代碼,它只提供了同步調用的接口,所以整體上的設計比我這裡所列的要簡單。其實同步調用挺好的,不容易出錯。
service_dispatch 接口具體在 Rust 中的實現並不困難。我們只需要了解如何做 Rust C FFI 即可。其實沒什麼神秘的,只需要注意三點:
一個完整流程我們看一個從 Swift 到 Rust 的完整的 Ping/Pong 的代碼,看看具體是怎麼運作的。
首先在 Swift 側,我們先初始化 service 結構。初始化的時候會調用 Rust 側的初始化,生成上文我們所說的 runtime/state。
當我們在 Swift 裡調用 service.ping 時,會先生成一個 AbiRequestPing。這是我用 Apple 官方的 swift protobuf 庫,基於我定義的 protobuf 生成的結構。由於 Swift import 一個庫之後,所有的結構就無需 namespace 可以直接訪問,所以我加了一個前綴(在 protobuf 定義:option swift_prefix="Abi"),一來好找,二來避免和其它數據結構衝突。
生成好 AbiRequestPing 後,需要將其進一步封裝到 AbiNativeRequest(見上文的 protobuf 定義),然後將其序列化成字節流。因為接下來要將這個字節流傳給 Rust,所以我們需要將其轉換成 UnsafeByte<UInt8>。之後調用 service_dispatch_block,同步返回結果 —— 為了簡單起見,我們先不看異步的流程。這個結果是一個 ByteBuffer 結構。這是 Rust 傳給 Swift 的指針,所以我們需要將其處理成一個 UnsafeRawBufferPointer,封裝成 Data,再反序列化成 AbiResponsePong。
這裡面的核心是 rustCall 函數,它負責處理和內存安全相關的代碼,我們先放下不表。
Rust 側的 service_dispatch_block,會把傳入的指針轉換成 Vec<u8>,然後再反序列化成 NativeRequest,就可以正常使用了。
內存管理
這時候,你可能會想到:數據在 Swift 和 Rust 間傳來傳去,究竟誰應該負責清理內存?
答案是:誰原本擁有的內存,誰負責釋放。
Swift 側是調用方,其傳遞給 Rust 的內存都在 withUnsafeBytes 閉包中,Rust 函數調用棧結束後,對該內存的引用消失,所以沒有內存洩漏的危險,不需要手工處理。
Rust 是被調方,內存傳遞給 Swift 後,並不知道 Swift 會何時何地結束引用,所以 Rust 自己的所有權模型被略過(因為使用了 unsafe),需要手工「釋放」。釋放的原則:
任何 Rust 傳給 Swift 的 buffer,包括各種指針和字符串(字符串也是指針,但往往會被人忽略),都需要手工釋放。
所謂的「釋放」,只不過是把原來的指針再還給 Rust,並由 Rust 代碼從指針中構建數據結構來重新「擁有」這塊內存,這樣 Rust 的所有權模型會接管並在合適的時候進行釋放。
當「擁有」這塊內存的 Rust 函數結束後,內存被回收。
這也就意味著 Rust 代碼需要為自己傳出去的內存提供回收的方法,供 Swift 使用。上文中提到的 FFI 接口,有兩個函數:rust_bytebuffer_free 和 rust_str_free 是負責做這個事情的。因為我們兩個語言之間交互的主要接口就幾個,而涉及的指針,只有以下兩種,所以我們只需要相應地處理:
我們看剛才被忽略的 rustCall 代碼:
如果你仔細看這段 Swift 代碼,你可能會非常疑惑,這裡沒有調用 rust_str_free 的代碼釋放包含錯誤消息的字符串啊?
這裡用了 Swift 的一個很有用的模式:使用參數標籤來擴展已有的功能。Swift 有著非常強大的 extension 能力[2],輔以參數標籤,能力爆表:
這段代碼裡我只需擴展 String,為其 init 函數增加一個我自己的會「歸還」Rust 指針並初始化字符串的實現即可。
說句題外話,初學 Swift 的時候,我覺得函數的參數標籤是個非常雞肋的功能,邊寫邊吐槽它的繁瑣(對於一個不太使用 xcode,大部分時候在 vscode 寫代碼的人來說,需要額外敲很多鍵),後來發現參數標籤可以用作重載,臥槽,對我這個 Swift 小白來說,簡直就是如獲至寶。現在我已經離不開參數標籤,並且開始吐槽:為啥 Rust 不支持參數標籤(及重載)?
錯誤處理跨語言的錯誤處理是一個很有意思的技術活。我們需要回答一個核心問題:如何把 Rust 代碼的錯誤 Resut<T, E>,優雅地轉化成 Swift 裡的 Exception?
一種思路是,把 Result<T, E> 中的 E ,也就是 Error,轉化成一個 C 的結構體,包含錯誤碼 (enum)和錯誤消息(char *),然後在 Swift 側,利用這個信息重組並拋出異常。
另一種思路是,Rust 代碼中返回的 protobuf 中包含錯誤信息,然後在 Swift 側,查看這一信息並在需要的時候拋出異常。
因為我已經在使用 protobuf 來傳遞數據,所以我更加喜歡第二種思路的處理方式:簡潔且沒有額外的內存需要釋放,然而,我使用的庫 ffi-support 在其封裝的 FFI 調用接口上,強行安置了 ExternalError 這個參數,使得我只能使用第一種思路。
如果你再看一眼 service_dispatch_block 的實現,會對下面這個閉包式的調用感到困惑:call_with_result 為什麼要設計成這樣的形式?
這是因為其它語言調用 Rust 的時候,Rust 代碼有可能 panic(比如 unwrap() 失敗),這將會直接導致調用的線程崩潰,從而可能讓整個應用崩潰。從開發的角度,我們應該避免任何代碼主動產生 panic,而是要把所有錯誤封裝到 Result 中,但因為我們的代碼會調用第三方庫,我們無法保證所有第三方庫都嚴格這樣處理。對於 Swift 代碼來說,Rust 代碼所提供的庫是一個黑盒,它理應保證不會出現任何會導致崩潰的行為。所以,我們需要一旦遇到 panic 時,能夠進行棧展開(stack unwinding)。
我們知道,當函數正常調用結束後,其調用棧會返回到調用之前的狀態 —— 你可以寫一段簡單的 C 代碼,編譯成 .o,然後用 objdump 來查看編譯器自動插入的棧展開代碼。然而,當一層層調用,棧不斷累積的時候,如果內層的函數拋出了異常,而很外面的函數才捕獲這個異常,那麼,(支持異常處理的)編譯器會插入回溯代碼,一路把棧回溯到捕獲異常的位置。在這個過程中,涉及到的上下文中所有的棧對象和用智能指針管理的堆對象都會並回收,不會有內存洩漏(對於 C++ 來說,非智能指針分配出的對象會洩漏)。對於 Rust 來說,棧展開是內存安全的,不會有任何內存洩漏。下圖是我在 google image 裡找到的關於棧展開不錯的實例[3](我自己就懶得畫了):
所以 call_with_result 就是為了保證在 FFI 這一層,所有調用的代碼都有合適的棧展開代碼來把任何潛在的 panic 捕獲到並回溯堆棧,讓 Swift(或者其他語言)的代碼就像經歷了一次異常。只要 Swift 代碼捕獲這個異常,那麼程序依舊能夠正常處理。call_with_result 的具體實現如下,感興趣的可以深入了解:
單元測試
我們講了跨語言調用的解決方案,實現方法,以及內存管理和異常處理這些在實際開發中非常重要的部分。接下來,我們講講同樣非常重要卻往往被人忽視的部分:單元測試。
Rust FFI 接口之外的單元測試自不必說,該怎麼搞就怎麼搞,我們用單元測試(以及 property testing)保證純粹的 Rust 代碼在邏輯上的正確性。
Rust 提供給其它語言的 C FFI,需要妥善測試。這裡有幾個挑戰:
我們要為測試環境提供一個貼近於 Swift 調用 Rust 的運行環境,比如:所有的測試使用同一個 service_init 產生的 handle。這個,可以通過 std::sync::Once 來完成。
對於 service_dispatch,模擬 Swift callback 函數。
因為 service_dispatch 在其他線程中執行,因此測試結果出錯需要能夠被測試線程捕獲。
2 和 3 的實現方法可以參考以下實例:
可以看到,assert_eq! 在 on_result 回調中調用,而這個回調運行在 tokio 管理的若干個線程中的某個,因而有可能測試線程結束時,該線程還沒有結束。所以這裡我們需要不那麼優雅地通過 sleep 阻塞一下測試線程。這裡因為回調是一個 C 函數,無法做成 Rust 的閉包,因此,使用 channel 同步兩個線程的思路行不通。如果大家有比 sleep 更好的方法,歡迎跟我探討。我個人非常討厭在 test 中顯式地 sleep 來進行同步。
即便我們阻塞了足夠多的時間,這裡還有另一個問題:assert_eq! 產生的 panic 無法被測試線程捕獲到。所以我們在 FFI 代碼的測試初始化時,需要添加 panic 處理的 hook。這裡,我們讓 panic 發生後,做完正常的處理流程,就立刻結束整個進程。這樣,在 tokio 運行時某個線程中調用的 assert_eq! 被觸發並產生錯誤時,測試能夠正常退出並顯示測試錯誤。
同樣的,這個代碼也只需執行一次,所以也應該將其包裹在 std::sync::Once 中。
Rust 開發的心得我認為 Rust 開發的一大好處是你可以不斷將代碼拆分成新的 crate,讓這些小的 crate 可以有自己完整的單元測試。這樣非常符合 SRP(Single Responsibility Principle)。在這個 POC 裡,我做的 Rust 側代碼:
你可以看到,我甚至為測試單獨創建了兩個 crate。我不敢說我的項目結構一定是合理的,但是類似的拆分思路可以讓我們很好地應對大型項目的需求,並且讓代碼很好擴展,很好測試。
我最大的心得還是在 protubuf 的使用上。
自從我在自己的一個實驗性質的項目 gitrocks 裡使用 protobuf 來做應用程式的主要的數據結構後,這一思想我已經運用得越來越嫻熟。對於 Rust 代碼來說,一個手工撰寫的 struct 和一個由 protobuf 生成出來的 struct,除了後者有一些限制外(比如不能用指針類的數據結構,如 Arc),本質是一樣的。而後者可以將數據高效地序列化/反序列化,並且在應用程式的多個版本之間安全無障礙地共享。
因此,現在我做任何一個新的 Rust 項目的流程是:
先定義項目中的 protos。項目都需要什麼數據結構,哪些結構可以用 protobuf 定義。項目中使用的所有 error 都在 protobuf 裡定義。
創建一個 protos crate。使用 prost 生成代碼並添加合適的 serde 支持。之後,為每個數據結構定義一些接口,如 new,以及各種 From 轉換,以便 into() 可以到處使用。
創建一個 errors crate。使用 thiserror 進行各種 error 的轉換,以及 protobuf 裡定義的 error 和 thiserror 定義的 error 的轉換(這下連 Error 也可以序列化並發送到其它地方)。
創建 fixtures crate。集中處理所有測試數據。
創建其它的項目邏輯,使用 protobuf 生成的數據結構。
Swift:被 apple 耽誤的好語言最後,讓我好好吐槽一下 Swift 糟糕的生態。
作為一個 Swift 正式使用時間只能以天來計算的初學者來說,這個標題寫得對 apple 極為大不敬。
然而,我的 Swift 初體驗真的是可以用糟糕透頂來形容。
別會錯意,我不是說 Swift 語言本身。作為一個半吊子 Rust 開發者,當我寫了一兩百行真正的 Swift 代碼後,我便沉迷於這個語言的強大的表現力和簡單又優雅的語法。
但是,Swift 生態非常地支離破碎,稍微複雜一些的需求,就無法完成或者完成得非常彆扭。這和我學習 Rust 的體驗非常不一樣。
比如,連結一個 C 的靜態庫。Rust 你即便不知道怎麼做,stackoverflow 一下,你就能找到靠譜的答案,十分鐘搞定,毫無門檻。
Swift?OMG,讓人絕望。
至今我還沒有搞定在 Swift Package 裡如何使用一個靜態庫。
按照 apple 官方的說法,我可以創建 xcframework,然後在 Swift Package 裡引入 xcframework。
看似很簡單的任務。我用 Rust 編譯出了 linux / osx / iOS (arm) / iOS (x86_64 simulator) 幾個平臺的靜態庫,按照 apple 的官方文檔生成 xcframework,結果各種出錯。好吧,linux 在 aple 生態外,你不支持,無可厚非,我們暫且將其扔到一邊;iOS (arm) / iOS (x86_64) 也出錯,這是什麼鬼?同樣的靜態庫在 xcode 裡就可以正常編譯連結運行,為啥生成 xcframework 就報錯?難道 xcframework 不是親兒子?
好吧,osx 能夠正常打包,我們就在 xcframework 裡(暫時)只支持 osx 吧。
按照 apple 的官方文檔,我把 xcframework 放在 Swift Package 裡作為一個 binaryTarget,並在 target 中引用,照理來說該大功告成了吧?可 swift build 報錯。搜索了半天未果,後來我不得不就著錯誤消息查看了 Swift Package 的原始碼才解決了這個問題:
你敢相信這麼業餘的代碼是 apple 的工程師寫的麼?我們判斷一個庫是不是一個 static lib 竟然要靠它的命名是不是以 lib 開始?難道非標的靜態庫命名方式你就不工作了?好吧,我暫且認了,可是我用的是打包好的 xcframework 啊,我在創建 xcframework 時使用非標的 lib 命名方式,為啥你當時不給報個錯,讓我糾正過來,或者把 lib 名改成標準的名字呢?
吐槽歸吐槽,這不重要,我在 Rust 側構建時按照你要求改回來還不行麼?
這下,編譯通過了。然而,一旦我在代碼中引用靜態庫裡的函數,還是各種 symbol undefined 錯誤。我嘗試了各種論壇上幾乎各種方法,從 module.modulemap 到 bridging header,都無法正常編譯通過。
而如果我為這個 Swift package 創建一個 xcode 項目(swift package generate-xcodeproj),在 xcode 裡打開,添加 bridging header 就可以成功編譯。但是 xcode ... 不支持 linux 啊,你讓我如何開心地做 CI?畢竟,github action 等 CI 工具,osx 的價格是 linux 的十倍左右啊。
所以,我現在只能很無奈地本地用 xcodebuild test 做 precommit check,然後 CI 中禁用了 Swift 代碼的 build/test。讓一個 POC 代碼這麼消耗錢糧,不值當。
好的工具是很容易上手使用,而很難誤用。就我這兩天的體驗來說,在 WWDC 上大吹特吹的 xcframework 和被寄予希望的 Swift Package module,也許在整個 apple 的生態系統裡,工作得很好,然而一旦和更大的開源生態結合起來,還有很多路要走。
賢者時刻上篇文章我引用了別人做的 JSON parsing 的數據,27M 的 JSON,Swift 花了 3s,而 Rust 花了 0.18s,二者 17 倍的差距。對於這個結果,不但有些讀者不相信,我自己也不敢相信。於是我弄了一個大 JSON,然後用 app.quicktype.io 上生成的數據結構,分別用 Rust 的 serde_json 和 Swift 自帶的 JSONDecoder() 測試,Rust 3.95ms,Swift 49.2ms,依然有 12 倍的差距。
參考資料Unsafe Swift: Using Pointers and Interacting With C:https://www.raywenderlich.com/7181017-unsafe-swift-using-pointers-and-interacting-with-c
The power of extensions in Swift: https://www.swiftbysundell.com/articles/the-power-of-extensions-in-swift/
stack unwinding: https://www.bogotobogo.com/cplusplus/stackunwinding.php