跳至主要內容

一篇標記為「required」的文章

檢視所有標籤

·12 分鐘閱讀

我們非常興奮地在今天向開源社群發布基於 Rust 的新 Relay 編譯器的預覽版(作為 v13.0.0-rc.1)!這個新的編譯器速度更快,支援新的執行階段功能,並為未來的額外發展提供了堅實的基礎。

在發布這個版本之前,Meta 的程式碼庫一直在增長,沒有停止的跡象。以我們的規模來說,編譯程式碼庫中所有查詢所需的時間不斷增加,直接損害了開發人員的生產力。雖然我們嘗試了許多策略來優化基於 JavaScript 的編譯器(如下所述),但我們逐步提高效能的能力跟不上程式碼庫中查詢數量的增長。

因此,我們決定用 Rust 重寫編譯器。我們選擇 Rust 是因為它速度快、記憶體安全,並且可以輕鬆安全地跨執行緒共享大型資料結構。開發工作於 2020 年初開始,該編譯器於當年年底在內部發布。發布過程順利,沒有中斷應用程式開發。最初的內部基準測試顯示,編譯器的平均效能提高了近 5 倍,在 P95 上提高了近 7 倍。從那時起,我們進一步提高了編譯器的效能。

這篇文章將探討 Relay 為何有編譯器、我們希望透過新的編譯器解鎖哪些功能、它的新功能,以及我們為何選擇使用 Rust 語言。如果您急於開始使用新的編譯器,請查看 編譯器套件的 README版本說明

為何 Relay 有編譯器?

Relay 有編譯器,是為了提供穩定性保證並實現卓越的執行階段效能。

為了理解原因,請考慮使用該框架的工作流程。透過 Relay,開發人員使用一種稱為 GraphQL 的宣告式語言來指定每個元件需要哪些資料,但不需要指定如何取得這些資料。然後,編譯器會將這些元件的資料相依性縫合到擷取給定頁面所有資料的查詢中,並預先計算出讓 Relay 應用程式具有如此高水準效能和穩定性的成品。

在這個工作流程中,編譯器

  • 允許以隔離的方式來推論元件,使一大類的錯誤成為不可能,並且
  • 將盡可能多的工作轉移到建置時間,顯著提高了使用 Relay 的應用程式的執行階段效能。

讓我們依序檢視這些。

支援局部推論

透過 Relay,元件僅透過使用 GraphQL 片段來指定其自身的資料需求。然後,編譯器會將這些元件的資料相依性縫合到擷取給定頁面所有資料的查詢中。開發人員可以專注於編寫元件,而不用擔心其資料相依性如何融入較大的查詢中。

然而,Relay 將這種局部推論更進一步。編譯器還會產生 Relay 執行階段使用的檔案,以讀取給定元件片段所選取的資料(我們稱之為資料遮罩)。因此,元件永遠不會存取(實際上,不僅在類型層級!)它沒有明確要求的任何資料。

因此,修改一個元件的資料相依性不會影響另一個元件看到的資料,這表示開發人員可以以隔離的方式來推論元件。這為 Relay 應用程式提供了無與倫比的穩定性,使一大類的錯誤成為不可能,並且是 Relay 可以擴展到許多開發人員接觸相同程式碼庫的關鍵原因。

提高執行階段效能

Relay 還利用編譯器將盡可能多的工作轉移到建置時間,從而提高 Relay 應用程式的效能。

由於 Relay 編譯器具有所有元件資料相依性的全域知識,因此它能夠撰寫與手動撰寫一樣好(甚至通常更好)的查詢。它可以透過以在執行階段速度會不切實際地慢的方式來最佳化查詢。例如,它會修剪永無法從產生查詢中存取的區段,並將查詢的相同區段展平。

而且由於這些查詢是在建置時間產生的,因此 Relay 應用程式永遠不會從 GraphQL 片段產生抽象語法樹狀結構 (AST),操作這些 AST,或在執行階段產生查詢文字。相反地,Relay 編譯器會將應用程式的 GraphQL 片段替換為預先計算、最佳化的指令(作為純 JavaScript 資料結構),這些指令描述如何將網路資料寫入儲存區並將其讀回。

