EP.05系統設計系列

API 設計:
REST、GraphQL、tRPC 三種方案的取捨

RESTful 最佳實踐、GraphQL N+1 問題解法、tRPC 零配置型別安全 — 根據情境選對 API 架構

Joseph Chen 2026 18 min read REST · GraphQL · tRPC · API Design

「API 是你後端的門面。設計糟的 API 就像沒有菜單的餐廳,客人只能靠猜。 等你的 API 被一百個前端工程師使用之後,改一個 endpoint 名稱就是一場災難。」

很多人第一次寫 API 是這樣的:GET /getUser?userId=123POST /createNewOrder。 能動,但沒有規範。等到系統越來越大,你會發現每個 endpoint 命名風格都不一樣, Status Code 亂用,前端不知道何時該 retry,出了 bug 沒有人知道從哪裡看。 這篇帶你系統性地理解 API 設計的三種主流方案,以及在不同情境下如何做出正確選擇。

Section 1:為什麼 API 設計很重要

API 設計是軟體工程中一個容易被輕視的環節。大多數初學者在第一次寫後端時,關注的是「能不能動」, 而不是「設計得好不好」。但 API 是前後端之間的契約——一旦上線,改動就有代價。

設計糟糕的 API

text
GET /getUser?userId=123
POST /createNewOrder
GET /fetchAllProductsFromDB
DELETE /deleteUserAndAllHisData?id=456

動詞混入 URL、命名不一致、欄位語義不清、難以維護

設計良好的 RESTful API

text
GET    /users/123
POST   /orders
GET    /products
DELETE /users/456

以資源為核心、HTTP Method 語義清晰、URL 簡潔可預測

API 是你後端的門面。設計糟的 API 就像沒有菜單的餐廳,客人只能靠猜。

好的 API 設計讓前端工程師不需要讀文件就能猜到 endpoint 是什麼。 這種「最少驚喜原則(Principle of Least Surprise)」是 API 設計的核心價值之一。

Section 2:RESTful API 設計原則

REST(Representational State Transfer)不是一個標準,而是一組風格指南。 以下六個核心原則是業界公認的最佳實踐,掌握它們你的 API 就能讓人賞心悅目。

原則 1

以資源為中心(名詞,不是動詞)

URL 代表的是「資源」,動作由 HTTP Method 表達。不要把動詞塞進 URL——那是 RPC 風格,不是 REST。

text
❌ POST /createUser       →  ✅ POST /users
❌ GET  /getUserById/1    →  ✅ GET  /users/1
❌ POST /deletePost       →  ✅ DELETE /posts/1
❌ GET  /getAllOrders      →  ✅ GET  /orders
❌ POST /updateProfile    →  ✅ PATCH /users/1/profile
原則 2

HTTP Method 有語義

不同的 HTTP Method 代表不同的操作意圖,正確使用能讓 API 的行為可預測。 冪等性(Idempotent)意指「重複執行相同請求,結果相同」,這對重試機制很重要。

Method用途冪等有 Body
GET讀取資源✅ 是❌ 否
POST建立資源❌ 否✅ 是
PUT全量更新(取代整個資源)✅ 是✅ 是
PATCH部分更新(只改指定欄位)⚠️ 視實作✅ 是
DELETE刪除資源✅ 是❌ 通常否
原則 3

Status Code 要正確

最常見的錯誤:所有 response 都回傳 200,然後在 body 裡放 success: false。 這讓監控系統無法正確判斷錯誤率,也讓前端工程師非常困惑。正確使用 HTTP Status Code 是專業度的體現。

text
200 OK            - 讀取/更新成功
201 Created       - 建立成功(POST 後回傳,附上新資源 URL)
204 No Content    - 刪除成功(不回傳 body)
400 Bad Request   - 用戶端請求錯誤(欄位缺失、格式錯誤)
401 Unauthorized  - 未登入(沒有 Token 或 Token 無效)
403 Forbidden     - 登入了但沒權限(沒有執行此操作的資格)
404 Not Found     - 資源不存在
422 Unprocessable - 格式對但業務邏輯錯(Email 已被使用)
429 Too Many Req  - 超過 Rate Limit
500 Internal Err  - 你的 bug(伺服器端錯誤)
502 Bad Gateway   - 上游服務(資料庫、微服務)掛了

