為什麼 GraphQL Resolver 是以欄位為單位觸發?從根本理解查詢邏輯

Published May 6, 2025 by 徐培鈞
Web API

GraphQL 是一種查詢語言(Query Language),它讓前端可以精確地定義需要的資料欄位,只拿想要的資料、不多不少。

然而,如果你是從 REST API 或資料庫查詢背景轉過來的新手,會對一件事感到困惑:

為什麼明明只送出一次 GraphQL 查詢,卻會執行那麼多 Resolver 函式?

這篇文章會帶你拆解 GraphQL 查詢的執行過程,並深入理解「Resolver 是以欄位為單位觸發」的設計哲學與實際效益。

GraphQL 中的欄位:你查詢的每一項資料,都是一個欄位

在 GraphQL 中,「欄位(field)」是整個查詢語言的最小單位,也是資料請求的基本單位

當你撰寫一段 GraphQL 查詢時,每一行你寫下的屬性名稱,例如 nameemailtitle,其實都是一個「欄位」。

🧠 換個說法來理解:

當你送出一個 GraphQL 查詢,就像是在對後端說:「我需要這個資料型別中的哪些部分」。

你不是要整包資料,而是精確挑選要哪些欄位,這種按需取用(field selection)的特性就是 GraphQL 的強大之處。

舉例說明

假設我們有一個查詢如下:

query {
  user(id: "123") {
    name
    email
    profilePicture
  }
}

這個查詢的意思是:

  • 我想查一位 user,ID 是 123。
  • 但我只需要這位使用者的 nameemailprofilePicture,其餘資料(如帳號建立時間、電話、角色權限等)一律不需要傳回。

每一個被寫在 { ... } 中的項目,就是一個「欄位(field)」。在這個例子裡:

  • user 是一個欄位(出現在 Query 中)
  • nameemailprofilePictureUser 型別中的欄位

欄位的「型別」是什麼?

GraphQL 中每個欄位都有一個「回傳型別(return type)」。

例如:

type User {
  name: String
  email: String
  posts: [Post]
}

這裡的 name 欄位回傳一個 Stringposts 欄位則回傳一個 Post 陣列。

這個欄位型別的設計,是由開發者在 Schema 中定義的,它控制了該欄位該怎麼解釋與處理。

GraphQL 的欄位 ≠ 資料庫欄位:這點很重要!

很多初學者剛接觸 GraphQL 時,會誤以為欄位就是資料庫裡的欄位(例如 MySQL 裡的 column)。

這種看法不完全正確,因為:在資料庫中,「欄位」表示實際儲存的結構與資料型別,例如 VARCHAR(255)INT 等。

但在 GraphQL 中,「欄位」更像是你想要「取得什麼」的語意表示,它不一定對應某一個具體儲存欄位,它可以:

  • 從資料庫直接取出欄位資料
  • 呼叫第三方 API 得到資料
  • 根據使用者狀態計算後回傳一個結果
  • 從 Redis 快取中讀取資料
  • 組合多個來源的結果再轉換回來

🧠 用一個比喻來說:

GraphQL 的欄位比較像是「你去餐廳點菜時,點了哪些菜」;而資料庫的欄位比較像是「這間餐廳的食材庫房裡有哪些原料」。

你點了「奶油雞排」,服務生(Resolver)可能要去不同的地方抓雞肉、香料、奶油,甚至還要煮一煮再送給你。

你只說了你要什麼(欄位),怎麼準備資料是後端決定的事。

什麼是 GraphQL 的 Resolver?

在 GraphQL 中,Resolver(解析器)是負責回傳欄位資料的函式

當你送出一個 GraphQL 查詢,GraphQL 不會自己知道資料要去哪裡拿——這時就會交給 Resolver 來處理。

你可以把 Resolver 想成是每個欄位的「資料來源說明書」或「取得資料的說明方法」。

每當前端查詢某個欄位,GraphQL 就會去執行該欄位對應的 Resolver,取得並回傳值。

Resolver 的基本定義:欄位的資料取得邏輯

GraphQL 是「欄位驅動(field-based)」的架構,這意味著:

  • 每一個欄位(不管是 Query、Mutation,或 Object Type)都有自己的 Resolver
  • 只有當這個欄位被查詢時,對應的 Resolver 才會被執行
  • 每一層嵌套欄位都可以有自己的 Resolver

