安息吧,REST API,GraphQL 長存

發表於2017-09-21

即使與 REST API 打交道這麼多年,當我第一次瞭解到 GraphQL 和它試圖解決的問題時,我還是禁不住把本文的標題發在了 Twitter 上。

請別會錯意。我不是在說 GraphQL 會“殺死” REST 或別的類似的東西。REST 可能永遠不會消失,就像 XML 從沒消失過一樣。我只是認為 GraphQL 之於 REST,正如 JSON 之於 XML 那般。

本篇文章實際上並沒有100%贊成 GraphQL。後文會有一個專門的章節來闡述 GraphQL 的靈活性成本,更高的靈活性意味著更高的成本。

我喜歡“始終以 WHY 開頭”,所以讓我們開始吧。

摘要:為什麼我們需要 GraphQL ?

GraphQL 解決的最重要的3個問題分別是:

  • 需要進行多次往返以獲取檢視所需的資料:使用 GraphQL,你可以隨時通過單次往返伺服器獲取檢視所需的所有初始資料。要使用 REST API 實現相同的功能,我們需要引入難以管理和擴充套件的非結構化引數和條件。
  • 客戶端依賴於服務端:客戶端使用 GraphQL 作為請求語言:(1) 消除了伺服器對資料形狀或大小進行硬編碼的需要,(2) 將客戶端與服務端分離。這意味著我們可以把客戶端與服務端分離開來,單獨進行維護和改進。
  • 糟糕的前端開發體驗:使用 GraphQL,開發人員可以宣告式地來表達其使用者介面的資料需求。他們宣告他們需要什麼資料,而不是如何獲取它。UI 需要哪些資料,與開發人員在 GraphQL 中宣告該資料的方式之間存在緊密的聯絡。

本文將詳細介紹 GraphQL 如何解決所有這些問題。

在我們開始之前,如果你還不熟悉 GraphQL,可以從簡單的定義開始。

什麼是 GraphQL ?

GraphQL 是一門語言。如果我們將 GraphQL 嵌入某個軟體應用,該應用能夠宣告式地將任意必需的資料傳遞給同樣使用 GraphQL 的後端資料服務。

就像一個小孩可以很快學會一門新的語言 – 而成年人則相對沒那麼容易學會 – 從頭開始使用 GraphQL 會比引入 GraphQL 到一個成熟的應用中更容易。

要讓一個資料服務能夠使用 GraphQL,我們需要實現一個執行時層,並將其暴露給想要與服務端通訊的客戶端。將伺服器端的這一層看作簡單的 GraphQL 語言的翻譯器,或者代表資料服務的 GraphQL 代理。GraphQL 不是儲存引擎,所以它並不是一個獨立的解決方案。這就是為什麼我們不能僅有一個 GraphQL 的伺服器,我們還需要實現一個翻譯執行時。

這個抽象層可以用任意語言編寫,它定義了一個通用的基於圖形的 schema 來發布它所代表的資料服務的功能。使用 GraphQL 的客戶端程式可以通過其功能查詢該 schema。這種方法使得客戶端與服務端解耦,並允許其兩者獨立開發和擴充套件。

GraphQL 請求可以是查詢(讀取操作)或突變(寫入操作)。對於這兩種情況,請求都是一個簡單的字串,GraphQL 服務可以使用指定格式的資料解釋,執行和解析。通常用於移動和 Web 應用的響應格式為 JSON

什麼是 GraphQL?(大白話版)

GraphQL 為資料通訊而生。你有一個客戶端和一個伺服器,它們需要相互通訊。客戶端需要告知伺服器需要哪些資料,伺服器需要用實際的資料來滿足客戶端的資料需求。GraphQL 是此種通訊方式的中介。

截圖來源於我的 Pluralsight 課程 – 使用 GraphQL 構建可擴充套件的 API。

你可能會問,為什麼客戶端不直接與伺服器通訊呢? 當然可以。

在客戶端和伺服器之間加入 GraphQL 層的考量有多種原因。其中之一,也許是最受歡迎的原因便是效率。客戶端通常需要向伺服器請求多個資源,而伺服器會用單個資源進行響應。所以客戶端的請求最終會多次往返伺服器,以收集所有需要的資料。

