Web Dev EP.36

GraphQL:
重新定義 API 互動

不再被 REST 的多個 Endpoint 所困擾。學習如何讓前端精確定義所需的資料,並透過 Apollo Server 打造高效能的 API 層。

為什麼我們需要 GraphQL?

在傳統的 REST API 中,前端開發者經常面臨兩個頭痛的問題:Over-fetchingUnder-fetching

Over-fetching

你只需要使用者的姓名,但 /api/user/1 卻回傳了包含地址、電話、幾百行歷史紀錄的龐大 JSON,造成頻寬浪費。

Under-fetching

你想顯示一篇貼文和其作者資訊,必須先打 /api/post/1 拿到作者 ID,再打一次 /api/user/ID。這種 N+1 請求讓載入變慢。

GraphQL 的解法:精確請求

前端直接描述它想要的資料結構,伺服器保證只回傳那些欄位,且一次請求就能拿到所有巢狀資料。

前端發送的 Query
query GetPostDetails {
  post(id: "1") {
    title
    content
    author {
      name
      avatar
    }
  }
}
後端回傳的 JSON
{
  "data": {
    "post": {
      "title": "GraphQL 入門",
      "content": "...",
      "author": {
        "name": "Joseph",
        "avatar": "/img/j.png"
      }
    }
  }
}

一、Schema:定義你的資料契約

GraphQL 是強型別的。在寫任何程式碼之前,你必須使用 SDL (Schema Definition Language) 定義資料模型。

schema.graphql
type User {
  id: ID!
  name: String!
  email: String
  posts: [Post]
}

type Post {
  id: ID!
  title: String!
  author: User!
}

# 所有的進入點
type Query {
  user(id: ID!): User
  allPosts: [Post]
}

# 所有的寫入操作
type Mutation {
  createPost(title: String!, authorId: ID!): Post
}
* ! 代表該欄位為必填 (Non-Null)。

二、Resolvers:資料的加工廠

Schema 只是「規格」,真正的邏輯寫在 Resolvers。每個 Schema 中的欄位,都對應到一個 Resolver 函數,負責去資料庫、快取、甚至另一個 API 抓取資料。

resolvers.ts
const resolvers = {
  Query: {
    user: async (parent, { id }, context) => {
      return await db.users.findById(id);
    },
    allPosts: () => db.posts.findMany(),
  },
  
  // 處理巢狀關聯
  Post: {
    author: async (post) => {
      // 这里的 post 就是上層傳下來的 parent 資料
      return await db.users.findById(post.authorId);
    }
  },

  Mutation: {
    createPost: async (_, { title, authorId }) => {
      return await db.posts.create({ title, authorId });
    }
  }
};
警告:N+1 查詢陷阱

如果上面的 `Post.author` 被呼叫了 100 次(當你請求 100 篇貼文時),這會導致資料庫被查詢 101 次。 這在 REST 裡是常見問題,在 GraphQL 裡如果你不注意,會變得更嚴重。


三、DataLoader:快取與批次處理

為了解決 N+1 問題,我們使用 Facebook 開源的 DataLoader。 它的原理是:在一個 Tick 內收集所有的 ID,然後用一個 `WHERE IN (id1, id2...)` 的 Query 一次抓回來。

使用 DataLoader 優化後端
import DataLoader from 'dataloader';

// 1. 定義批次抓取函數
const batchUsers = async (userIds) => {
  const users = await db.users.findMany({
    where: { id: { in: userIds } }
  });
  // 必須保證回傳順序與傳入的 userIds 一致
  return userIds.map(id => users.find(u => u.id === id));
};

// 2. 初始化 DataLoader
const userLoader = new DataLoader(batchUsers);

// 3. 在 Resolver 中使用
const resolvers = {
  Post: {
    author: (post) => userLoader.load(post.authorId) // 這裡會自動進行 Batching
  }
};

四、Apollo Client:前端的狀態管理

在 React 中,Apollo Client 不只是發送請求的工具,它還是一個強大的「本地快取」。它會自動把抓回來的資料根據 `__typename` 和 `id` 進行正規化 (Normalization)。

React 元件實作
import { useQuery, gql } from '@apollo/client';

const GET_POSTS = gql`
  query GetAllPosts {
    allPosts {
      id
      title
      author { name }
    }
  }
`;

function PostList() {
  const { loading, error, data } = useQuery(GET_POSTS);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error :(</p>;

  return data.allPosts.map(post => (
    <div key={post.id}>
      <h3>{post.title}</h3>
      <p>By: {post.author.name}</p>
    </div>
  ));
}

五、Subscriptions:即時資料流

GraphQL 內建支援 Subscriptions,底層通常透過 WebSocket 實作。這對於即時聊天室、股票行情或通知系統非常完美。

Schema 定義即時通知
type Subscription {
  postCreated: Post
}
後端觸發推送
// 使用 PubSub 機制
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();

const resolvers = {
  Mutation: {
    createPost: async (_, args) => {
      const newPost = await db.posts.create(args);
      pubsub.publish('POST_CREATED', { postCreated: newPost });
      return newPost;
    }
  },
  Subscription: {
    postCreated: {
      subscribe: () => pubsub.asyncIterator(['POST_CREATED']),
    },
  },
};

GraphQL vs REST:該如何選擇?

特性REST APIGraphQL
資料獲取固定結構,容易 Overfetch精確請求,絕不浪費
進入點多個 URL (End-points)單一 URL (/graphql)
強型別需依賴 Swagger/OpenAPI原生 Schema 支援
快取優秀 (基於 HTTP Cache)較困難 (需依賴 Client Cache)

總結:給初學者的話

GraphQL 不是為了取代 REST,而是為了解決「複雜前端應用」與「多樣化資料需求」而生的。如果你正在開發一個大型、資料關係緊密的專案,GraphQL 會是你的救星。

#GraphQL#Apollo#API#Backend#Fullstack#Schema#DataLoader