為什麼用了 6 年 GraphQL 後,我決定棄坑
GraphQL 的複雜、安全和效能問題,讓我轉向更簡單的 OpenAPI 等替代方案
作者: Matt Bessey
GraphQL 曾讓我驚艷,從 2018 年開始在正式環境使用後,它也確實風靡一時。當時我還大力推薦過這項技術(可以翻翻我長草的部落格)。在用一堆沒類型定義的 JSON REST API 開發 React SPA 開發到心很累之後,GraphQL 簡直像一陣及時雨,我那時完全是 GraphQL 的鐵粉。
但隨著時間推移,當我需要考量安全性、效能、維護性等非功能性需求時,想法就改變了。這篇文章想說明為什麼現在我不推薦大家使用 GraphQL,還有我認為更好的替代方案。
我會用 Ruby 搭配 graphql-ruby 函式庫來舉例,但我相信這些問題在其他程式語言和 GraphQL 函式庫也普遍存在。
如果你有更好的解法或緩解措施,歡迎留言討論。現在,我們開始吧…
攻擊面
GraphQL 將查詢語言暴露給不可信任的用戶端,很明顯會增加應用程式的受攻擊範圍。實際上,攻擊類型比我想像的還多,防禦它們也很麻煩。以下是我多年來遇過最棘手的問題…
授權
這個問題大家應該都很清楚,我就不贅述。簡單來說:如果你的 API 對所有用戶端都公開且可自查詢文件,你必須非常確定每個欄位都根據當前使用者和使用情境正確授權。一開始授權物件看似足夠,但很快就不夠用了。例如,假設我們是推特 X 🙄 的 API:
query {
user(id: 321) {
handle # ✅ 我可以看使用者的公開資訊
email # 🛑 但我不該只因為能看使用者資訊就看到個資
}
user(id: 123) {
blockedUsers {
# 🛑 有時連公開資訊都不能看,
# 因為情境很重要!
handle
}
}
}
難怪「失效的存取控制」會爬到 OWASP 十大安全風險 的第一名,GraphQL 難辭其咎。一個緩解方法是整合 GraphQL 函式庫的授權框架 來提高安全性。每次物件回傳或欄位解析時,系統都會確認當前使用者是否有權限。
相比之下,REST API 通常只要授權每個端點,工作量少很多。
速率限制
用 GraphQL 時,不能假設所有請求對伺服器的負載都一樣。查詢可以無限大。就算在空的 Schema 中, introspection 暴露的類型也是循環的,所以可以寫出回傳數 MB JSON 的有效查詢:
query {
__schema {
types {
__typename
interfaces {
possibleTypes {
interfaces {
possibleTypes {
name
}
}
}
}
}
}
}
我對某個很紅網站的 GraphQL API 資源管理器測試這個攻擊,10 秒後就收到 500 錯誤。我只用 ( 去除空白後 ) 128 位元組 的查詢,就吃了 10 秒 CPU 時間,而且我根本沒登入。
常見的緩解方法 1 是:
- 評估 Schema 中每個欄位的解析複雜度,超過上限就放棄查詢。
- 記錄查詢的實際複雜度,並從限額中扣除,限額會定期重置。
這個計算 很難抓準 。如果回傳的 List 欄位長度事先未知,就更麻煩了。你可以假設其複雜度,但如果錯了,可能會誤限有效查詢或放過無效查詢。
更糟的是,Schema 通常包含循環。假設你經營部落格,文章有多個標籤,每個標籤又連結到其他文章。
type Article {
title: String
tags: [Tag]
}
type Tag {
name: String
relatedTags: [Tag]
}
評估 Tag.relatedTags
的複雜度時,你可能假設文章最多 5 個標籤,所以複雜度設為 5 ( 或 5 乘以 子欄位的複雜度 )。問題是 Article.relatedTags
可以是自己子欄位,複雜度會指數級上升,公式是 N^5 * 1
。所以以下查詢:
query {
tag(name: "security") {
relatedTags {
relatedTags {
relatedTags {
relatedTags {
relatedTags { name }
}
}
}
}
}
}
預期複雜度是 5^5 = 3,125
。但如果攻擊者找到有 10 個標籤的文章,就能發動複雜度 10^5 = 100,000
的查詢,比預估值高 20 倍。
一個緩解方法是 限制查詢巢狀深度 。但上面例子深度只有 7,不算太深,所以效果有限。GraphQL Ruby 預設最大深度是 13。
相比之下,REST API 端點的回應時間通常差不多,所以只要用 token bucket 演算法限制每分鐘最多請求數就好,例如 200 次。如果有比較慢的端點 ( 例如產生 CSV 或 PDF ),可以設定更嚴格的限制。用 HTTP 中介軟體就能輕鬆做到:
Rack::Attack.throttle('API v1', limit: 200, period: 60) do |req|
if req.path =~ '/api/v1/'
req.env['rack.session']['session_id']
end
end
查詢解析
查詢在執行前會先解析。我們曾收到滲透測試報告,指出惡意查詢可能讓伺服器 OOM。例如:
query {
__typename @a @b @c @d @e ... # 假設有 1000 多個
}
這是語法有效的查詢,但對我們的 Schema 無效。符合規範的伺服器會解析它,並產生包含數千個錯誤的回應,佔用記憶體是原始查詢字串的 2,000 倍。因為記憶體會被放大,所以不能只限制查詢大小,因為有些有效查詢會比最小的惡意查詢還大。
如果伺服器可以設定錯誤數量上限,超過就停止解析, 就能緩解這個問題 。否則就要自己想辦法。REST API 沒有這麼嚴重的問題。
效能
講到 GraphQL 的效能,大家常說它跟 HTTP 快取不相容。但我覺得這不是問題。對 SaaS 應用程式來說,資料通常因使用者而異,不能用過期資料,所以我也不需要回應快取 ( 以及它造成的快取失效問題… )。
我遇到的主要效能問題是…
資料讀取的 N+1 問題
這個問題應該滿多人知道的。簡單來說:如果欄位解析器會讀取外部資料來源 ( 例如資料庫或 HTTP API ),而且它在包含 N 個項目的 List 裡面,就會讀取 N 次。
這不是 GraphQL 獨有的問題,而且嚴格的 GraphQL 解析演算法讓大部分函式庫都有共同解法: Dataloader 模式 。但 GraphQL 的特性是,它是查詢語言,所以即使後端沒變,只要用戶端改查詢,這個問題就可能出現。結果就是,你必須預先在各處使用 Dataloader,以防用戶端未來在 List 中讀取欄位。這會產生很多樣板程式碼,維護起來很麻煩。
REST API 通常可以在 Controller 中處理 N+1 查詢,我覺得比較好理解:
class BlogsController < ApplicationController
def index
@latest_blogs = Blog.limit(25).includes(:author, :tags)
render json: BlogSerializer.render(@latest_blogs)
end
def show
# 這裡不用預讀,因為 N=1
@blog = Blog.find(params[:id])
render json: BlogSerializer.render(@blog)
end
end
授權的 N+1 問題
還沒完,還有更多 N+1 問題!如果你照前面的建議整合授權框架,就會遇到另一種 N+1 問題。繼續用推特 X 的 API 當例子:
class UserType < GraphQL::BaseObject
field :handle, String
field :birthday, authorize_with: :view_pii
end
class UserPolicy < ApplicationPolicy
def view_pii?
# 糟糕,我要讀取資料庫才能知道使用者朋友
user.friends_with?(record)
end
end
query {
me {
friends { # 回傳 N 個使用者
handle
birthday # 執行 UserPolicy#view_pii? N 次
}
}
}
這比前面例子更棘手,因為授權程式碼不一定在 GraphQL 情境下執行,例如可能在背景作業或 HTML 端點執行。所以不能直接用 Dataloader,因為 Dataloader 需要在 GraphQL 中執行 ( 至少 Ruby 是這樣 )。
根據我的經驗,這才是最大的效能瓶頸。我們常發現查詢花最多時間在授權資料。同樣的,REST API 沒有這個問題。
我用過一些不太好的方法來解決,例如用 request level globals 來快取策略呼叫的資料,但感覺不太好。
耦合
依我的經驗,成熟的 GraphQL 程式碼庫會把商業邏輯塞進 傳輸層 。原因有很多,有些前面提過:
- 處理資料授權會讓 GraphQL 類型中充滿授權規則。
- 處理 mutation / 參數授權會讓 GraphQL 參數中充滿授權規則。
- 解決 resolver 的 N+1 問題會把邏輯移到 GraphQL 專用的 Dataloader。
- 使用 ( 很棒的 ) Relay Connection 模式會把資料讀取邏輯移到 GraphQL 專用的 connection 物件 。
結果就是,要完整測試應用程式,必須做大量的整合測試,也就是執行 GraphQL 查詢。我發現這樣測試很痛苦。錯誤會被框架攔截,所以得去看 JSON GraphQL 錯誤訊息中的堆疊追蹤。因為授權和 Dataloader 的邏輯都在框架內,除錯更難,因為你要的斷點不在程式碼裡。
當然,因為它是查詢語言,你得寫更多測試來確保參數和欄位都正常運作。
複雜度
總之,解決安全性和效能問題的方法會讓程式碼庫變得非常複雜。REST API 不是沒有這些問題 ( 雖然比較少 ),只是 REST API 的解法通常比較簡單,後端工程師比較好理解。
還有更多…
以上是我不太想用 GraphQL 的主要原因。還有一些小抱怨,為了避免文章太長,我簡單列一下…
- GraphQL 不鼓勵破壞性更動,也沒提供處理工具。這讓控制所有用戶端的開發者很困擾,他們得 自己想辦法 。
- 工具都依賴 HTTP 狀態碼,所以 200 狀態碼可能代表很多意思 ( 從一切正常到全掛了都有 ),很煩人。
- 在 HTTP/2 以後的時代,一次讀取所有資料不見得比較快。如果伺服器沒有平行處理,反而比發多個請求到不同伺服器還慢。
替代方案
抱怨完了。那我推薦什麼呢?老實說,我還在 技術成熟度曲線 的早期階段,但我覺得如果你:
- 控制所有用戶端。
- 用戶端數量 ≤ 3。
- 用戶端是用靜態類型語言寫的。
- 伺服器和用戶端用 > 1 種語言開發 2。
那可能用符合 OpenAPI 3.0+ 規範的 JSON REST API 比較好。如果你的前端工程師喜歡 GraphQL 的自文件和類型安全特性 ( 我之前也是 ),我覺得 REST API 很適合你。這方面的工具比 GraphQL 出現時進步很多,有很多產生類型化用戶端程式碼的工具,甚至有 框架專用的資料讀取函式庫 。我的經驗是,它幾乎保留了我喜歡 GraphQL 的優點,又沒有臉書需要的複雜度。
跟 GraphQL 一樣,有幾種實作方式…
先實作再產生文件的工具會從有類型提示的伺服器產生 OpenAPI 規範。Python 的 FastAPI 和 TypeScript 的 tsoa 就是這類工具 3。我對這種方式比較熟悉,覺得它滿好用的。
先寫規範再產生程式碼就像 GraphQL 的 Schema First。這類工具會從手寫的規範產生程式碼。我看 OpenAPI YAML 檔時從沒想過「好想自己寫」,但 TypeSpec 的出現改變了一切。它可以打造很優雅的 Schema First 流程:
- 寫簡潔易懂的 TypeSpec Schema。
- 產生 OpenAPI YAML 規範。
- 產生前端用的類型化 API 用戶端程式碼 ( 例如 TypeScript )。
- 產生後端用的類型化伺服器程式碼 ( 例如 TypeScript + Express 、 Python + FastAPI 、 Go + Echo )。
- 寫伺服器端的程式碼,不用擔心類型安全問題。
這種方式比較新,但我覺得很有潛力。
我覺得現在有更強大又更簡單的選擇,我很好奇它們的缺點是什麼 😄。
感謝閱讀!更多討論請參考 Hacker News 和 Reddit 。