401 vs 403 的差別:401 是「我不知道你是誰」,403 是「我知道你是誰,但你沒有權限」。 例如:沒帶 JWT token 是 401;帶了 token 但嘗試刪除別人的資料是 403。

原則 4

版本控制

API 版本控制讓你能在不破壞舊客戶端的前提下推出新版 API。 常見的版本控制策略有三種:URL 路徑(最直覺)、Header(最乾淨)、Query String(最懶惰)。

text
// URL 路徑版本(推薦:最直覺,可書籤)
GET /api/v1/users   ← 穩定的舊版,繼續維護
GET /api/v2/users   ← 新版,有破壞性更改(response 結構改變)

// Header 版本(較乾淨,但需要客戶端配合)
GET /api/users
Accept: application/vnd.myapp.v2+json

// Query String 版本(不推薦:會被 cache 搞混)
GET /api/users?version=2

為什麼需要版本?行動端 App 用戶可能還在使用三個月前的舊版本,這些用戶仍在呼叫 v1 API。 如果你直接改 v1,這些用戶的 App 就會壞掉。版本控制讓你可以漸進式遷移。

原則 5

Filter / Sort / Pagination

永遠不要設計「回傳所有資料」的 endpoint,這是資料庫的炸彈。 用 Query Parameters 提供篩選、排序、分頁能力。

text
// 篩選 + 排序 + 分頁
GET /products?category=electronics&sort=-price&page=2&limit=20

// -price 表示降序(負號前綴),+price 或 price 表示升序
// 回傳格式應包含 pagination meta
{
  "data": [...],
  "pagination": {
    "page": 2,
    "limit": 20,
    "total": 347,
    "totalPages": 18
  }
}

// Cursor-based Pagination(更適合大資料集)
GET /posts?after=eyJpZCI6MTAwfQ==&limit=20
原則 6

回應格式標準化

統一的回應格式讓前端工程師能寫通用的 error handler,而不是每個 endpoint 都要特殊處理。

json
// 成功回應
{
  "data": {
    "id": 1,
    "name": "Joseph Chen",
    "email": "joseph@example.com"
  },
  "message": "ok"
}

// 失敗回應(業務邏輯錯誤)
{
  "error": {
    "code": "EMAIL_TAKEN",
    "message": "Email 已被使用,請使用其他 Email",
    "field": "email"
  }
}

// 失敗回應(驗證錯誤,多欄位)
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "請求格式不正確",
    "details": [
      { "field": "email", "message": "不是有效的 Email 格式" },
      { "field": "password", "message": "密碼至少需要 8 個字元" }
    ]
  }
}

Section 3:GraphQL — 你問什麼就給什麼

GraphQL 是 Facebook 在 2015 年開源的查詢語言,為了解決 REST 在複雜前端需求下的兩個核心問題:Over-fetching(拿了太多不需要的欄位)Under-fetching(一個頁面需要打多個 API)

REST 的困境:N+1 查詢問題

javascript
// 你要顯示用戶的個人資料頁(包含他的文章和留言)
// REST 需要這樣做:

GET /users/123           // 第 1 個請求:拿用戶資料(20 個欄位,你只需要 3 個)
GET /users/123/posts     // 第 2 個請求:拿文章列表
GET /posts/456/comments  // 第 3 個請求:拿第一篇文章的留言
GET /posts/789/comments  // 第 4 個請求:拿第二篇文章的留言
// ...

// 如果要顯示 10 個用戶的所有文章:
// 1 個(獲取用戶列表)+ 10 個(每個用戶的文章)= 11 個請求
// 這就是 N+1 問題

GraphQL 用一個 endpoint + 彈性查詢語言解決了這個問題:

graphql
# 一次請求,只拿你要的欄位,深度任意
query {
  user(id: "123") {
    name           # 只拿 name,不要其他 19 個欄位
    email
    posts {
      title
      comments {
        content
        author { name }
      }
    }
  }
}

GraphQL Schema 定義(後端)

GraphQL 是強型別的。你需要先定義 Schema(資料的形狀),前端只能查詢 Schema 裡有的欄位。 這個強約束同時也是它的優勢——IDE 可以提供完整的自動補全。