使用 GraphQL,我們基本上可以將這種多個請求的複雜度轉移到伺服器端,並且通過 GraphQL 層處理它。客戶端向 GraphQL 層發起單個請求,並獲得一個完全符合客戶端需求的響應。

引入 GraphQL 層有諸多好處。例如,一大好處便是能與多個服務進行通訊。當你有多個客戶端請求多個服務的資料時,中間的 GraphQL 層可以簡化和標準化此通訊過程。儘管這並不是拿來與 REST API 作比較的一個重點 – 因為這很容易實現,而 GraphQL 執行時提供了一種結構化和標準化的方式。

截圖來源於我的 Pluralsight 課程 – 使用 GraphQL 構建可擴充套件的 API。

我們可以讓客戶端與 GraphQL 層通訊,而不是直接連線兩個不同的資料服務(如上面的幻燈片中那樣)。然後 GraphQL 層將與兩個不同的資料服務進行通訊。GraphQL 首先將客戶端從需要與多種語言進行通訊中隔離,並將單個請求轉換為使用不同語言的多個服務的多個請求。

想象一下,有三個人說三種不同的語言,並擁有不同的知識型別。然後,只有把所有三個人的知識結合在一起才能得到回答。如果你有一個能說這三種語言翻譯人員,那麼把你的問題的答案結合在一起就很容易。這正是 GraphQL 執行時所做的。

計算機尚未聰明到能回答任何問題(至少現在還沒有),所以它們必須遵循既定的演算法。這就是為什麼我們需要在 GraphQL 執行時中定義一個 schema,並且該 schema 能被客戶端所使用。

這個 schema 基本可以視為一個功能文件,其中列出了客戶端可以請求 GraphQL 層的所有查詢。因為我們在這裡使用的是節點的圖,所以使用 schema 會帶來一些靈活性。該 schema 大致表示了 GraphQL 層可以響應的範圍。

還不夠清楚?我們可以說 GraphQL 其實根本就是:REST API 的接替者。所以讓我回答一下你最有可能問的問題。

REST API 有什麼問題?

REST API 最大的問題是其多端點的本質。這要求客戶端進行多次往返以獲取資料。

REST API 通常是端點的集合,其中每個端點代表一個資源。因此,當客戶端需要獲取多個資源的資料時,需要對 REST API 進行多次往返,以將其所需的資料放在一起。

在 REST API 中,沒有客戶端請求語言。客戶端無法控制伺服器返回的資料。沒有任何語言可以這樣做。更確切地說,可用於客戶端的語言非常有限。

例如,READ REST API 端點可能是

  • GET /ResouceName ——從該資源獲取所有記錄的列表;
  • GET /ResourceName/ResourceID ——獲取該 ID 標識的單條記錄。

例如,客戶端不能指定為該資源中的記錄選擇哪些欄位。這意味著 REST API 服務將始終返回所有欄位,而不管客戶端實際需要哪些。GraphQL 針對這個問題定義的術語是超量獲取不需要的資訊。這對客戶端和伺服器而言都是網路和記憶體資源的浪費。

REST API 的另一大問題是版本控制。如果你需要支援多個版本,那通常意味著需要新的端點。而在使用和維護這些端點時會導致諸多問題,並且這可能導致伺服器上的程式碼冗餘。

上面提到的 REST API 的問題正是 GraphQL 試圖要解決的問題。它們當然不是 REST API 的所有問題,我也不想討論 REST API 是什麼。我主要討論的是比較流行的基於資源的 HTTP 端點 API。這些 API 中的每一個最終都會變成一個具有常規 REST 端點 + 由於效能原因而制定的自定義特殊端點的組合。這就是為什麼 GraphQL 提供了更好的選擇。

GraphQL如何做到這一點?