這種安排的另一個好處是,Relay 應用程式套件不包含結構描述,也不包含 GraphQL 片段的字串表示(使用持續性查詢時)。這有助於減少應用程式大小,節省使用者的頻寬並提高應用程式效能。

事實上,新的編譯器更進一步,並以另一種方式節省使用者的頻寬 — Relay 可以在建置時間通知應用程式的伺服器每個查詢文字,並產生唯一的查詢 ID,這表示應用程式永遠不需要透過使用者緩慢的網路傳送可能非常長的查詢字串。使用此類持續性查詢時,透過網路傳送以進行網路要求的唯一內容是查詢 ID 和查詢變數!

新的編譯器啟用了哪些功能?

與動態語言相比,編譯語言有時會被認為會引入摩擦並減慢開發人員的速度。然而,Relay 利用編譯器來減少摩擦並使常見的開發人員任務更容易。例如,Relay 公開了常用互動的高階基元,這些互動很容易出現細微錯誤,例如分頁和使用新變數重新擷取查詢。

這些互動的共同點是它們需要從舊的查詢產生新的查詢,因此需要樣板和重複 — 這是自動化的理想目標。Relay 利用編譯器的全域知識,讓開發人員只需新增一個指示詞並變更一個函式呼叫,即可啟用分頁和重新擷取。就是這樣。

但是讓開發人員能夠輕鬆新增分頁只是冰山一角。我們對編譯器的願景是,它能提供更多高階工具來發布功能和避免樣板、為開發人員提供即時協助和見解,並由其他用於處理 GraphQL 的工具可以使用的部分組成。

這個專案的主要目標是,重寫編譯器的架構應能讓我們在未來幾年實現這個願景。

雖然我們尚未實現,但我們在每個標準上都取得了重大成就。

例如,新的編譯器隨附了對新的 @required 指示詞的支援,如果讀取時給定的子欄位為 null,則該指示詞會將父連結欄位設為 null 或擲回錯誤。這聽起來可能像是微不足道的生活品質改進,但如果您元件的一半程式碼是 null 檢查,@required 看起來就相當不錯!

沒有 @required 的元件
@required 的元件

接下來,編譯器為內部使用的 VSCode 擴充功能提供支援,該擴充功能會在您輸入時自動完成欄位名稱,並在懸停時顯示類型資訊,以及許多其他功能。我們尚未公開它,但我們希望在某個時候公開!我們的經驗是,這個 VSCode 擴充功能使處理 GraphQL 資料變得更容易、更直觀。

最後,新的編譯器是作為一系列獨立模組撰寫的,這些模組可以被其他 GraphQL 工具重複使用。我們稱之為 Relay 編譯器平台。在內部,這些模組正被重複用於其他程式碼產生工具,以及用於不同平台的其他 GraphQL 用戶端。

編譯器效能

到目前為止,我們已經討論了 Relay 為何有編譯器,以及我們希望透過重寫啟用哪些功能。但我們尚未討論我們為何決定在 2020 年重寫編譯器:效能。

在決定重寫編譯器之前,隨著程式碼庫的增長,編譯程式碼庫中所有查詢所需的時間逐漸但無情地減慢。我們逐步提高效能的能力跟不上程式碼庫中查詢數量的增長,而且我們看不到任何漸進式解決這個困境的方法。

達到 JavaScript 的盡頭

先前的編譯器是用 JavaScript 撰寫的。這是一個自然的語言選擇,原因有幾個:它是我們的團隊經驗最豐富的語言、Relay 執行階段所用的語言(允許我們在編譯器和執行階段之間共享程式碼),以及 GraphQL 參考實作和我們的行動 GraphQL 工具所用的語言。

編譯器的效能在相當長的時間內保持合理:Node/V8 隨附了高度最佳化的 JIT 編譯器和垃圾回收器,如果您小心(我們很小心),速度會相當快。但編譯時間正在增加。