graphql
# Schema 定義(後端,通常是 schema.graphql 檔案)

type User {
  id: ID!           # ! 表示不可為 null
  name: String!
  email: String!
  posts: [Post!]!   # 陣列,且每個元素不可為 null
  createdAt: String
}

type Post {
  id: ID!
  title: String!
  content: String   # 可為 null(草稿可能沒有內容)
  author: User!
  comments: [Comment!]!
}

type Comment {
  id: ID!
  content: String!
  author: User!
}

# Query:讀取操作
type Query {
  user(id: ID!): User        # 回傳單個用戶(可能為 null)
  users: [User!]!            # 回傳所有用戶列表
  posts(limit: Int): [Post!]!
}

# Mutation:寫入操作
type Mutation {
  createPost(title: String!, content: String): Post!
  updateUser(id: ID!, name: String): User!
  deletePost(id: ID!): Boolean!
}

# Subscription:即時訂閱(需要 WebSocket)
type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

React 中使用 GraphQL(Apollo Client)

tsx
import { gql, useQuery, useMutation } from '@apollo/client';

// 定義 Query(放在元件外,避免每次 render 重新建立)
const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      name
      email
      posts {
        id
        title
      }
    }
  }
`;

const CREATE_POST = gql`
  mutation CreatePost($title: String!, $content: String) {
    createPost(title: $title, content: $content) {
      id
      title
    }
  }
