我們非常興奮地宣布,今天將以開源方式發布基於 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 編譯器會以預先計算的最佳化指令 (以普通的 Javascript 資料結構的形式) 取代應用程式的 GraphQL 片段,這些指令描述了如何將網路資料寫入儲存區並讀取回來。
此安排的另一個好處是,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 schema 表示法需要數 GB 的記憶體來表示我們的 schema,因此我們將其替換為自訂的分支。 - 我們在最熱門的程式碼路徑中進行了效能分析器引導的微優化。例如,我們停止使用
...
運算子來複製和修改物件,而是偏好在複製時明確列出物件的屬性。這樣保留了物件的隱藏類別,並使程式碼能夠更好地進行 JIT 優化。 - 我們重組了編譯器,使其能分發給多個 worker,每個 worker 處理單一的 schema。在 Meta 之外,具有多個 schema 的專案並不常見,因此即使如此,大多數使用者仍將使用單執行緒的編譯器。
這些優化不足以跟上 Relay 在內部快速採用的速度。
最大的挑戰是 NodeJS 不支援具有共享記憶體的多執行緒程式。最好的方法是啟動多個 worker,這些 worker 通過傳遞訊息來進行通訊。
這在某些情況下效果很好。例如,Jest 採用了這種模式,並在執行轉換檔案的測試時利用了所有核心。這非常適合,因為 Jest 不需要進程之間共享太多資料或記憶體。
另一方面,我們的 schema 太大了,無法在記憶體中有多個實例,因此根本沒有好的方法可以有效地平行化 Relay 編譯器,使其在 JavaScript 中每個 schema 使用一個以上的執行緒。
決定採用 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
指令有多困難,該指令現在預設為啟用。還有許多其他貢獻者 — 這真是一次社群努力!