Resolver 的職責通常包含:

  1. 拿資料(從資料庫、API、記憶體等)
  2. 處理邏輯(過濾、排序、轉換格式)
  3. 驗證權限(是否能看這筆資料)
  4. 回傳符合 Schema 規範的資料

Resolver 的結構與位置範例

我們來看一段簡單的 GraphQL Schema 和對應的 Resolver 定義。

Schema 定義:

type Query {
  user(id: ID!): User
}

type User {
  id: ID!
  name: String
  posts: [Post]
}

type Post {
  title: String
}

對應的 Resolver 實作(JavaScript 版):

const resolvers = {
  Query: {
    // 查詢 user(id: "123") 時會觸發這個 Resolver
    user: (_, { id }) => getUserById(id),
  },
  User: {
    // 查詢 user.posts 時會觸發這個 Resolver
    posts: (user) => getPostsByUser(user.id),
  }
}

說明:

  • Query.user 是最外層查詢:你會傳入 ID,後端會查資料庫並回傳一個 User 物件。
  • User.posts 是內層嵌套查詢:當你在查 user { posts } 時,這個欄位才會觸發,去查這個使用者發過的文章。

👉 即使是同一筆查詢中,GraphQL 還是會「逐欄位逐層執行各自的 Resolver」,不會一次打包處理完畢。

Resolver 的四個參數是什麼?

在 GraphQL 中,每個欄位的 Resolver 函式都可以接收最多四個參數

它們在查詢執行的過程中由 GraphQL 引擎自動注入,協助你處理資料來源、解析查詢參數、執行認證邏輯、甚至觀察查詢結構本身。

這四個參數分別是:

(fieldName): (parent, args, context, info) => {
  // 這裡是你的 Resolver 邏輯
}

parent:上一層欄位的資料物件(來源)

parent 是前一層欄位的回傳結果,也就是這個欄位所在物件的值。這在處理巢狀結構(Nested Fields)時非常重要。

為什麼重要?

  • 如果你在解析 User.postsparent 就是那個 User 物件。
  • 這樣你就可以用 parent.id 去查詢這位使用者發表的文章。

範例:

User: {
  posts: (parent) => {
    console.log(parent); // { id: "123", name: "小明", ... }
    return getPostsByUserId(parent.id);
  }
}

args:查詢中帶入的參數(使用者輸入)

args 是一個物件,包含前端查詢傳入的參數。這只適用於有參數的欄位(例如 user(id: "123") 中的 id)。

範例:

Query: {
  user: (_, args) => {
    console.log(args); // { id: "123" }
    return getUserById(args.id);
  }
}

小提醒:

  • args 通常會搭配 QueryMutation 使用,也可用於內部欄位支援傳參功能(例如傳入排序方式、分頁條件等)。

context:請求共用的上下文(環境設定)