GraphQL 背後有很多概念和設計決策,但最重要的可能是:

  • GraphQL schema 是強型別 schema。要建立一個 GraphQL schema,我們要定義具有型別欄位。這些型別可以是原語的或者自定義的,並且 schema 中的所有其他型別都需要型別。這種豐富的型別系統帶來豐富的功能,如擁有內省 API,並能夠為客戶端和伺服器構建強大的工具。
  • GraphQL 使用圖與資料通訊,資料自然是圖。如果需要表示任何資料,右側的結構便是圖。GraphQL 執行時允許我們使用與該資料的自然圖形式匹配的圖 API 來表示我們的資料。
  • GraphQL 具有表達資料需求的宣告性。GraphQL 為客戶端提供了一種宣告式語言,以便表達它們的資料需求。這種宣告性創造了一個關於使用 GraphQL 語言的內在模型,它接近於我們用英語考慮資料需求的方式,並且它讓使用 GraphQL API 比備選方案(REST API)容易得多。

最後一個概念解釋了為什麼我個人認為 GraphQL 是一個規則顛覆者的原因。

這些都是高層次的概念。讓我們進一步瞭解一些細節。

為了解決多次往返的問題,GraphQL 讓響應伺服器只是作為一個端點。本質上,GraphQL 將自定義端點的思想運用到極致,即讓整個伺服器成為一個可以回覆所有資料請求的自定義端點。

與單一端點概念相關的另一大概念是使用該自定義的單個端點所需的富客戶端請求語言。沒有客戶端請求語言,單個端點是沒有用的。它需要一種語言來處理自定義請求,並響應該自定義請求的資料。

擁有客戶端請求語言意味著客戶端將處於控制之中。它們可以明確地請求它們需要什麼,伺服器將會正確應答它們請求的內容。這解決了超量獲取的問題。

對於版本控制,GraphQL 的做法很有趣。我們可以完全避免版本控制。本質上,我們可以新增新的欄位,而不需要刪除舊的欄位,因為我們有一個圖,並且我們可以通過新增更多的節點來靈活地擴充套件圖。因此,我們可以在圖上留下舊的 API,並引入新的 API,而不會將其標記為新版本。API 只會增長,而不會有版本。

這對於移動客戶端尤其重要,因為我們無法控制它們正在使用的 API 版本。一旦安裝,移動應用可能會持續使用同一個舊版 API 很多年。對於 Web,則很容易控制 API 的版本,因為我們只需推送新的程式碼即可。然而對於移動應用,這很難做到。

還不完全信服?要不我們用實際的例子來對 GraphQL 和 REST 做個一對一的比較?

RESTful APIs vs GraphQL APIs — 示例

假設我們是負責構建展示“星球大戰”電影和角色的嶄新使用者介面的開發者。

我們負責構建的第一個 UI 很簡單:顯示單個星球大戰人物的資訊。例如,達斯·維德(Darth Vader),以及該角色參演的所有電影。這個檢視需要顯示人物的姓名,出生年份,星球名稱以及所有他們參演的電影的名稱。

就是這麼簡單,我們只要處理3種不同的資源:人物,星球和電影。這些資源之間的關係也很簡單,任何人都能猜到這裡的資料形狀。人物物件從屬於一個星球物件,並且具有一個或多個電影物件。

這個 UI 的 JSON 資料可能類似於:

假設某個資料服務給我們提供了該資料的確切結構,這有一種使用 React.js 表示它的檢視的方式:

這是一個很簡單的例子,雖然我們對星球大戰的觀影經驗可能有所幫助,但 UI 和資料之間的關係其實是非常清晰的。UI 使用了我們假想的 JSON 資料物件中的所有“鍵”。

現在我們來看看如何使用 RESTful API 請求這些資料。

我們需要獲取單個人物的資訊,並且假定我們知道該人物的 ID,則 RESTful API 會將該資訊暴露為:

這個請求將返回給我們該人物的姓名,出身年份和其他有關資訊。一個設計良好的 RESTful API 還會返回給我們該人物的星球 ID 和參演的所有電影 ID 的陣列。

這個請求的 JSON 響應可能是這樣的:

然後為了獲取星球的名稱,我們再請求:

然後為了獲取電影名,我們發出請求:

一旦我們獲取了來自伺服器的所有6個響應,我們便可以將它們組合起來,以滿足我們的檢視所需的資料。

除了我們必須做6次往返以滿足一個簡單的使用者介面的簡單資料需求的事實,我們獲取資料的方法是命令式的。我們給出了如何獲取資料以及如何處理它以使其準備好渲染檢視的說明。

