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

更新日期: 2025 年 5 月 6 日

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);
  }
}

四個參數的整理比較

參數用途範圍典型用途
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 是否被呼叫?原因說明
1user✅ 是最外層 Query 的欄位,通常會有自訂 Resolver
2name✅ 是(或預設行為)如果未定義 Resolver,GraphQL 會從 user 物件自動讀取
3email✅ 是(或預設行為)同上
4posts✅ 是嵌套欄位,會依照 user.id 查使用者的文章
5title✅ 是(或預設行為)每筆文章都會執行一次(依據資料筆數)
6createdAt✅ 是(或預設行為)同上,逐筆執行

補充說明:

  • 如果某個欄位是基本型別(例如 StringInt),且你沒有為它特別定義 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,拿到文章陣列

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

  • 對於每一篇文章,都要執行一次 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使用 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, ... }

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

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

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

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

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

對比表格:REST vs GraphQL

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

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

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

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

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

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

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

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

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

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

Similar Posts

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *