為什麼 GraphQL Resolver 是以欄位為單位觸發?從根本理解查詢邏輯
更新日期: 2025 年 5 月 6 日
GraphQL 是一種查詢語言(Query Language),它讓前端可以精確地定義需要的資料欄位,只拿想要的資料、不多不少。
然而,如果你是從 REST API 或資料庫查詢背景轉過來的新手,會對一件事感到困惑:
為什麼明明只送出一次 GraphQL 查詢,卻會執行那麼多 Resolver 函式?
這篇文章會帶你拆解 GraphQL 查詢的執行過程,並深入理解「Resolver 是以欄位為單位觸發」的設計哲學與實際效益。
GraphQL 中的欄位:你查詢的每一項資料,都是一個欄位
在 GraphQL 中,「欄位(field)」是整個查詢語言的最小單位,也是資料請求的基本單位。
當你撰寫一段 GraphQL 查詢時,每一行你寫下的屬性名稱,例如 name
、email
、title
,其實都是一個「欄位」。
🧠 換個說法來理解:
當你送出一個 GraphQL 查詢,就像是在對後端說:「我需要這個資料型別中的哪些部分」。
你不是要整包資料,而是精確挑選要哪些欄位,這種按需取用(field selection)的特性就是 GraphQL 的強大之處。
舉例說明
假設我們有一個查詢如下:
query {
user(id: "123") {
name
email
profilePicture
}
}
這個查詢的意思是:
- 我想查一位
user
,ID 是 123。 - 但我只需要這位使用者的
name
、email
和profilePicture
,其餘資料(如帳號建立時間、電話、角色權限等)一律不需要傳回。
每一個被寫在 { ... }
中的項目,就是一個「欄位(field)」。在這個例子裡:
user
是一個欄位(出現在 Query 中)name
、email
、profilePicture
是User
型別中的欄位
欄位的「型別」是什麼?
GraphQL 中每個欄位都有一個「回傳型別(return type)」。
例如:
type User {
name: String
email: String
posts: [Post]
}
這裡的 name
欄位回傳一個 String
,posts
欄位則回傳一個 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 的職責通常包含:
- 拿資料(從資料庫、API、記憶體等)
- 處理邏輯(過濾、排序、轉換格式)
- 驗證權限(是否能看這筆資料)
- 回傳符合 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.posts
,parent
就是那個 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
通常會搭配Query
和Mutation
使用,也可用於內部欄位支援傳參功能(例如傳入排序方式、分頁條件等)。
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);
}
}
四個參數的整理比較
參數 | 用途範圍 | 典型用途 |
---|---|---|
parent | 巢狀結構處理 | 從上一層物件取值,如 user.id 查 posts |
args | 查詢/操作參數解析 | 解析查詢參數,如 user(id: "123") |
context | 跨欄位共用資訊與資源 | 帶入登入者資訊、資料庫、DataLoader 等資源 |
info | 進階用法:查詢結構與元資料 | 查詢欄位列表、執行細節、AST 抽象語法樹等 |
查詢時到底會觸發哪些 Resolver?
在 GraphQL 裡,雖然你只送出一筆查詢請求,但實際上這個查詢會被 GraphQL 執行引擎逐層解析(traverse)欄位結構,然後逐一觸發每個欄位對應的 Resolver 函式。
換句話說:
GraphQL 查詢不是一次全部執行完,而是按照欄位層級與查詢深度「一層一層執行 Resolver」。
這個「遞迴處理欄位」的設計,是 GraphQL 非常有彈性、可擴充的關鍵。
每個欄位都會觸發對應的 Resolver
來看一個常見的範例查詢:
query {
user(id: "123") {
name
email
posts {
title
createdAt
}
}
}
表面上看起來只是一筆查詢,但實際執行時,GraphQL 會依序解析每一層欄位、並對應執行 Resolver。以下是實際的觸發順序與說明:
觸發順序 | 欄位 | Resolver 是否被呼叫? | 原因說明 |
---|---|---|---|
1 | user | ✅ 是 | 最外層 Query 的欄位,通常會有自訂 Resolver |
2 | name | ✅ 是(或預設行為) | 如果未定義 Resolver,GraphQL 會從 user 物件自動讀取 |
3 | ✅ 是(或預設行為) | 同上 | |
4 | posts | ✅ 是 | 嵌套欄位,會依照 user.id 查使用者的文章 |
5 | title | ✅ 是(或預設行為) | 每筆文章都會執行一次(依據資料筆數) |
6 | createdAt | ✅ 是(或預設行為) | 同上,逐筆執行 |
補充說明:
- 如果某個欄位是基本型別(例如
String
、Int
),且你沒有為它特別定義 Resolver,那 GraphQL 會自動從上一層物件中找出對應屬性。 - 如果某個欄位是關聯欄位(例如
User.posts
是一對多關係),就一定要手動定義 Resolver,告訴 GraphQL 要怎麼去撈出這些資料。
GraphQL 查詢執行原理:一層一層解析,一層一層執行
GraphQL 查詢其實是一棵「查詢樹(Query Tree)」,GraphQL 執行引擎會從根節點(Root)開始,一層一層地遞迴解析每個欄位,過程如下:
根節點查詢:
- 執行
Query.user(id: "123")
- 拿到一個
User
物件(例如{ id: 123, name: "小明", email: "...", ... }
)
第一層欄位解析:
name
→ 回傳小明
email
→ 回傳[email protected]
posts
→ 執行User.posts
的 Resolver,拿到文章陣列
第二層嵌套欄位解析(對每筆資料都執行一次):
- 對於每一篇文章,都要執行一次
title
和createdAt
的欄位處理
圖解概念(邏輯順序)
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 中,若你需要查詢一位使用者和他的文章,通常要:
- 先打
/users/123
取得使用者資料 - 再打
/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 | 使用 DataLoader 後 |
---|---|---|
查詢次數 | 每個 user 查一次 post,N 次查詢 | 一次合併查詢所有 user 的 post |
是否可快取相同查詢 | ❌ 否 | ✅ 是 |
效能表現 | O(N) 次查詢 | O(1) 批次查詢 |
維護成本 | 高(邏輯分散) | 低(集中批次處理) |
常見整合方式:將 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, ... }
你不能只要 name
和 email
,除非後端再寫一個只回傳這兩個欄位的新 API。
GraphQL:每個欄位都是動態組合的單位
GraphQL 的最大特點是:查什麼就回什麼(what you ask is what you get)。前端只需要在查詢中列出想要的欄位,後端就會按欄位一個個執行對應的 Resolver,組合出完整結果。
query {
user(id: "123") {
name
email
}
}
這樣的查詢就只會回傳 name
和 email
,而不會浪費資源拉取多餘資料。
對比表格:REST vs GraphQL
面向 | REST API | GraphQL(欄位級別執行) |
---|---|---|
🔹 查詢單位 | 一個 endpoint 回傳一整包資料 | 每個欄位獨立處理,查什麼就回什麼 |
🔹 查詢方式 | 由後端預先定義資料結構,前端被動接受 | 前端可以動態組合欄位,自由決定要什麼資料 |
🔹 執行邏輯 | 請求進來就執行完整邏輯,資料一口氣查出 | 每個欄位逐一由 Resolver 執行,支援遞迴與關聯資料結構 |
🔹 擴充與維護 | 一旦資料需求變動,就得新增 endpoint 或修改既有邏輯 | 每個欄位邏輯獨立,可增可減、不會影響整體架構 |
🔹 資料來源限制 | 一個 endpoint 多半綁定一張資料表或一個 Service | 每個欄位都可以分別串資料庫、API、Redis、快取等多方來源 |
🔹 效能調校難易度 | 容易控制,但結構僵化、資料多容易浪費 | 彈性高,但需額外設計 DataLoader、快取策略避免效能瓶頸 |
🧠 進一步理解:為什麼 GraphQL 更接近「使用者視角」
REST API 傾向「資料庫導向」或「服務導向」,每個 endpoint 都像是「技術服務」的出口,讓前端來取用。
而 GraphQL 是「畫面導向」:資料結構可以根據畫面的需要動態組合,這讓後端變得更像是一個彈性資料工廠,由前端決定如何組裝。
總結:為什麼這樣的設計值得學會?
GraphQL 的設計思維看似複雜,但一旦掌握,就會發現它提供了極強的彈性、擴充性與效能管理彈道。
透過欄位級別的查詢與 Resolver 執行邏輯,GraphQL 可以支援現代前端應用程式所需的:
- 客製化資料拉取能力
- 快速迭代與維護效率
- 多資料來源統一查詢的架構能力
以下是這種設計所帶來的核心價值:
優點 | 說明 |
---|---|
🧩 可組合性強 | 每個欄位的 Resolver 都可以獨立拆解、獨立組裝,自由定義資料結構 |
🧪 易於測試與維護 | 不需重新寫整支 API,只要針對個別欄位進行調整與測試即可 |
🔄 支援多資料來源 | GraphQL 可以同時查詢資料庫、API、快取、第三方服務,並統一整合在同一個查詢裡 |
🎯 精準控制資料回傳 | 不必多回傳任何用不到的欄位,讓前端與後端通訊更輕量、更有效率 |
🚀 適合大型應用與模組開發 | 資料邏輯清楚分層,利於多人協作與功能拆分,特別適合微服務、BFF(Backend for Frontend)架構 |