GraphQL 是一項令人難以置信的技術,自從我在 2018 年首次開始將其投入生產以來,它就吸引了很多人的注意力。
在一大堆無型別的 JSON REST API 上構建了許多 React SPA 之後,我發現 GraphQL 是一股清新的空氣。
然而,隨著時間的推移,我有機會部署到更需要關注安全性、效能和可維護性等非功能性要求的環境中,我的觀點發生了變化。在本文中,我想向您介紹為什麼今天我不向大多數人推薦 GraphQL,以及我認為更好的替代方案。
安全性
從 GraphQL 誕生之初,很明顯將查詢語言暴露給不受信任的客戶端會增加應用程式的攻擊面。然而,需要考慮的攻擊種類比我想象的還要多,緩解這些攻擊是一項相當大的負擔。以下是我多年來遇到的最糟糕的情況……
1、授權
這是 GraphQL 最廣為人知的風險。
如果您向所有客戶端公開一個完全自文件化的查詢 API,您最好確保每個欄位都針對當前使用者進行了適當的授權,以適應正在獲取該欄位的上下文。最初授權物件似乎足夠了,但這很快就會變得不夠。
query {
user(id: 321) {
handle # 我可以檢視使用者的公開資訊
email # 我不應該因為可以檢視使用者名稱,就能看到他們的個人資訊。
}
user(id: 123) {
blockedUsers {
# 有時我甚至不應該看到他們的公開資訊,
# 因為上下文很重要!
handle
}
}
}
人們不禁要問,GraphQL 對“訪問控制失效”升至OWASP 前 10 名中的第 1 名負有多大責任。
這裡的一個緩解措施是透過與GraphQL 庫的授權框架整合,使 API 預設安全。每個返回的物件和/或解析的欄位,都會呼叫您的授權系統來確認當前使用者是否具有訪問許可權。
將此與 REST 世界進行比較,一般來說,您會授權每個端點,這是一項小得多的任務。
2、速率限制
我剛剛針對一個非常受歡迎的網站的 GraphQL API 瀏覽器測試了此攻擊:
query { |
並在 10 秒後獲得了 500 響應。我剛剛耗費了某人 10 秒的 CPU 時間來執行這個(刪除空格)128 位元組查詢,而且它甚至不需要我登入。
這種攻擊的常見緩解方法:
- 估算解析資料結構schema中每個欄位的複雜度,放棄超過某個最大複雜度值的查詢
- 捕捉執行查詢的實際複雜度,並將其從按一定間隔重置的積分桶中提取出來
要正確計算複雜度是一件很複雜的事情。如果在執行前不知道返回列表欄位的長度,計算就會變得特別棘手。您可以對這些欄位的複雜性進行假設,但如果假設錯誤,最終可能會限制有效查詢的速率或不限制無效查詢的速率。
更糟糕的是,構成結構schema的圖形通常包含迴圈。比方說,您執行一個部落格,其中的每篇文章都有多個標籤,您可以從中看到相關的文章。
type Article { |
在估算 Tag.relatedTags 的複雜度時,您可能會假設一篇文章永遠不會有超過 5 個標籤,因此將該欄位的複雜度設為 5(或 5 * 其子欄位的複雜度)。這裡的問題是 Article.relatedTags 可以是它自己的子標籤,因此您的估計不準確性會以指數形式增加。計算公式為 N^5 * 1:
query { |
您預計複雜度為 5^5 = 3,125。如果攻擊者能找到一篇有 10 個標籤的文章,他們就能觸發一個 "真實 "複雜度為 10^5 = 100_000 的查詢,比預計的複雜度高 20 倍。
部分緩解措施是防止深度巢狀查詢。不過,上面的示例表明,這並不是真正的防禦措施,因為這並不是一個異常深的查詢。GraphQL Ruby 的預設最大深度是 13,而這只是 7。
與 REST 端點的速率限制相比,後者的響應時間通常相當。在這種情況下,你所需要的只是一個桶式速率限制器,防止使用者在所有端點上的請求超過每分鐘 200 次。如果確實有速度較慢的端點(如 CSV 報告或 PDF 生成器),可以為其定義更嚴格的速率限制。使用某些 HTTP 中介軟體,這一點非常簡單:
Rack::Attack.throttle('API v1', limit: 200, period: 60) do |req| |
3、查詢解析
在執行查詢之前,首先要對其進行解析。我們曾經收到過一份筆試報告,證明有可能偽造出一個無效的查詢字串,導致伺服器當機。例如
query { |
這是一個語法上有效的查詢,但對我們的結構schema來說是無效的。符合規範的伺服器會對其進行解析,並開始生成包含數千個錯誤的錯誤響應,我們發現這些錯誤所消耗的記憶體是查詢字串本身的 2,000 倍。由於這種記憶體放大效應,僅僅限制有效負載的大小是不夠的,因為你會遇到比最小的危險惡意查詢還要大的有效查詢。
如果你的伺服器提供了一個概念,即在放棄解析之前最多會出現多少次錯誤,那麼這種情況就可以得到緩解。如果沒有,您就必須自行解決。目前還沒有與這種嚴重程度相當的 REST 攻擊。
效能
說到 GraphQL 的效能,人們經常會說它與 HTTP 快取不相容。就我個人而言,這並不是一個問題。對於 SaaS 應用程式來說,資料通常是高度使用者特定的,提供陳舊的資料是不可接受的,所以我沒有發現自己錯過了響應快取(或快取失效導致的錯誤......)。
我發現自己在處理的主要效能問題是...
1、資料獲取和 N+1 問題
我認為這個問題如今已被廣泛理解。簡而言之:如果欄位解析器命中一個外部資料來源(如 DB 或 HTTP API),並且它巢狀在一個包含 N 個項的列表中,那麼它將執行這些呼叫 N 次。
這並不是 GraphQL 獨有的問題,實際上,嚴格的 GraphQL 解析演算法已經讓大多數庫共享了一種通用的解決方案:Dataloader 模式。
但 GraphQL 的獨特之處在於,由於它是一種查詢語言,當客戶端修改查詢時,如果後端沒有任何變化,這就會成為一個問題。因此,我發現最終不得不在各處防禦性地引入 Dataloader 抽象,以防將來客戶端最終在列表上下文中獲取欄位。這需要編寫和維護大量的模板。
與此同時,在 REST 中,我們通常可以將巢狀的 N+1 查詢上傳到控制器,我認為這種模式更容易理解:
class BlogsController < ApplicationController |
2、授權和 N+1 問題
還有更多的 N+1!
如果你按照之前的建議與庫包的授權框架整合,那麼你現在就有了一個全新的 N+1 問題需要處理。讓我們繼續前面的 X API 示例:
class UserType < GraphQL::BaseObject |
這實際上比我們之前的例子更難處理,因為授權程式碼並不總是在 GraphQL 上下文中執行。例如,它可能在後臺作業或 HTML 端點中執行。這意味著我們不能天真地使用 Dataloader,因為 Dataloader 需要在 GraphQL 中執行(無論如何,在 Ruby 實現中)。
根據我的經驗,這實際上是效能問題的最大根源。我們經常會發現,我們的查詢花費在授權資料上的時間比其他任何事情都多。同樣,這個問題在 REST 世界中根本不存在。
我曾使用請求級全域性等討厭的方法來緩解這一問題,以便在策略呼叫中記憶快取資料,但感覺並不好。
3、耦合
根據我的經驗,在成熟的 GraphQL 程式碼庫中,您的業務邏輯會被強制引入傳輸層。這是透過一系列機制實現的,其中一些我們已經討論過:
- 解決資料授權問題,在整個 GraphQL 型別中加入授權規則
- 解決突變/引數授權問題,從而在整個 GraphQL 引數中加入授權規則
- 解決解析器資料獲取 N+1 的問題,從而將這一邏輯轉移到 GraphQL 特定的資料載入器中
- 利用(可愛的)中繼連線模式,將資料獲取邏輯轉移到 GraphQL 特定的自定義連線物件中
所有這一切的最終結果是,要對應用程式進行有意義的測試,就必須在整合層進行廣泛的測試,即執行 GraphQL 查詢。我發現這樣做會帶來痛苦的體驗。遇到的任何錯誤都會被框架捕獲,從而導致閱讀 JSON GraphQL 錯誤響應中的堆疊跟蹤這一有趣的任務。
由於授權和 Dataloaders 的許多工作都是在框架內完成的,因此除錯通常要困難得多,因為您想要的斷點並不在應用程式程式碼中。
當然,同樣,由於這是一種查詢語言,您需要編寫更多的測試來確認我們提到的所有引數和欄位級別的行為是否正常工作。
複雜性
總的來說,我們所討論的各種安全和效能問題的緩解措施都會大大增加程式碼庫的複雜性。並不是說 REST 就沒有這些問題(雖然它的問題肯定要少一些),只是 REST 解決方案對於後端開發人員來說,實施和理解起來通常要簡單得多。
總結主要原因
以上就是我不喜歡 GraphQL 的主要原因。我還有一些其他的憎惡,但為了讓這篇文章繼續下去,我將在這裡總結一下。
- GraphQL 不鼓勵破壞性更改,也不提供處理這些更改的工具。這就為那些控制著所有客戶端的人增加了不必要的複雜性,他們不得不尋找變通辦法。
- 對 HTTP 響應程式碼的依賴在工具中隨處可見,因此處理 200 可能意味著從一切正常到一切當機的所有情況,這可能會相當惱人。
- 在 HTTP 2+ 時代,在一次查詢中獲取所有資料往往不利於縮短響應時間,事實上,如果伺服器沒有並行化,與向不同伺服器傳送並行處理的請求相比,響應時間會更長。
替代方案
好了,廢話少說。我有什麼建議?如果符合下述條件:
- 控制所有客戶
- 擁有 ≤ 3 個客戶端
- 有一個用靜態型別語言編寫的客戶端
- 在伺服器和客戶端上使用的語言>1 種2
您最好使用符合 OpenAPI 3.0+ 標準的 JSON REST API。
根據我的經驗,如果您的前端開發人員喜歡 GraphQL 的主要原因是其自文件化的型別安全特性,那麼我認為這將非常適合您。
自從 GraphQL 出現以來,這方面的工具已經有了很大改進;有很多生成型別化客戶端程式碼的選項,甚至包括特定框架的資料獲取庫。
到目前為止,我的經驗非常接近於 "我使用 GraphQL 的最佳部分,但沒有 Facebook 所需的複雜性"。
與 GraphQL 一樣,有幾種實現方法...
1、首先,實現工具從型別化/型別提示伺服器中生成 OpenAPI 規範。Python 中的 FastAPI和 TypeScript 中的 tsoa 就是這種方法的很好例子,這是我最有經驗的方法,而且我認為它執行良好。
2、規範先行相當於 GraphQL 中的 "結構schema 先行"。規範先行工具會根據手寫的規範生成程式碼。我不能說我曾經看著一個 OpenAPI YAML 檔案,然後想 "我真想自己寫這個",但最近釋出的 TypeSpec 完全改變了一切。
有了 TypeSpec,就可以實現相當優雅的結構優先工作流程:
- 編寫簡潔易讀的 TypeSpec 結構
- 從中生成 OpenAPI YAML 規範
- 為您選擇的前端語言(如 TypeScript)生成靜態型別的 API 客戶端
- 為您的後端語言和伺服器框架生成靜態型別的伺服器處理程式(例如,TypeScript + Express、Python + FastAPI、Go + Echo)
- 為處理程式編寫可編譯的實現,並確保其型別安全
這種方法不太成熟,但我認為大有可為。