跳至主要內容

一篇標記為「relay-compiler」的文章

檢視所有標籤

·閱讀時間 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 檢查,那麼 @required 就會變得非常有用!

沒有 @required 的元件
以及有 @required 的元件

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

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

編譯器效能

到目前為止,我們已經討論了為什麼 Relay 有編譯器以及我們希望重寫帶來什麼。但是我們還沒有討論為什麼我們決定在 2020 年重寫編譯器:效能。

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

達到 JavaScript 的極限

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

編譯器的效能保持在相當合理的狀態一段時間:Node/V8 配有經過高度最佳化的 JIT 編譯器和垃圾回收器,如果您小心(我們是),它會相當快。但是編譯時間正在增長。

我們嘗試了一些策略來跟上

  • 我們使編譯器具有增量性。針對變更,它僅重新編譯受該變更影響的相依性。
  • 我們已經確定了哪些轉換速度很慢(即展平),並盡可能地進行了演算法上的改進(例如新增記憶化)。
  • 官方 graphql npm 套件的 GraphQL 架構表示形式需要數 GB 的記憶體才能表示我們的架構,因此我們用自訂的 fork 取代了它。
  • 我們在最熱門的程式碼路徑中進行了由效能分析器引導的微優化。例如,我們停止使用 ... 運算符來複製和修改物件,而是傾向於在複製物件時明確列出物件的屬性。這樣可以保留物件的隱藏類別,並使程式碼能夠更好地被 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 的一部分。新的編譯器功能包括:

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

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

感謝

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