context 是每次 GraphQL 請求都共用的一份物件,通常用來存放與整個請求相關的資源,例如:

  • 當前登入的使用者資訊(context.currentUser
  • 資料庫連線實體(如 Prisma、Mongoose)
  • DataLoader 實體(用來解決 N+1 問題)
  • 請求的 IP、Header 等原始資訊

範例:

User: {
  email: (parent, _, context) => {
    if (!context.currentUser.isAdmin) {
      throw new Error("沒有權限檢視 email");
    }
    return parent.email;
  }
}

一般會在 Server 啟動時注入 context:

const server = new ApolloServer({
  schema,
  context: ({ req }) => ({
    currentUser: authenticate(req),
    db: myDbClient,
    loaders: createDataLoaders()
  })
});

info:查詢欄位的執行細節(進階用途)

info 是一個描述當前查詢結構的物件,包含欄位名稱、傳回型別、AST 抽象語法樹、執行路徑等資訊。

這通常只在進階情境(例如自動生成查詢、記錄分析、客製化日誌等)才會使用。

常見用途:

  • 查詢當前欄位名稱或欄位清單
  • 取得 GraphQL 查詢的文字表示
  • 判斷呼叫者請求了哪些子欄位(如選擇性延遲加載)

範例(列出查詢欄位):

User: {
  posts: (parent, args, context, info) => {
    const requestedFields = info.fieldNodes[0].selectionSet.selections.map(
      field => field.name.value
    );
    console.log("請求的子欄位有:", requestedFields);
    return getPostsByUserId(parent.id);
  }
}

四個參數的整理比較

用途範圍巢狀結構處理
典型用途從上一層物件取值,如 user.id 查 posts
用途範圍查詢/操作參數解析
典型用途解析查詢參數,如 user(id: "123")
用途範圍跨欄位共用資訊與資源
典型用途帶入登入者資訊、資料庫、DataLoader 等資源
用途範圍進階用法:查詢結構與元資料
典型用途查詢欄位列表、執行細節、AST 抽象語法樹等

查詢時到底會觸發哪些 Resolver?

在 GraphQL 裡,雖然你只送出一筆查詢請求,但實際上這個查詢會被 GraphQL 執行引擎逐層解析(traverse)欄位結構,然後逐一觸發每個欄位對應的 Resolver 函式

換句話說:

GraphQL 查詢不是一次全部執行完,而是按照欄位層級與查詢深度「一層一層執行 Resolver」。

這個「遞迴處理欄位」的設計,是 GraphQL 非常有彈性、可擴充的關鍵。

每個欄位都會觸發對應的 Resolver

來看一個常見的範例查詢:

query {
  user(id: "123") {
    name
    email
    posts {
      title
      createdAt
    }
  }
}

表面上看起來只是一筆查詢,但實際執行時,GraphQL 會依序解析每一層欄位、並對應執行 Resolver。以下是實際的觸發順序與說明:

欄位user
Resolver 是否被呼叫?✅ 是
原因說明最外層 Query 的欄位,通常會有自訂 Resolver
欄位name
Resolver 是否被呼叫?✅ 是(或預設行為)
原因說明如果未定義 Resolver,GraphQL 會從 user 物件自動讀取
欄位email
Resolver 是否被呼叫?✅ 是(或預設行為)
原因說明同上
欄位posts
Resolver 是否被呼叫?✅ 是
原因說明嵌套欄位,會依照 user.id 查使用者的文章
欄位title
Resolver 是否被呼叫?✅ 是(或預設行為)
原因說明每筆文章都會執行一次(依據資料筆數)
欄位createdAt
Resolver 是否被呼叫?✅ 是(或預設行為)
原因說明同上,逐筆執行

補充說明:

  • 如果某個欄位是基本型別(例如 StringInt),且你沒有為它特別定義 Resolver,那 GraphQL 會自動從上一層物件中找出對應屬性。
  • 如果某個欄位是關聯欄位(例如 User.posts 是一對多關係),就一定要手動定義 Resolver,告訴 GraphQL 要怎麼去撈出這些資料。

GraphQL 查詢執行原理:一層一層解析,一層一層執行

GraphQL 查詢其實是一棵「查詢樹(Query Tree)」,GraphQL 執行引擎會從根節點(Root)開始,一層一層地遞迴解析每個欄位,過程如下:

根節點查詢:

  • 執行 Query.user(id: "123")
  • 拿到一個 User 物件(例如 { id: 123, name: "小明", email: "...", ... }

第一層欄位解析:

  • name → 回傳 小明
  • email → 回傳 ming@example.com
  • posts → 執行 User.posts 的 Resolver,拿到文章陣列

第二層嵌套欄位解析(對每筆資料都執行一次):

  • 對於每一篇文章,都要執行一次 titlecreatedAt 的欄位處理

圖解概念(邏輯順序)

Query
└── user(id: "123")         // 呼叫 Query.user Resolver
    ├── name                // 自動讀取 user.name
    ├── email               // 自動讀取 user.email
    └── posts               // 呼叫 User.posts Resolver
        ├── title           // 自動讀取每篇 post  title
        └── createdAt       // 自動讀取每篇 post  createdAt

✅ 每個「節點」都可以視為一次資料查詢動作,而不是整棵樹一次完成。

這樣設計的好處是什麼?

GraphQL 採用「欄位導向(field-level)」的查詢與執行方式,也就是每個欄位都有獨立的 Resolver。

這種設計不像傳統 API 一樣「一次打包一份資料」,而是像拼圖一樣「一塊一塊拼出你要的結果」,帶來非常多實際開發上的好處。

彈性最大化:每個欄位都能獨立處理

GraphQL 的 Resolver 是按欄位定義的,你可以針對每個欄位設計不同的資料來源與邏輯,不必受到資料庫結構或 REST endpoint 限制。

舉例:

假設你有以下欄位 User.posts,這個欄位的資料可以:

  • 資料庫中的 posts 資料表查詢
  • 或從第三方 API 抓取(例如部落格服務)
  • 或從Redis 快取中取得已處理過的結果
  • 甚至可以是依據使用者角色條件動態決定回傳內容

這代表你可以根據不同需求自由組裝欄位來源與邏輯,不會被單一資料結構綁死。

支援嵌套結構:天然適合處理關聯資料

GraphQL 原生支援資料「嵌套查詢(Nested Query)」,而且這正是 Resolver 單欄位執行最重要的理由之一。

在傳統 REST API 中,若你需要查詢一位使用者和他的文章,通常要:

  1. 先打 /users/123 取得使用者資料
  2. 再打 /users/123/posts 取得文章列表

而在 GraphQL 中,只要一次查詢:

query {
  user(id: "123") {
    name
    posts {
      title
    }
  }
}

你會發現每一層欄位都能被 Resolver 獨立處理,也能任意加深層級而不增加請求數,讓資料查詢結構與實際 UI 呈現更接近。

擴充與測試容易:每個欄位都可單獨開發、更新、測試

由於每個欄位有自己對應的 Resolver,你可以:

  • 單獨針對某個欄位寫測試
  • 更新某個欄位的邏輯而不影響其他欄位
  • 用 mock 資料模擬欄位結果進行單元測試或 UI 開發

這種設計讓開發者可以更模組化開發、更快速定位錯誤、更安心地更新單一欄位邏輯而不怕牽一髮動全身

這樣會不會造成效能問題?

有可能會,但這是一個「可以解決的問題」。

雖然 GraphQL 的「每個欄位一個 Resolver」設計帶來極大的彈性,但同時也會對效能帶來挑戰。

尤其在查詢關聯資料(例如一對多、多對多)時,這種逐欄位逐層執行的特性,可能會不小心造成大量重複查詢,進而拖慢效能,甚至壓垮後端。

常見效能問題一:多次重複資料查詢

以一個簡單的查詢為例:

query {
  users {
    name
    posts {
      title
    }
  }
}

假設 users 回傳了 100 筆使用者資料,而每個使用者都查一次 posts,後端就可能:

  • 查詢 users:1 次
  • 查詢 posts:100 次(每位使用者各查一次)

➡️ 總共 101 次查詢!

⚠️ 這就是著名的「N+1 問題」

所謂的 N+1 問題(N+1 Query Problem),是指:

查詢一個主資源後(+1),為每個子項目再各執行一次查詢(+N)

這在小資料量下可能不明顯,但當 N 很大時(例如一次查詢 1000 位使用者),效能會急遽惡化,後端連線數、資料庫 I/O 負載都會飆高,甚至導致服務崩潰。

其他常見效能問題

除了 N+1 查詢之外,欄位級 Resolver 還可能導致:

  • 同一筆資料被查多次(如不同欄位都各自查一次)
  • 不同使用者查詢時無法共用快取結果
  • 第三方 API 遭遇 rate limit(因為每個欄位都重複呼叫)

解法:使用 DataLoader 批次合併與快取查詢

為了解決這個問題,Facebook 團隊推出了 DataLoader 這個工具函式庫,專門用來:

📌 DataLoader 三大核心功能:

Batching(批次處理)

將多次查詢合併成一次資料庫請求,例如將 user.id 為 1、2、3 的請求,合併成:

SELECT * FROM posts WHERE user_id IN (1, 2, 3)

Caching(快取)

同一個請求中,如果多次請求相同資源(如兩個欄位都查 user(123)),DataLoader 會自動重複使用第一次結果。

每個 request 單獨快取(per-request cache)

避免跨請求的資料污染,保證查詢一致性。

範例程式碼:如何使用 DataLoader 合併查詢

// 定義 DataLoader:將多個 userId 合併成一次查詢
const postLoader = new DataLoader(async (userIds) => {
  const posts = await getPostsBatch(userIds); // 假設傳回 [{ userId: 1, ... }, { userId: 2, ... }]
  // 依 userId 整理成 DataLoader 需要的順序結構
  return userIds.map(id => posts.filter(post => post.userId === id));
});

// Resolver:改成呼叫 DataLoader 的 load 方法
const resolvers = {
  User: {
    posts: (user) => postLoader.load(user.id)
  }
};

與原本做法的比較:

傳統 Resolver每個 user 查一次 post,N 次查詢
使用 DataLoader 後一次合併查詢所有 user 的 post
傳統 Resolver❌ 否
使用 DataLoader 後✅ 是
傳統 ResolverO(N) 次查詢
使用 DataLoader 後O(1) 批次查詢
傳統 Resolver高(邏輯分散)
使用 DataLoader 後低(集中批次處理)

常見整合方式:將 DataLoader 放進 context

const context = {
  loaders: {
    postLoader: new DataLoader(batchFetchPosts)
  }
};

// Resolver 中使用
posts: (user, _, context) => context.loaders.postLoader.load(user.id)

這種寫法可以讓每個 request 擁有獨立的 DataLoader 實體,避免快取交叉污染。

延伸補充:DataLoader 不只用於資料庫!

DataLoader 也可以:

  • 合併 API 請求(避免打爆第三方服務)
  • 快取 Redis 讀取結果
  • 整合不同 microservice 查詢(如 BFF 架構)

它的核心價值是:用最少次數取得最多資料,避免做重工。

與傳統 API 查詢邏輯的對比

當你剛接觸 GraphQL,很容易會想:「這不就是另一種寫法的 API 嗎?」

但實際上,GraphQL 在查詢結構、資料流設計、執行邏輯與擴充彈性上,都與傳統的 REST API 有根本性差異。

下面我們從實務角度一項項比較,讓你更清楚了解為什麼 GraphQL 的「欄位級別執行」不只是寫法不同,而是整個思維模式的轉變。

REST API:一次回傳一包資料

傳統 REST API 是以「資源(Resource)」為單位建立的。

每個 endpoint 對應到一種資料結構,資料回傳格式通常是事先定義好的一整包內容。

例如:

GET /users/123
→ 回傳 { id, name, email, posts, roles, profile, ... }

你不能只要 nameemail,除非後端再寫一個只回傳這兩個欄位的新 API。

GraphQL:每個欄位都是動態組合的單位

GraphQL 的最大特點是:查什麼就回什麼(what you ask is what you get)。前端只需要在查詢中列出想要的欄位,後端就會按欄位一個個執行對應的 Resolver,組合出完整結果。

query {
  user(id: "123") {
    name
    email
  }
}

這樣的查詢就只會回傳 nameemail,而不會浪費資源拉取多餘資料。

對比表格:REST vs GraphQL

REST API一個 endpoint 回傳一整包資料
GraphQL(欄位級別執行)每個欄位獨立處理,查什麼就回什麼
REST API由後端預先定義資料結構,前端被動接受
GraphQL(欄位級別執行)前端可以動態組合欄位,自由決定要什麼資料
REST API請求進來就執行完整邏輯,資料一口氣查出
GraphQL(欄位級別執行)每個欄位逐一由 Resolver 執行,支援遞迴與關聯資料結構
REST API一旦資料需求變動,就得新增 endpoint 或修改既有邏輯
GraphQL(欄位級別執行)每個欄位邏輯獨立,可增可減、不會影響整體架構
REST API一個 endpoint 多半綁定一張資料表或一個 Service
GraphQL(欄位級別執行)每個欄位都可以分別串資料庫、API、Redis、快取等多方來源
REST API容易控制,但結構僵化、資料多容易浪費
GraphQL(欄位級別執行)彈性高,但需額外設計 DataLoader、快取策略避免效能瓶頸

🧠 進一步理解:為什麼 GraphQL 更接近「使用者視角」

REST API 傾向「資料庫導向」或「服務導向」,每個 endpoint 都像是「技術服務」的出口,讓前端來取用。

而 GraphQL 是「畫面導向」:資料結構可以根據畫面的需要動態組合,這讓後端變得更像是一個彈性資料工廠,由前端決定如何組裝。

總結:為什麼這樣的設計值得學會?

GraphQL 的設計思維看似複雜,但一旦掌握,就會發現它提供了極強的彈性、擴充性與效能管理彈道。

透過欄位級別的查詢與 Resolver 執行邏輯,GraphQL 可以支援現代前端應用程式所需的:

  • 客製化資料拉取能力
  • 快速迭代與維護效率
  • 多資料來源統一查詢的架構能力

以下是這種設計所帶來的核心價值:

說明每個欄位的 Resolver 都可以獨立拆解、獨立組裝,自由定義資料結構
說明不需重新寫整支 API,只要針對個別欄位進行調整與測試即可
說明GraphQL 可以同時查詢資料庫、API、快取、第三方服務,並統一整合在同一個查詢裡
說明不必多回傳任何用不到的欄位,讓前端與後端通訊更輕量、更有效率
說明資料邏輯清楚分層,利於多人協作與功能拆分,特別適合微服務、BFF(Backend for Frontend)架構