回應設計原則:打造專業的 API
好的 API 不只是功能正確,回應設計同樣重要。本篇將介紹如何設計一致、清晰、易用的 API 回應。
一、 狀態碼選擇
1.1 常用狀態碼
| 狀態碼 | 說明 | 使用場景 |
|---|---|---|
| 200 | OK | 成功的 GET/PUT/PATCH |
| 201 | Created | 成功的 POST(創建資源) |
| 204 | No Content | 成功的 DELETE |
| 400 | Bad Request | 請求格式錯誤 |
| 401 | Unauthorized | 未認證 |
| 403 | Forbidden | 無權限 |
| 404 | Not Found | 資源不存在 |
| 409 | Conflict | 資源衝突 |
| 422 | Unprocessable Entity | 驗證失敗 |
| 429 | Too Many Requests | 請求過多 |
| 500 | Internal Server Error | 伺服器錯誤 |
1.2 狀態碼決策樹
1.3 常見錯誤
javascript
// ❌ 錯誤:所有錯誤都返回 200
res.status(200).json({ success: false, error: "Not found" });
// ✅ 正確:使用適當的狀態碼
res.status(404).json({ error: "Resource not found" });
// ❌ 錯誤:業務錯誤用 500
res.status(500).json({ error: "Email already exists" });
// ✅ 正確:業務錯誤用 4xx
res.status(409).json({ error: "Email already exists" });二、 回應格式設計
2.1 成功回應
javascript
// 單一資源
{
"id": 123,
"name": "John",
"email": "john@example.com",
"createdAt": "2025-01-13T00:00:00Z"
}
// 列表資源
{
"data": [
{ "id": 1, "name": "John" },
{ "id": 2, "name": "Jane" }
],
"pagination": {
"page": 1,
"limit": 10,
"total": 100,
"totalPages": 10
}
}
// 操作結果
{
"message": "User created successfully",
"data": { "id": 123, "name": "John" }
}2.2 錯誤回應
javascript
// 簡單錯誤
{
"error": {
"code": "NOT_FOUND",
"message": "User not found"
}
}
// 驗證錯誤
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "age", "message": "Age must be a positive number" }
]
}
}
// 開發模式(包含堆疊追蹤)
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Something went wrong",
"stack": "Error: Something went wrong\n at ..."
}
}2.3 統一回應結構
javascript
// 封裝回應
function sendSuccess(res, data, statusCode = 200) {
res.status(statusCode).json(data);
}
function sendError(res, code, message, details = null, statusCode = 400) {
res.status(statusCode).json({
error: {
code,
message,
...(details && { details }),
...(process.env.NODE_ENV === "development" && {
stack: new Error().stack,
}),
},
});
}
function sendPaginated(res, data, pagination) {
res.json({
data,
pagination,
});
}三、 分頁設計
3.1 Offset 分頁
javascript
// GET /users?page=2&limit=10
app.get("/users", async (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 10));
const offset = (page - 1) * limit;
const [users, total] = await Promise.all([
User.find().skip(offset).limit(limit),
User.countDocuments(),
]);
res.json({
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
});
});優點:直觀、可以跳頁 缺點:大數據量時效能差、資料變更會跳過/重複
3.2 Cursor 分頁
javascript
// GET /users?cursor=abc123&limit=10
app.get("/users", async (req, res) => {
const limit = parseInt(req.query.limit) || 10;
const cursor = req.query.cursor;
const query = cursor ? { _id: { $gt: cursor } } : {};
const users = await User.find(query)
.sort({ _id: 1 })
.limit(limit + 1); // 多取一個判斷 hasNext
const hasNext = users.length > limit;
if (hasNext) users.pop();
res.json({
data: users,
pagination: {
nextCursor: hasNext ? users[users.length - 1]._id : null,
hasNext,
},
});
});優點:效能好、資料一致 缺點:不能跳頁、需要排序欄位
3.3 Link 標頭(GitHub 風格)
javascript
app.get("/users", async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = 10;
const total = await User.countDocuments();
const totalPages = Math.ceil(total / limit);
const links = [];
if (page > 1) {
links.push(`<${baseUrl}?page=1>; rel="first"`);
links.push(`<${baseUrl}?page=${page - 1}>; rel="prev"`);
}
if (page < totalPages) {
links.push(`<${baseUrl}?page=${page + 1}>; rel="next"`);
links.push(`<${baseUrl}?page=${totalPages}>; rel="last"`);
}
res.set("Link", links.join(", "));
res.set("X-Total-Count", total);
res.json(users);
});四、 錯誤處理架構
4.1 錯誤類別
javascript
class ApiError extends Error {
constructor(statusCode, code, message, details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.isOperational = true;
}
}
class NotFoundError extends ApiError {
constructor(resource = "Resource") {
super(404, "NOT_FOUND", `${resource} not found`);
}
}
class ValidationError extends ApiError {
constructor(details) {
super(422, "VALIDATION_ERROR", "Validation failed", details);
}
}
class UnauthorizedError extends ApiError {
constructor(message = "Authentication required") {
super(401, "UNAUTHORIZED", message);
}
}
class ForbiddenError extends ApiError {
constructor(message = "Permission denied") {
super(403, "FORBIDDEN", message);
}
}4.2 錯誤處理中介軟體
javascript
function errorHandler(err, req, res, next) {
// 記錄錯誤
console.error({
error: err.message,
stack: err.stack,
url: req.url,
method: req.method,
requestId: req.id,
});
// 已知的操作錯誤
if (err.isOperational) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
},
});
}
// 未知錯誤
res.status(500).json({
error: {
code: "INTERNAL_ERROR",
message:
process.env.NODE_ENV === "production"
? "An unexpected error occurred"
: err.message,
},
});
}
app.use(errorHandler);4.3 非同步錯誤處理
javascript
// 包裝非同步路由
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// 使用
app.get(
"/users/:id",
asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError("User");
}
res.json(user);
})
);五、 回應標頭
5.1 常用標頭
javascript
app.use((req, res, next) => {
// 內容類型
res.setHeader("Content-Type", "application/json; charset=utf-8");
// 快取控制
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
// 安全標頭
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-XSS-Protection", "1; mode=block");
// 請求追蹤
res.setHeader("X-Request-Id", req.id);
next();
});5.2 CORS 標頭
javascript
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "https://example.com");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "86400");
next();
});5.3 限流標頭
javascript
function rateLimit(req, res, next) {
const limit = 100;
const remaining = getRemainingRequests(req.ip);
const reset = getResetTime(req.ip);
res.setHeader("X-RateLimit-Limit", limit);
res.setHeader("X-RateLimit-Remaining", remaining);
res.setHeader("X-RateLimit-Reset", reset);
if (remaining <= 0) {
res.setHeader("Retry-After", Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: "Too many requests" });
}
next();
}六、 效能優化
6.1 壓縮
javascript
const compression = require("compression");
app.use(
compression({
filter: (req, res) => {
if (req.headers["x-no-compression"]) {
return false;
}
return compression.filter(req, res);
},
threshold: 1024, // 只壓縮超過 1KB 的回應
})
);6.2 條件請求(ETag)
javascript
const etag = require("etag");
app.get("/users/:id", async (req, res) => {
const user = await User.findById(req.params.id);
const userJson = JSON.stringify(user);
const hash = etag(userJson);
res.setHeader("ETag", hash);
if (req.headers["if-none-match"] === hash) {
return res.status(304).end();
}
res.json(user);
});6.3 欄位篩選
javascript
// GET /users?fields=id,name,email
app.get("/users", async (req, res) => {
const fields = req.query.fields?.split(",").join(" ") || "";
const users = await User.find().select(fields);
res.json(users);
});七、 時間格式
7.1 使用 ISO 8601
javascript
// ✅ 推薦:ISO 8601 格式
{
"createdAt": "2025-01-13T12:00:00Z",
"updatedAt": "2025-01-13T14:30:00+08:00"
}
// ❌ 避免:Unix 時間戳
{
"createdAt": 1736769600
}
// ❌ 避免:自定義格式
{
"createdAt": "2025/01/13 12:00:00"
}7.2 統一處理
javascript
// 序列化時自動轉換
const userSchema = new Schema(
{
createdAt: { type: Date, default: Date.now },
},
{
toJSON: {
transform: (doc, ret) => {
ret.createdAt = ret.createdAt.toISOString();
return ret;
},
},
}
);總結
| 原則 | 說明 |
|---|---|
| 正確狀態碼 | 用狀態碼表達結果 |
| 一致格式 | 成功/錯誤結構統一 |
| 清晰錯誤 | 錯誤碼 + 訊息 + 詳情 |
| 標準分頁 | offset 或 cursor |
| 適當快取 | ETag、Cache-Control |
| ISO 8601 | 時間格式標準化 |
> **黃金法則**:API 回應應該讓開發者看一眼就知道:成功或失敗、失敗原因、如何修正。
進階挑戰
- 設計一個錯誤碼系統(如
AUTH_001、USER_002),讓錯誤可追蹤和文檔化。 - 實作一個支援
fields和expand的欄位選擇系統。 - 研究 GraphQL,思考它如何解決 REST 的過度/不足獲取問題。