我們嘗試了許多策略來跟上

  • 我們使編譯器成為漸進式的。在回應變更時,它只會重新編譯受該變更影響的相依性。
  • 我們已經識別出哪些轉換速度慢(即展平),並盡我們所能地進行了演算法改進(例如新增記憶化)。
  • 官方的 graphql npm 套件的 GraphQL 結構描述表示需要數 GB 的記憶體來表示我們的結構描述,因此我們將其替換為自訂分支。
  • 我們在最熱門的程式碼路徑中進行了以效能分析器引導的微優化。舉例來說,我們不再使用 ... 運算子來複製和修改物件,而是偏好在複製時明確列出物件的屬性。這樣做保留了物件的隱藏類別,並使程式碼能更好地被 JIT 優化。
  • 我們重構了編譯器,使其能分派給多個工作者,每個工作者處理單一的 schema。在 Meta 以外,具有多個 schema 的專案並不常見,因此即使這樣做,大多數使用者仍然會使用單執行緒的編譯器。

這些優化措施不足以跟上 Relay 在內部快速普及的腳步。

最大的挑戰是 NodeJS 不支援具有共享記憶體的多執行緒程式。最好的做法是啟動多個工作者,並透過傳遞訊息來進行溝通。

這在某些情況下效果很好。例如,Jest 採用了這種模式,在執行檔案轉換測試時會利用所有核心。這很適合,因為 Jest 不需要進程之間共享太多資料或記憶體。

另一方面,我們的 schema 太大了,無法在記憶體中有多個實例,因此在 JavaScript 中,根本沒有好的方法可以有效地平行化 Relay 編譯器,讓每個 schema 使用超過一個執行緒。

決定採用 Rust

在我們決定重寫編譯器之後,我們評估了許多程式語言,看看哪種語言最符合我們的專案需求。我們希望一種語言是快速、記憶體安全,並支援並行處理—最好是在編譯時就能捕獲並行處理的錯誤,而不是在運行時。同時,我們也希望這種語言在內部能有良好的支援。這將選擇範圍縮小到幾個選項。

  • C++ 符合大多數的標準,但感覺很難學習。而且,編譯器在安全性方面的協助不如我們期望的那麼多。
  • Java 可能也是一個不錯的選擇。它可以很快,而且支援多核心,但提供的底層控制較少。
  • OCaml 在編譯器領域是一個經過驗證的選擇,但多執行緒是一個挑戰。
  • Rust 快速、記憶體安全,並支援並行處理。它使得在執行緒之間安全地共享大型資料結構變得容易。由於 Rust 的普遍熱潮、我們團隊先前的一些經驗,以及 Facebook 其他團隊的使用,這顯然是我們的首選。

內部推出

Rust 證明非常適合!這個主要由 JavaScript 開發人員組成的團隊發現 Rust 很容易上手。而且,Rust 先進的型別系統在編譯時捕獲了許多錯誤,幫助我們保持了高效率。

我們在 2020 年初開始開發,並在當年年底在內部推出了編譯器。最初的內部基準測試顯示,編譯器的平均效能提升了將近 5 倍,而在 P95 時則提升了將近 7 倍。從那時起,我們進一步提高了編譯器的效能。

在 OSS 中發布

今天,我們很高興發布新版本的編譯器,作為 Relay v13 的一部分。新編譯器的功能包括:

您可以在 README發行說明 中找到有關編譯器的更多資訊!

我們將繼續在編譯器中開發功能,例如讓開發人員能夠存取圖表上的衍生值、為更新本地資料添加更符合人體工學的語法,以及完整開發我們的 VSCode 擴充功能,所有這些我們都希望發布到開源社群。我們為這次的發布感到自豪,但還有更多功能即將推出!

感謝

感謝 Joe Savona、Lauren Tan、Jason Bonta 和 Jordan Eldredge 為這篇部落格文章提供了精彩的回饋。感謝 ch1ffa、robrichard、orta 和 sync 提交了與編譯器錯誤相關的問題。感謝 MaartenStaa 添加 TypeScript 支援。感謝 @andrewingram 指出啟用 @required 指令的困難之處,該指令現在預設啟用。還有許多其他人做出了貢獻—這確實是社群的共同努力!