GraphQL 完全指南

深入 GraphQL:Schema 型別系統、Query/Mutation/Subscription、Resolver、N+1 與 DataLoader、快取與安全

本篇是 API 設計範式總覽 中 GraphQL 的深入版;與 REST/gRPC 的比較選型看總覽篇,REST 深入見 REST API 設計


目錄


什麼是 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)IntFloatStringBooleanID,可自訂(如 DateTime
  • ! 表非空String! 一定有值;String 可為 null
  • [Type] 表陣列
  • 三個 root typeQuery / 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 因單端點 + 任意查詢,更需要主動設這些限制。


最佳實踐

  1. Schema 先行:先設計型別與關聯,schema 即契約與文件
  2. 善用 ! 標非空,讓型別精確、減少 client 判 null
  3. 一定要用 DataLoader 解 N+1(GraphQL 幾乎必踩)
  4. 錯誤放 errors + extensions.code,善用 partial data
  5. 正式環境關 introspection、設查詢深度/複雜度上限
  6. 分頁用 Relay cursor connection 慣例
  7. 客戶端用 Apollo/Relay 正規化快取,別自己硬刻
  8. 高流量考慮 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

🔗相關文章