如果你不明白我的意思,你可以自己動手嘗試一下。星球大戰資料有一個 RESTful API,目前由 http://swapi.co/ 託管。可以去嘗試使用它構建我們的人物資料物件。資料的鍵可能有所不同,但是 API 端點是一樣的。你需要執行6次 API 呼叫。此外,你將不得不超量獲取檢視不需要的資訊。

當然,這只是 RESTful API 對於此資料的一個實現。可能會有更好的實現,能使這個檢視更容易實現。例如,如果 API 伺服器實現了資源巢狀,並且表明了人物與電影之間的關係,則我們可以通過以下方式讀取電影資料:

然而,一個純粹的 RESTful API 伺服器很可能不會像這般實現,並且我們需要讓我們的後端工程師為我們額外建立這個自定義的端點。這就是擴充套件 RESTful API 的現實——我們不得不新增自定義端點,以有效滿足不斷增長的客戶端需求。然而管理像這樣的自定義端點是很困難的一件事。

現在來看看 GraphQL 的實現方式。伺服器端的 GraphQL 包含了自定義端點的思想,並將其運用到極致。伺服器將只是單個端點,而通道不再重要。如果我們通過 HTTP 執行此操作,那麼 HTTP 方法肯定也不重要。假設我們有單個 GraphQL 端點通過 HTTP 暴露在 /graphql

由於我們希望在單次往返中請求我們所需的資料,所以我們需要一種表達我們對伺服器端完整資料需求的方式。我們使用 GraphQL 查詢來做:

一個 GraphQL 查詢只是一個字串,但它必須包括我們需要的所有資料。這就是宣告式的好處。

在英語中,我們如何宣告我們的資料需求:我們需要一個人物的姓名,出生年份,星球名稱和所有電影名。在 GraphQL 中,這被轉換為:

再讀一遍英文表述的需求,並將其與 GraphQL 查詢進行比較。它們及其相似。現在,將此 GraphQL 查詢與我們最開始使用的原始 JSON 資料進行比較。會發現,GraphQL 查詢就是 JSON 資料的確切結構,除了沒有所有“值”部分。如果我們根據問答關係來考慮這個問題,那麼問題就是沒有答案的答案宣告。

如果答案是:

離太陽最近的行星是水星。

這個問題的一個很好的表述方式是同樣的沒有答案部分的宣告:

(什麼是)離太陽最近的行星?

同樣的關係也適用於 GraphQL 查詢。採用 JSON 響應,移除所有“答案”部分(鍵所對應的值),最後得到一個非常適合代表關於該 JSON 響應的問題的 GraphQL 查詢。

現在,將 GraphQL 查詢與我們為資料定義的宣告式的 React UI 進行比較。GraphQL 查詢中的所有內容都在 UI 中被用到,UI 中的所有內容都會顯示在 GraphQL 查詢中。

這便是 GraphQL 設計哲學的偉大之處。UI 知道它需要的確切資料,並且提取出它所要求的資料是相當容易的。設計一個 GraphQL 查詢只需從 UI 中直接提取用作變數的資料。

如果我們反轉這個模式,它同樣有效。如果我們有一個 GraphQL 查詢,我們明確知道如何在 UI 中使用它的響應,因為查詢與響應具有相同的“結構”。我們不需要檢查響應才知道如何使用它,我們也不需要有關 API 的任何文件。這些都是內建的。

星球大戰資料有一個 GraphQL API 託管在 https://github.com/graphql/swapi-graphql。可以去嘗試使用它構建我們的人物資料物件。後續我們探討的 API 可能會有一些細微的變動,但下面是你可以使用這個 API 來檢視我們對檢視資料請求的正式查詢(以Darth Vader為例):

這個請求定義了一個非常接近檢視的響應結構,記住,我們是在一次往返中獲得的所有這些資料。

GraphQL 靈活性的代價

完美的解決方案實際並不存在。由於 GraphQL 過於靈活,將會帶來一些明確的問題和擔憂。

