我們非常興奮地在今天開源發佈基於 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 編譯器具有所有元件資料相依性的全域知識,因此它能夠編寫出與手動編寫的查詢一樣好(通常甚至更好)的查詢。它可以透過以在執行階段不切實際的慢速方式優化查詢來做到這一點。例如,它可以刪除永遠無法從產生的查詢存取的 branch,並展平查詢的相同部分。
而且由於這些查詢是在建置時產生的,因此 Relay 應用程式永遠不會從 GraphQL 片段產生抽象語法樹 (AST),也不會操作這些 AST,或在執行階段產生查詢文字。相反,Relay 編譯器會將應用程式的 GraphQL 片段替換為預先計算的、經過最佳化的指令(作為普通的 Javascript 資料結構),這些指令描述如何將網路資料寫入商店並讀取出來。
這種安排的一個額外好處是,Relay 應用程式套件既不包含架構,也不包含(當使用持久化查詢時)GraphQL 片段的字串表示形式。這有助於縮減應用程式大小,節省使用者的頻寬並提高應用程式效能。
事實上,新的編譯器更進一步,以另一種方式節省使用者的頻寬——Relay 可以在建置時通知應用程式的伺服器每個查詢文字,並產生唯一的查詢 ID,這意味著應用程式永遠不需要透過使用者緩慢的網路傳送可能非常長的查詢字串。當使用此類持久化查詢時,唯一需要透過網路傳送以發出網路請求的是查詢 ID 和查詢變數!
新編譯器啟用了什麼?
與動態語言相比,編譯語言有時被認為會引入摩擦並減慢開發人員的速度。但是,Relay 利用編譯器來減少摩擦並使常見的開發人員任務更容易。例如,Relay 公開了用於常見互動的高階原語,這些原語很容易出現微妙的錯誤,例如分頁和使用新變數重新提取查詢。
這些互動的共同點是它們需要從舊查詢中產生新查詢,因此涉及樣板和重複——自動化的理想目標。Relay 利用編譯器的全域知識來授權開發人員,透過新增一個指令和變更一個函式呼叫來啟用分頁和重新提取。就是這樣。
但是,讓開發人員能夠輕鬆新增分頁只是冰山一角。我們對編譯器的願景是,它提供更多用於交付功能和避免樣板的高階工具,為開發人員提供即時協助和見解,並且由其他用於處理 GraphQL 的工具可以使用的部分組成。
該專案的主要目標是,重寫的編譯器的架構應該讓我們能夠在未來幾年內實現這個願景。
雖然我們還沒有實現這一目標,但我們在每個標準上都取得了重大成就。
例如,新的編譯器支援新的 @required
指令,如果給定的子欄位在讀出時為空值,則該指令將使父鏈接欄位失效或拋出錯誤。這聽起來可能像是一個微不足道的品質提升,但是如果您一半的元件程式碼都是空值檢查,@required
就開始看起來非常不錯了!
@required
的元件
@required
的元件
接下來,編譯器為內部專用的 VSCode 擴充功能提供支援,該擴充功能在您鍵入時自動完成欄位名稱,並在懸停時顯示類型資訊,以及許多其他功能。我們尚未公開發佈它,但我們希望在某個時候發佈!我們的經驗是,這個 VSCode 擴充功能使處理 GraphQL 資料更加容易和直觀。
最後,新的編譯器被編寫為一系列獨立的模組,可以由其他 GraphQL 工具重複使用。我們稱之為 Relay 編譯器平台。在內部,這些模組正在被重複用於其他程式碼產生工具和不同平台上的其他 GraphQL 客戶端。
編譯器效能
到目前為止,我們已經討論了為什麼 Relay 有編譯器,以及我們希望透過重寫來實現什麼。但我們還沒有討論為什麼我們決定在 2020 年重寫編譯器:效能。
在決定重寫編譯器之前,隨著程式碼庫的增長,編譯程式碼庫中所有查詢所花費的時間逐漸但毫不留情地減慢。我們逐步獲得效能提升的能力,無法跟上程式碼庫中查詢數量的增長,而且我們看不到擺脫這種困境的漸進式方法。
達到 JavaScript 的極限
先前的編譯器是用 JavaScript 編寫的。這是出於幾個原因的自然語言選擇:這是我們的團隊最有經驗的語言,是編寫 Relay 執行階段的語言(允許我們在編譯器和執行階段之間共享程式碼),以及編寫 GraphQL 參考實作和我們的行動 GraphQL 工具的語言。
編譯器的效能維持在合理範圍內一段時間:Node/V8 具有高度最佳化的 JIT 編譯器和垃圾回收機制,如果謹慎使用(我們確實如此),速度可以相當快。但編譯時間卻持續增長。
我們嘗試了許多策略來跟上進度
- 我們已將編譯器設為增量式。在響應變更時,它只會重新編譯受該變更影響的依賴項。
- 我們已找出哪些轉換速度較慢(即 flatten),並盡可能地進行演算法改進(例如新增記憶化)。
- 官方的
graphql
npm 套件的 GraphQL schema 表示法需要數 GB 的記憶體來表示我們的 schema,因此我們將其替換為自訂分支。 - 我們在最熱門的程式碼路徑中進行了效能分析器導向的微最佳化。例如,我們停止使用
...
運算子來複製和修改物件,而是傾向於在複製時明確列出物件的屬性。這保留了物件的隱藏類別,並使程式碼更能進行 JIT 最佳化。 - 我們重組了編譯器,使其可以分派給多個 worker,每個 worker 處理單一 schema。在 Meta 之外,具有多個 schema 的專案並不常見,因此即使如此,大多數使用者仍會使用單執行緒編譯器。
這些最佳化措施不足以跟上 Relay 在內部快速普及的腳步。
最大的挑戰是 NodeJS 不支援具有共享記憶體的多執行緒程式。最好的方法是啟動多個 worker,透過傳遞訊息進行溝通。
這在某些情況下效果很好。例如,Jest 採用此模式,並在執行轉換檔案的測試時使用所有核心。這很適合,因為 Jest 不需要共享太多進程之間的資料或記憶體。
另一方面,我們的 schema 太大了,無法在記憶體中有多個實例,因此在 JavaScript 中,除了每個 schema 一個執行緒之外,沒有其他有效率的方法可以平行化 Relay 編譯器。
決定採用 Rust
在我們決定重寫編譯器後,我們評估了許多語言,以了解哪種語言可以滿足我們專案的需求。我們想要一種快速、記憶體安全且支援並行的語言,最好能在建置時而非執行時捕獲並行錯誤。同時,我們希望該語言在內部有良好的支援。這將選擇範圍縮小到幾個選項
- C++ 符合大多數條件,但感覺難以學習。而且,編譯器在安全性方面的協助不如我們期望的那麼多。
- Java 也可能是不錯的選擇。它可以很快且支援多核心,但提供的低階控制較少。
- OCaml 在編譯器領域是一個經過驗證的選擇,但多執行緒具有挑戰性。
- Rust 快速、記憶體安全且支援並行。它使在執行緒之間安全地共享大型資料結構變得容易。由於 Rust 的普遍興奮感、我們團隊之前的一些經驗以及 Facebook 其他團隊的使用,這是我們明確的首選。
內部推出
事實證明,Rust 非常適合!這個主要由 JavaScript 開發人員組成的團隊發現 Rust 很容易上手。而且,Rust 的進階類型系統在建置時捕獲了許多錯誤,幫助我們保持了高速的開發速度。
我們於 2020 年初開始開發,並於該年底在內部推出了編譯器。最初的內部基準測試顯示,編譯器的平均效能提高了近 5 倍,在 P95 時提高了近 7 倍。從那時起,我們進一步提高了編譯器的效能。
在 OSS 中發布
今天,我們很高興發布新版本的編譯器,作為 Relay v13 的一部分。新的編譯器功能包括
@required
指令。@no_inline
指令,可用於防止內嵌常見的片段,從而產生較小的產生檔案。- 驗證衝突的 GraphQL 欄位、引數和指令
- 支援 TypeScript 類型產生
- 支援遠端查詢持久化。
您可以在 README 和 版本說明中找到有關編譯器的更多資訊!
我們正在繼續開發編譯器中的功能,例如讓開發人員能夠存取圖表上的衍生值、增加對更新本機資料的更符合人體工學的語法的支援,以及全面完善我們的 VSCode 擴充功能,我們希望將所有這些都發布為開源。我們對這次發布感到自豪,但還有更多即將推出!
感謝
感謝 Joe Savona、Lauren Tan、Jason Bonta 和 Jordan Eldredge 在這篇部落格文章中提供了很棒的回饋。感謝 ch1ffa、robrichard、orta 和 sync 提出與編譯器錯誤相關的問題。感謝 MaartenStaa 新增 TypeScript 支援。感謝 @andrewingram 指出啟用 @required
指令有多困難,現在預設已啟用。還有許多其他人做出了貢獻 — 這確實是社群共同的努力!