`;

function UserProfile({ id }: { id: string }) {
  // useQuery 自動處理 loading、error、data 狀態
  const { data, loading, error } = useQuery(GET_USER, {
    variables: { id },
  });

  const [createPost] = useMutation(CREATE_POST, {
    // 建立成功後自動重新 fetch 用戶資料
    refetchQueries: [{ query: GET_USER, variables: { id } }],
  });

  if (loading) return <div>載入中...</div>;
  if (error) return <div>錯誤:{error.message}</div>;

  return (
    <div>
      <h1>{data.user.name}</h1>
      <p>{data.user.email}</p>
      <ul>
        {data.user.posts.map((post: any) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

GraphQL 的缺點

  • 學習曲線:需要理解 Schema、Resolver、DataLoader 等概念
  • N+1 問題沒有自動消失:Resolver 層仍需用 DataLoader 做 batch 查詢
  • 快取困難:REST 可以用 URL 做 HTTP 快取,GraphQL 需要 Apollo Cache 等工具
  • 對外 API 不友善:第三方開發者更熟悉 REST

Section 4:tRPC — 前後端型別完全共享

tRPC(TypeScript Remote Procedure Call)是 2021 年興起的方案,專門為 TypeScript 全端專案設計。 它的核心概念很簡單:後端定義 procedure(程序),前端直接呼叫,型別自動推導,零 schema、零 code generation

用 REST 的時候,你通常需要手動維護 TypeScript 型別或 OpenAPI schema,這很容易讓前後端型別不同步。 GraphQL 需要 code generation(graphql-codegen)才能有型別。 tRPC 直接跳過這些:後端的回傳型別,前端自動就有。

後端定義(Next.js API Routes)

typescript
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  // Query:讀取操作
  getUser: t.procedure
    .input(z.object({ id: z.string() }))  // Zod 驗證 input
    .query(async ({ input }) => {
      const user = await db.user.findUnique({
        where: { id: input.id },
      });
      return user;  // 回傳型別自動推導為 User | null
    }),

  // 巢狀 router(模組化)
  post: t.router({
    list: t.procedure
      .input(z.object({ limit: z.number().optional() }))
      .query(async ({ input }) => {
        return db.post.findMany({ take: input.limit ?? 10 });
      }),

    create: t.procedure
      .input(
        z.object({
          title: z.string().min(1).max(100),
          content: z.string(),
        })
      )
      .mutation(async ({ input }) => {
        // input.title 是 string,input.content 是 string
        // Zod 已驗證,不需要再手動 check
        return db.post.create({ data: input });
      }),

    delete: t.procedure
      .input(z.object({ id: z.string() }))
      .mutation(async ({ input }) => {
        await db.post.delete({ where: { id: input.id } });
        return { success: true };
      }),
  }),
});

// 匯出型別(這是 tRPC 魔法的關鍵)
export type AppRouter = typeof appRouter;

前端使用(完整型別,零配置)

tsx
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/router';

// 把後端的 AppRouter 型別帶到前端
export const trpc = createTRPCReact<AppRouter>();

// --- 元件中使用 ---
// app/profile/page.tsx
import { trpc } from '@/utils/trpc';

function UserProfile({ id }: { id: string }) {
  // 完整的型別提示!
  // data 的型別自動推導為 { id: string; name: string; email: string } | null
  const { data, isLoading } = trpc.getUser.useQuery({ id });

  // 後端的 input schema 也完整共享
  // 如果傳 { id: 123 }(number)會立即報 TypeScript 錯誤
  const createPost = trpc.post.create.useMutation({
    onSuccess: () => {
      // 建立成功後 invalidate 查詢快取
      utils.post.list.invalidate();
    },
  });

  const utils = trpc.useUtils();

  if (isLoading) return <div>載入中...</div>;

  return (
    <div>
      <h1>{data?.name}</h1>
      <button
        onClick={() =>
          createPost.mutate({
            title: '新文章',
            content: '內容...',
            // TypeScript 會確保這裡的型別符合後端 Zod schema
          })
        }
      >
        新增文章
      </button>
    </div>
  );
}

tRPC 的核心優勢

  • 型別安全 100%:後端改了回傳欄位,前端立刻報錯,不需要任何 codegen
  • 重構友善:rename 一個 procedure,IDE 會幫你找到所有使用到的地方
  • 開發速度快:不需要寫 OpenAPI spec、不需要 graphql-codegen
  • DX 極佳:後端和前端工程師共享同一個 TypeScript 型別系統

限制:只適合前後端同一個 TypeScript monorepo 的場景。 如果有 iOS App、Android App、第三方合作方,tRPC 就幫不了你了。

Section 5:三種方案全面對比

沒有最好的方案,只有最適合當前情境的方案。以下對比幫助你在做技術選型時有清晰的依據。

比較維度RESTGraphQLtRPC
學習成本低(需 TypeScript)
型別安全手動維護
(OpenAPI/Swagger)
Schema 生成
(需 codegen)
自動推導
(100% 覆蓋)
前後端共用型別⚠️ 需 codegen✅ 原生支援
適合微服務❌ 單一 repo
行動端 / 外部 API✅ 最通用
效能可能 N+1
(需手動最佳化)
Batch(DataLoader)
需額外設定
等同 REST
(底層同 HTTP)
HTTP 快取✅ 原生支援❌ 需 Apollo⚠️ React Query
2026 趨勢主流(穩定)下降中上升中

GraphQL 的趨勢下降主要原因:複雜度高、學習曲線陡峭、大多數應用其實不需要它的彈性查詢能力。 tRPC 的崛起則是因為 Next.js 生態的成熟,越來越多團隊採用 TypeScript 全端方案。

Section 6:決策指南 — 如何選擇

技術選型沒有對錯,關鍵是理解每個方案的適用情境。用以下決策樹幫助你做出有依據的選擇:

API 技術選型決策樹

text
你的 API 需要對外公開(第三方開發者、行動端 App、合作夥伴)?
  ↓ 是
  → 用 REST(最通用,開發者熟悉,有 HTTP 快取,文件工具完整)
  ↓ 否
是 Next.js + TypeScript 的全端專案(前後端同一個 repo)?
  ↓ 是
  → 用 tRPC(零配置型別安全,DX 最好,重構最容易)
  ↓ 否
前端需要高度彈性的資料查詢?
(例如:social media、複雜 dashboard、用戶自訂查詢欄位)
  ↓ 是
  → 用 GraphQL(但要評估團隊學習成本)
  ↓ 否
→ 用 REST(最穩健的預設選擇)

選 REST 的情境

  • ✓ 有外部 API 消費者
  • ✓ 行動端(iOS / Android)
  • ✓ 微服務架構
  • ✓ 團隊有不熟 TypeScript 的成員
  • ✓ 需要 CDN / HTTP 快取
  • ✓ 預設選擇(最保險)

選 GraphQL 的情境

  • ✓ 複雜的前端資料需求
  • ✓ 多個前端(web + mobile)共用同一 API
  • ✓ 資料關聯複雜(社交圖譜)
  • ✓ 需要即時訂閱(Subscription)
  • ✓ 團隊已有 GraphQL 經驗

選 tRPC 的情境

  • ✓ Next.js + TypeScript 全端
  • ✓ 小到中型團隊
  • ✓ 快速迭代的 SaaS 產品
  • ✓ 重視 DX 和型別安全
  • ✓ 前後端共用型別是關鍵需求

Section 7:REST API 安全性速查

這是後端工程師最容易忽略的部分。功能做好了,但安全沒做到位,一樣是爛的 API。 以下是每個後端 API 都必須考慮的五個安全面向:

1

Authentication:JWT Bearer Token

typescript
// 每個需要登入的 API,必須驗證 JWT Token
import jwt from 'jsonwebtoken';

function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization;

  // Authorization: Bearer <token>
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({
      error: { code: 'MISSING_TOKEN', message: '請先登入' }
    });
  }

  const token = authHeader.split(' ')[1];
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!);
    req.user = payload;  // 把用戶資訊掛到 request 上
    next();
  } catch {
    return res.status(401).json({
      error: { code: 'INVALID_TOKEN', message: 'Token 已過期或無效' }
    });
  }
}
2

Rate Limiting:防止濫用

typescript
import rateLimit from 'express-rate-limit';

// 全域 Rate Limit
const globalLimiter = rateLimit({
  windowMs: 60 * 1000,    // 1 分鐘視窗
  max: 100,               // 最多 100 次請求
  message: {
    error: {
      code: 'RATE_LIMIT_EXCEEDED',
      message: '請求過於頻繁,請稍後再試',
    },
  },
});

// 登入 API 的嚴格 Rate Limit(防止暴力破解密碼)
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 分鐘
  max: 5,                     // 最多 5 次失敗嘗試
});

app.use('/api', globalLimiter);
app.post('/api/auth/login', loginLimiter, loginHandler);
3

CORS:只允許你的前端 Domain

typescript
import cors from 'cors';

app.use(cors({
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://yourdomain.com',
      'https://www.yourdomain.com',
      // 開發環境才允許 localhost
      ...(process.env.NODE_ENV === 'development'
        ? ['http://localhost:3000']
        : []),
    ];

    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('CORS policy: 不允許此來源'));
    }
  },
  credentials: true,       // 允許攜帶 Cookie
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
}));
4

Input Validation:永遠不相信用戶輸入

typescript
import { z } from 'zod';

// 用 Zod 定義嚴格的 Schema
const createUserSchema = z.object({
  email: z.string().email('不是有效的 Email'),
  password: z.string().min(8, '密碼至少 8 個字元').max(100),
  name: z.string().min(1).max(50).trim(),
  age: z.number().int().min(0).max(120).optional(),
});

app.post('/api/users', (req, res) => {
  // parse() 會拋出錯誤,safeParse() 會回傳 result
  const result = createUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: {
        code: 'VALIDATION_ERROR',
        details: result.error.issues.map(i => ({
          field: i.path.join('.'),
          message: i.message,
        })),
      },
    });
  }

  // result.data 已經過驗證且型別安全
  const { email, password, name } = result.data;
  // ... 繼續處理
});
5

SQL Injection 防範:用 ORM 或 Prepared Statement

typescript
// ❌ 危險:直接拼接 SQL 字串
const query = `SELECT * FROM users WHERE email = '${email}'`;
// 攻擊者可以輸入:' OR '1'='1 來繞過驗證

// ✅ 安全:Prisma ORM(自動防 SQL Injection)
const user = await prisma.user.findUnique({
  where: { email },  // Prisma 自動處理轉義
});

// ✅ 安全:Prepared Statement(原生 SQL)
const result = await db.query(
  'SELECT * FROM users WHERE email = $1',
  [email]  // 參數化查詢,不直接插入字串
);

安全性核心原則

  • 永遠在後端驗證 input(前端驗證只是 UX,不是安全)
  • 密碼使用 bcrypt / argon2 雜湊,永遠不要明文儲存
  • 敏感操作(刪帳號、改 Email)要求重新輸入密碼確認
  • Log 不要記錄密碼、Token、信用卡號等敏感資訊
  • HTTPS 是必須的,不是選配
REST API
GraphQL
tRPC
API Design
System Design
TypeScript