本篇是 API 設計範式總覽 中 GraphQL 的深入版;與 REST/gRPC 的比較選型看總覽篇,REST 深入見 REST API 設計。
目錄
- 什麼是 GraphQL?
- Schema 與型別系統
- Query(查詢)
- Mutation(變更)
- Subscription(訂閱)
- Resolver(解析器)
- N+1 問題與 DataLoader
- 錯誤處理
- 快取的挑戰
- 分頁(Cursor Connections)
- 安全性
- 最佳實踐
- 常見問題
- 總結
什麼是 GraphQL?
GraphQL 是一種 API 查詢語言與執行引擎:客戶端用單一端點送出一段查詢,明確描述要哪些欄位,伺服器精準回傳對應結構——解決 REST 的 over-fetching(拿到多餘欄位)與 under-fetching(一個畫面要打多支 API)。
REST: 多個端點,回傳固定結構(常多拿或少拿)
GraphQL: 單一端點 /graphql,client 決定要什麼,server 照樣回傳
核心特點
- 宣告式取資料:client 點菜,server 上菜,結構一一對應
- 強型別 Schema:API 結構由 schema 明確定義,自帶文件與驗證
- 單一端點:通常
POST /graphql - 三種操作:Query(讀)、Mutation(寫)、Subscription(即時)
Schema 與型別系統
GraphQL 的核心是 Schema:用 SDL(Schema Definition Language) 定義有哪些型別與欄位。它同時是契約與文件。
# 物件型別
type User {
id: ID! # ! = 非空(non-null)
name: String!
email: String
orders: [Order!]! # [Order!]! = 非空陣列,元素也非空
}
type Order {
id: ID!
amount: Float!
}
# 三個進入點(root types)
type Query {
user(id: ID!): User # 帶參數的查詢
users: [User!]!
}
type Mutation {
createUser(name: String!, email: String): User!
}
type Subscription {
orderCreated: Order!
}
重點
- 純量型別(Scalar):
Int、Float、String、Boolean、ID,可自訂(如DateTime) !表非空:String!一定有值;String可為 null[Type]表陣列- 三個 root type:
Query/Mutation/Subscription是 client 的進入點
Query(查詢)
客戶端只列出要的欄位,連關聯資料都能一次拿:
query GetUser($id: ID!) { # $id 是變數
user(id: $id) {
name
orders(last: 3) { # 一次取關聯,不必再打一支
amount
}
}
}
// variables
{ "id": "123" }
// 回應結構與查詢一一對應
{
"data": {
"user": {
"name": "Alice",
"orders": [{ "amount": 100 }, { "amount": 250 }]
}
}
}
常用語法
- 變數(Variables):
$id,把參數與查詢分離(避免字串拼接) - 別名(Alias):同一查詢取兩次同欄位用不同名
a: user(id:"1") b: user(id:"2") - 片段(Fragment):重複欄位集抽出共用
fragment UserCore on User { id name email }
query { user(id: "1") { ...UserCore } }
Mutation(變更)
寫入操作用 mutation,並可在回傳裡指定要哪些欄位(直接拿到更新後結果):
mutation {
createUser(name: "Bob", email: "b@x.com") {
id # 立刻取得新建的 id
createdAt
}
}
慣例上一個 mutation 對應一個明確操作;多個 mutation 在同一請求會依序執行(query 的多個欄位則可並行)。
Subscription(訂閱)
即時推送,client 訂閱事件、server 在事件發生時推送,通常底層走 WebSocket:
subscription {
orderCreated {
id
amount
}
}
適合即時通知、即時看板。底層即時通訊機制見 API 範式總覽的即時通訊章節。
Resolver(解析器)
Schema 定義「有什麼」,Resolver 定義「怎麼拿到」。每個欄位都對應一個 resolver 函式,伺服器逐欄位呼叫、組合成回應。
const resolvers = {
Query: {
user: (parent, args, context) => db.users.findById(args.id),
},
User: {
// User.orders 怎麼來:用上一層 resolver 回傳的 user
orders: (user, args, context) => db.orders.findByUserId(user.id),
},
};
Resolver 簽名
resolver(parent, args, context, info)
parent ← 上一層 resolver 的回傳值
args ← 該欄位的參數
context ← 跨 resolver 共享(如登入使用者、DataLoader)
info ← 查詢的 AST 資訊
Resolver 的鏈式解析正是 N+1 問題的根源——見下節。
N+1 問題與 DataLoader
這是 GraphQL 最常見的效能陷阱。
問題
查 10 個 user 各自的 orders 時,User.orders resolver 會對每個 user 各打一次 DB:
1 次查 users(拿到 10 筆)
+ 10 次查 orders(每個 user 一次)
= 11 次查詢(1 + N)
N 越大越慘,而且 GraphQL 鼓勵巢狀查詢,問題會放大。
解法:DataLoader(批次 + 快取)
DataLoader 把同一輪事件迴圈內的多次 key 查詢收集起來批次處理,並在單次請求內快取:
const orderLoader = new DataLoader(async (userIds) => {
// 一次查所有 userId 的 orders,再依序對回去
const orders = await db.orders.findByUserIds(userIds);
return userIds.map((id) => orders.filter((o) => o.userId === id));
});
const resolvers = {
User: {
orders: (user) => orderLoader.load(user.id), // 自動批次成「一次查 10 個」
},
};
改用 DataLoader 後:1 次查 users + 1 次批次查 orders = 2 次
這與資料庫層的關聯查詢效能直接相關——子表查詢要有索引,見 索引與查詢優化。
錯誤處理
GraphQL 的錯誤模型跟 REST 很不同:
- HTTP 通常一律回 200(即使有錯)——因為錯誤是「query 層級」而非「傳輸層級」
- 錯誤放在回應的
errors陣列,且可與 部分成功的data並存
{
"data": { "user": { "name": "Alice", "email": null } },
"errors": [
{
"message": "無權限讀取 email",
"path": ["user", "email"],
"extensions": { "code": "FORBIDDEN" }
}
]
}
- 部分資料(partial data):一個欄位失敗不必整個查詢失敗,其他欄位照常回
- 用
extensions.code放穩定錯誤碼供前端判斷
這也是 GraphQL 與 REST 的關鍵差異:REST 用 HTTP 狀態碼表達結果,GraphQL 多半 200 +
errors。
快取的挑戰
GraphQL 的彈性帶來快取難題:
- HTTP 快取難用:多為單一
POST /graphql端點、查詢內容各異,無法像 REST 用 URL +ETag做 HTTP 層快取 - 解法在客戶端:Apollo Client / Relay 用正規化快取(normalized cache)——以每個物件的
id為 key 存成扁平結構,跨查詢共用同一份物件 - 持久化查詢(Persisted Queries):把查詢預先註冊成 hash,client 只送 hash,可改用 GET + CDN 快取、也縮小傳輸
「快取相對麻煩」是選 GraphQL 要付的代價之一;REST 在這點較有優勢(見總覽比較)。
分頁(Cursor Connections)
GraphQL 生態的事實標準是 Relay 的 Connection 規範(cursor 分頁):
query {
users(first: 10, after: "cursorABC") {
edges {
node { id name } # 實際資料
cursor # 這筆的游標
}
pageInfo {
hasNextPage
endCursor # 下一頁用
}
}
}
edges/node/cursor/pageInfo是慣例結構- cursor 分頁穩定、適合大資料(原理與 REST 的 cursor 分頁一致)
安全性
GraphQL 的彈性也是攻擊面,需額外防護:
| 風險 | 防護 |
|---|---|
| 惡意深巢狀查詢(遞迴關聯把 server 拖垮) | 限制查詢深度(depth limiting) |
| 高成本查詢 | 查詢複雜度分析(給欄位配權重、設上限) |
| Introspection 洩漏 schema | 正式環境關閉 introspection 或限白名單 |
| 任意查詢 | Persisted Queries:只允許預先註冊的查詢 |
| 大量 alias 放大攻擊 | 限制查詢大小 / alias 數 |
REST 天然有「端點即邊界」,GraphQL 因單端點 + 任意查詢,更需要主動設這些限制。
最佳實踐
- Schema 先行:先設計型別與關聯,schema 即契約與文件
- 善用
!標非空,讓型別精確、減少 client 判 null - 一定要用 DataLoader 解 N+1(GraphQL 幾乎必踩)
- 錯誤放
errors+extensions.code,善用 partial data - 正式環境關 introspection、設查詢深度/複雜度上限
- 分頁用 Relay cursor connection 慣例
- 客戶端用 Apollo/Relay 正規化快取,別自己硬刻
- 高流量考慮 Persisted Queries(省傳輸 + 可 CDN 快取 + 限制可執行查詢)
常見問題
問題 1:GraphQL 會取代 REST 嗎?
不會,是互補。前端需求多變、關聯資料多、多種裝置共用一套 API → GraphQL 划算;單純 CRUD、想善用 HTTP 快取、公開 API → REST 仍是好選擇。實務常兩者並存。
問題 2:N+1 問題是什麼?一定要處理嗎?
巢狀查詢時,關聯欄位的 resolver 對每筆各打一次 DB(1+N 次)。幾乎一定要用 DataLoader 批次化,否則資料一多效能就崩。
問題 3:為什麼 GraphQL 出錯還回 HTTP 200?
因為 GraphQL 把錯誤視為「查詢結果的一部分」而非傳輸層問題,錯誤放在 errors 陣列,且允許 partial data(部分欄位成功)。傳輸層真正失敗時才會是非 2xx。
問題 4:GraphQL 為什麼快取比較麻煩?
單一 POST 端點 + 查詢內容各異,無法用 URL/ETag 做 HTTP 快取。解法移到客戶端的正規化快取(Apollo/Relay),或用 persisted queries 改 GET + CDN。
問題 5:GraphQL 安全上要注意什麼?
主要是查詢被濫用:深巢狀 / 高複雜度查詢拖垮 server。要設查詢深度與複雜度上限、正式環境關閉 introspection、必要時用 persisted queries 限制可執行的查詢。
總結
核心要點
- GraphQL:單一端點、client 宣告式點欄位,解決 REST 的 over/under-fetching
- Schema(SDL) 是強型別契約,三個 root:Query / Mutation / Subscription
- Resolver 逐欄位解析,鏈式解析造成 N+1,必用 DataLoader 批次化
- 錯誤走
errors陣列 + partial data,HTTP 多半 200 - 快取麻煩(靠客戶端正規化快取 / persisted queries)、安全需限深度與複雜度
- 分頁用 Relay cursor connection 慣例
快速參考
| 概念 | 重點 |
|---|---|
| Schema | SDL 定義型別,! 非空,[] 陣列 |
| Query / Mutation / Subscription | 讀 / 寫 / 即時 |
| Resolver | 逐欄位解析(parent, args, context, info) |
| N+1 | 巢狀查詢的效能陷阱 → DataLoader |
| 錯誤 | errors + partial data,多半 HTTP 200 |
| 快取 | HTTP 快取難,用客戶端正規化快取 |
| 安全 | 深度/複雜度上限、關 introspection |
建立日期:2026-06-18