GraphQL 易導致的一個重要威脅是資源耗盡攻擊(亦稱為拒絕服務攻擊)。GraphQL 伺服器可能會受到超複雜查詢的攻擊,這將耗盡伺服器的所有資源。查詢深度巢狀關係(使用者 -> 朋友 -> 朋友…),或者使用欄位別名多次查詢相同的欄位非常容易。資源耗盡攻擊並不是特定於 GraphQL 的場景,但是在使用 GraphQL 時,我們必須格外小心。

我們可以在這裡做一些緩和措施。比如,我們可以提前對查詢進行成本分析,並對可以使用的資料量實施某種限制。我們也可以設定超時時間來終結需要過長時間解析的請求。此外,由於 GraphQL 只是一個解析層,我們可以在 GraphQL 下的更底層處理速率限制。

如果我們試圖保護的 GraphQL API 端點並不公開,而是為了供我們自己的客戶端(網路或移動裝置)內部使用,那麼我們可以使用白名單方法和預先批准伺服器可以執行的查詢。客戶端可以要求伺服器只執行使用查詢唯一識別符號預先批准的查詢。據說 Facebook 採用的就是這種方法。

認證和授權是在使用 GraphQL 時需要考慮的其他問題。我們是在 GraphQL 解析過程之前,之後還是之間處理它們?

為了解答這個問題,你可以將 GraphQL 視為在你自己的後端資料獲取邏輯之上的 DSL(領域特定語言)。我們只需把它當作可以在客戶端和我們的實際資料服務(或多個服務)之間放置的一箇中間層。

然後將認證和授權視為另一層。GraphQL 在實際的身份驗證或授權邏輯的實現中並無用處,因為它的意義並不在於此。但是,如果我們想將這些層放置於 GraphQL 之後,我們可以使用 GraphQL 來傳遞客戶端和強邏輯之間的訪問令牌。這與我們通過 RESTful API 進行認證和授權的方式非常相似。

GraphQL 另一項更具挑戰性的任務是客戶端的資料快取。RESTful API 由於其字典性質而更容易快取。特定地址標識特定資料。我們可以使用地址本身作為快取鍵。

使用 GraphQL,我們可以採取類似的基本方式,將查詢文字用作快取其響應的鍵。但是這種方式有著諸多限制,而且不是很有效率,並且可能導致資料一致性的問題。多個 GraphQL 查詢的結果很容易重疊,而這種基礎的快取方式無法解決重疊的問題。

對於這個問題有一個很巧妙的解決方案,那就是使用圖查詢表示圖快取。如果我們將 GraphQL 查詢響應正規化化為一個扁平的記錄集合,給每條記錄一個全域性唯一的 ID,那麼我們就可以快取這些記錄,而不是快取完整的響應。

然而這不是一個簡單的過程。記錄將會相互引用,我們將在其中管理迴圈圖。操作和讀取快取需要遍歷查詢。儘管我們需要編寫一箇中間層來處理這些快取邏輯,但是這種方式總體上比基於響應的快取更有效率。Relay.js 便是一個採用這種快取策略並在內部實現自動管理的框架。

對於 GraphQL,或許我們應該關心的最重要的問題是通常被稱為 N+1 SQL 查詢的問題。GraphQL 查詢欄位被設計為獨立的功能,並且使用資料庫中的資料解析這些欄位可能會導致對已解析欄位產生新的資料庫請求。

對於簡單的 RESTful API 端點邏輯,可以通過增強結構化的 SQL 查詢來分析,檢測和解決 N+1 問題。對於 GraphQL 動態解析的欄位,就沒那麼簡單了。好在 Facebook 開創了一個可行的解決方案:DataLoader

顧名思義,DataLoader 是一個可用於從資料庫讀取資料並使其可用於 GraphQL 解析函式的工具程式。我們可以使用 DataLoader 而不是直接使用 SQL 查詢從資料庫中讀取資料,而 DataLoader 將作為我們的代理,以減少我們傳送到資料庫的實際 SQL 查詢。

DataLoader 的原理是使用批處理和快取的組合。如果相同的客戶端請求導致需要向資料庫請求多個資料,則可以使用 DataLoader 來合併這些請求,並從資料庫批量載入其響應。DataLoader 還將快取響應以使其可用於相同資源的後續請求。

謝謝閱讀!

相關文章