昨天我們完成了 MVC 架構的基本實作,將 Model 和 Controller 分離。但還有一些重要的功能散落在各處,比如資料驗證、錯誤處理、日誌記錄等。今天我們要深入探討 Express 的中間件系統,建立可重用的中間件模組,讓我們的 TodoList API 更加完善。
在 Express 中,中間件就像是流水線上的工作站,每個請求都會依序通過這些工作站:
// 請求處理流水線
HTTP Request
↓
[Middleware 1] → [Middleware 2] → [Route Handler] → [Error Handler]
↓ ↓ ↓ ↓
HTTP Response ← JSON Response ← Controller ← Error Response
app.use()
綁定的中間件router.use()
綁定的中間件首先,讓我們建立一些工具函數來統一管理回應格式和常數。
建立 utils/responses.js
:
// 統一的 API 回應格式
class ApiResponse {
static success(res, data, message = '操作成功', statusCode = 200) {
return res.status(statusCode).json({
success: true,
message,
data,
timestamp: new Date().toISOString()
});
}
static error(res, message = '操作失敗', statusCode = 400, errors = null) {
const response = {
success: false,
message,
timestamp: new Date().toISOString()
};
if (errors) {
response.errors = errors;
}
return res.status(statusCode).json(response);
}
static notFound(res, message = '資源不存在') {
return this.error(res, message, 404);
}
static serverError(res, message = '伺服器錯誤') {
return this.error(res, message, 500);
}
static created(res, data, message = '創建成功') {
return this.success(res, data, message, 201);
}
static noContent(res, message = '操作成功') {
return res.status(204).json({
success: true,
message,
timestamp: new Date().toISOString()
});
}
static validationError(res, errors) {
return this.error(res, '資料驗證失敗', 400, errors);
}
}
module.exports = ApiResponse;
建立 utils/constants.js
:
// HTTP 狀態碼常數
const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
INTERNAL_SERVER_ERROR: 500
};
// 錯誤訊息常數
const ERROR_MESSAGES = {
TODO_NOT_FOUND: '找不到指定的 Todo',
INVALID_TODO_ID: '無效的 Todo ID',
TITLE_REQUIRED: '標題為必填欄位',
TITLE_TOO_LONG: '標題長度不能超過 200 字元',
TITLE_EMPTY: '標題不能為空',
INVALID_COMPLETED_STATUS: '完成狀態必須是布林值',
SERVER_ERROR: '伺服器內部錯誤',
VALIDATION_FAILED: '資料驗證失敗',
REQUEST_TOO_LARGE: '請求內容過大',
INVALID_JSON: 'JSON 格式錯誤'
};
// 成功訊息常數
const SUCCESS_MESSAGES = {
TODO_CREATED: 'Todo 創建成功',
TODO_UPDATED: 'Todo 更新成功',
TODO_DELETED: 'Todo 刪除成功',
TODOS_RETRIEVED: 'Todo 列表取得成功',
TODO_RETRIEVED: '取得 Todo 成功'
};
// 設定常數
const CONFIG = {
MAX_TITLE_LENGTH: 200,
MAX_REQUEST_SIZE: '10mb',
DEFAULT_PAGE_LIMIT: 10,
MAX_PAGE_LIMIT: 100
};
module.exports = {
HTTP_STATUS,
ERROR_MESSAGES,
SUCCESS_MESSAGES,
CONFIG
};
建立 middleware/validation.js
:
const ApiResponse = require('../utils/responses');
const { ERROR_MESSAGES, CONFIG } = require('../utils/constants');
// 驗證 Todo 資料的中間件
const validateTodoData = (isRequired = true) => {
return (req, res, next) => {
const { title, completed } = req.body;
const errors = [];
// 檢查標題
if (isRequired && !title) {
errors.push({
field: 'title',
message: ERROR_MESSAGES.TITLE_REQUIRED
});
} else if (title !== undefined) {
if (typeof title !== 'string') {
errors.push({
field: 'title',
message: '標題必須是字串'
});
} else if (title.trim().length === 0) {
errors.push({
field: 'title',
message: ERROR_MESSAGES.TITLE_EMPTY
});
} else if (title.length > CONFIG.MAX_TITLE_LENGTH) {
errors.push({
field: 'title',
message: ERROR_MESSAGES.TITLE_TOO_LONG
});
}
}
// 檢查完成狀態
if (completed !== undefined && typeof completed !== 'boolean') {
errors.push({
field: 'completed',
message: ERROR_MESSAGES.INVALID_COMPLETED_STATUS
});
}
if (errors.length > 0) {
return ApiResponse.validationError(res, errors);
}
// 清理資料
if (title) {
req.body.title = title.trim();
}
next();
};
};
// 驗證 Todo ID 的中間件
const validateTodoId = (req, res, next) => {
const { id } = req.params;
if (!id || isNaN(parseInt(id)) || parseInt(id) <= 0) {
return ApiResponse.error(res, ERROR_MESSAGES.INVALID_TODO_ID, 400);
}
// 將字串轉換為數字,方便後續使用
req.params.id = parseInt(id);
next();
};
// 驗證查詢參數的中間件
const validateQueryParams = (req, res, next) => {
const { page, limit, completed } = req.query;
// 驗證分頁參數
if (page && (isNaN(parseInt(page)) || parseInt(page) < 1)) {
return ApiResponse.error(res, '頁碼必須是大於 0 的整數', 400);
}
if (limit && (isNaN(parseInt(limit)) || parseInt(limit) < 1)) {
return ApiResponse.error(res, '每頁筆數必須是大於 0 的整數', 400);
}
// 驗證完成狀態參數
if (completed && !['true', 'false'].includes(completed)) {
return ApiResponse.error(res, '完成狀態參數必須是 true 或 false', 400);
}
next();
};
module.exports = {
validateTodoData,
validateTodoId,
validateQueryParams
};
建立 middleware/logger.js
:
const fs = require('fs').promises;
const path = require('path');
// 請求日誌中間件
const requestLogger = (req, res, next) => {
const startTime = Date.now();
const timestamp = new Date().toISOString();
const method = req.method;
const url = req.originalUrl;
const userAgent = req.get('User-Agent') || 'Unknown';
const ip = req.ip || req.connection.remoteAddress || 'Unknown';
// 記錄請求開始
console.log(`[${timestamp}] ${method} ${url} - IP: ${ip}`);
// 監聽回應結束事件
res.on('finish', () => {
const duration = Date.now() - startTime;
const statusCode = res.statusCode;
const statusEmoji = getStatusEmoji(statusCode);
console.log(`[${timestamp}] ${statusEmoji} ${method} ${url} - ${statusCode} - ${duration}ms`);
});
next();
};
// 詳細的 API 日誌中間件
const apiLogger = async (req, res, next) => {
const startTime = Date.now();
const timestamp = new Date().toISOString();
const logData = {
timestamp,
method: req.method,
url: req.originalUrl,
ip: req.ip || req.connection.remoteAddress,
userAgent: req.get('User-Agent'),
body: req.method !== 'GET' ? req.body : undefined
};
// 監聽回應結束事件
res.on('finish', async () => {
const duration = Date.now() - startTime;
logData.statusCode = res.statusCode;
logData.duration = duration;
// 寫入日誌檔案(在生產環境中使用)
if (process.env.NODE_ENV === 'production') {
try {
await writeLogToFile(logData);
} catch (error) {
console.error('寫入日誌檔案失敗:', error);
}
}
});
next();
};
// 寫入日誌檔案
const writeLogToFile = async (logData) => {
const logDir = path.join(__dirname, '../logs');
const logFile = path.join(logDir, `api-${new Date().toISOString().split('T')[0]}.log`);
try {
// 確保日誌目錄存在
await fs.mkdir(logDir, { recursive: true });
// 寫入日誌
const logLine = JSON.stringify(logData) + '\n';
await fs.appendFile(logFile, logLine);
} catch (error) {
console.error('日誌寫入錯誤:', error);
}
};
module.exports = {
requestLogger,
apiLogger
};
建立 middleware/errorHandler.js
:
const ApiResponse = require('../utils/responses');
const { ERROR_MESSAGES } = require('../utils/constants');
// 404 錯誤處理中間件
const notFoundHandler = (req, res, next) => {
const message = `找不到路徑 ${req.originalUrl}`;
ApiResponse.notFound(res, message);
};
// 全域錯誤處理中間件
const globalErrorHandler = (err, req, res, next) => {
console.error('Global Error Handler:', {
message: err.message,
stack: err.stack,
url: req.originalUrl,
method: req.method,
timestamp: new Date().toISOString()
});
// 如果回應已經發送,交給 Express 預設錯誤處理器
if (res.headersSent) {
return next(err);
}
// 處理不同類型的錯誤
if (err.type === 'entity.parse.failed') {
return ApiResponse.error(res, ERROR_MESSAGES.INVALID_JSON, 400);
}
if (err.type === 'entity.too.large') {
return ApiResponse.error(res, ERROR_MESSAGES.REQUEST_TOO_LARGE, 413);
}
if (err.name === 'ValidationError') {
return ApiResponse.validationError(res, err.errors);
}
if (err.name === 'CastError') {
return ApiResponse.error(res, '資料格式錯誤', 400);
}
// 已知錯誤(有設定 status 的錯誤)
if (err.status || err.statusCode) {
return ApiResponse.error(res, err.message, err.status || err.statusCode);
}
// 未知錯誤
ApiResponse.serverError(res, ERROR_MESSAGES.SERVER_ERROR);
};
// 異步錯誤處理包裝器
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
// CORS 處理中間件
const corsHandler = (req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// 處理預檢請求
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
next();
};
module.exports = {
notFoundHandler,
globalErrorHandler,
asyncHandler,
corsHandler
};
建立 middleware/security.js
:
const ApiResponse = require('../utils/responses');
// 速率限制中間件(簡單版本)
const rateLimiter = (maxRequests = 100, windowMs = 15 * 60 * 1000) => {
const requests = new Map();
return (req, res, next) => {
const clientIP = req.ip || req.connection.remoteAddress;
const now = Date.now();
const windowStart = now - windowMs;
// 取得客戶端的請求記錄
if (!requests.has(clientIP)) {
requests.set(clientIP, []);
}
const clientRequests = requests.get(clientIP);
// 移除過期的請求記錄
const validRequests = clientRequests.filter(timestamp => timestamp > windowStart);
// 檢查是否超過限制
if (validRequests.length >= maxRequests) {
return ApiResponse.error(res, '請求次數過多,請稍後再試', 429);
}
// 記錄本次請求
validRequests.push(now);
requests.set(clientIP, validRequests);
// 設定回應標頭
res.setHeader('X-RateLimit-Limit', maxRequests);
res.setHeader('X-RateLimit-Remaining', maxRequests - validRequests.length);
res.setHeader('X-RateLimit-Reset', new Date(now + windowMs));
next();
};
};
// 請求大小限制中間件
const requestSizeLimit = (maxSize = '10mb') => {
return (req, res, next) => {
const contentLength = req.get('Content-Length');
if (contentLength) {
const sizeInBytes = parseInt(contentLength);
const maxSizeInBytes = parseSize(maxSize);
if (sizeInBytes > maxSizeInBytes) {
return ApiResponse.error(res, '請求內容過大', 413);
}
}
next();
};
};
// 解析大小字串(如 '10mb')
const parseSize = (size) => {
const units = {
'b': 1,
'kb': 1024,
'mb': 1024 * 1024,
'gb': 1024 * 1024 * 1024
};
const match = size.toString().toLowerCase().match(/^(\d+)([a-z]*)$/);
if (!match) return 0;
const value = parseInt(match[1]);
const unit = match[2] || 'b';
return value * (units[unit] || 1);
};
module.exports = {
rateLimiter,
requestSizeLimit
};
更新 controllers/todoController.js
:
const TodoModel = require('../models/TodoModel');
const ApiResponse = require('../utils/responses');
const { SUCCESS_MESSAGES, ERROR_MESSAGES } = require('../utils/constants');
const { asyncHandler } = require('../middleware/errorHandler');
class TodoController {
// GET /todos - 取得 Todo 列表
getAllTodos = asyncHandler(async (req, res) => {
const { completed, search, page = 1, limit = 10 } = req.query;
const filters = { completed, search };
// 從 Model 取得資料
const todos = await TodoModel.findAll(filters);
const total = await TodoModel.count(filters);
// 分頁處理
const pageNum = parseInt(page);
const limitNum = Math.min(parseInt(limit), 100);
const startIndex = (pageNum - 1) * limitNum;
const paginatedTodos = todos.slice(startIndex, startIndex + limitNum);
const responseData = {
todos: paginatedTodos,
pagination: {
total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
hasNextPage: startIndex + limitNum < total,
hasPreviousPage: pageNum > 1
}
};
ApiResponse.success(res, responseData, SUCCESS_MESSAGES.TODOS_RETRIEVED);
});
// GET /todos/:id - 取得特定 Todo
getTodoById = asyncHandler(async (req, res) => {
const { id } = req.params;
const todo = await TodoModel.findById(id);
if (!todo) {
return ApiResponse.notFound(res, ERROR_MESSAGES.TODO_NOT_FOUND);
}
ApiResponse.success(res, todo, SUCCESS_MESSAGES.TODO_RETRIEVED);
});
// POST /todos - 創建新 Todo
createTodo = asyncHandler(async (req, res) => {
const todoData = req.body;
const newTodo = await TodoModel.create(todoData);
ApiResponse.created(res, newTodo, SUCCESS_MESSAGES.TODO_CREATED);
});
// PUT /todos/:id - 更新 Todo
updateTodo = asyncHandler(async (req, res) => {
const { id } = req.params;
const todoData = req.body;
// 檢查 Todo 是否存在
const exists = await TodoModel.exists(id);
if (!exists) {
return ApiResponse.notFound(res, ERROR_MESSAGES.TODO_NOT_FOUND);
}
const updatedTodo = await TodoModel.update(id, todoData);
ApiResponse.success(res, updatedTodo, SUCCESS_MESSAGES.TODO_UPDATED);
});
// DELETE /todos/:id - 刪除 Todo
deleteTodo = asyncHandler(async (req, res) => {
const { id } = req.params;
const deletedTodo = await TodoModel.delete(id);
if (!deletedTodo) {
return ApiResponse.notFound(res, ERROR_MESSAGES.TODO_NOT_FOUND);
}
ApiResponse.success(res, deletedTodo, SUCCESS_MESSAGES.TODO_DELETED);
});
}
module.exports = new TodoController();
安裝常用的第三方中間件:
npm install cors helmet morgan compression
更新 package.json
:
{
"name": "express-todolist-mvc",
"version": "2.0.0",
"description": "TodoList API with advanced middleware system",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest",
"lint": "eslint ."
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"helmet": "^7.0.0",
"morgan": "^1.10.0",
"compression": "^1.7.4"
},
"devDependencies": {
"nodemon": "^3.0.1",
"eslint": "^8.0.0",
"jest": "^29.0.0"
}
}
更新 routes/todos.js
:
const express = require('express');
const router = express.Router();
const todoController = require('../controllers/todoController');
const { validateTodoData, validateTodoId, validateQueryParams } = require('../middleware/validation');
// GET /todos - 取得 Todo 列表
router.get('/', validateQueryParams, todoController.getAllTodos);
// GET /todos/:id - 取得特定 Todo
router.get('/:id', validateTodoId, todoController.getTodoById);
// POST /todos - 創建新 Todo
router.post('/', validateTodoData(true), todoController.createTodo);
// PUT /todos/:id - 更新 Todo
router.put('/:id', validateTodoId, validateTodoData(true), todoController.updateTodo);
// DELETE /todos/:id - 刪除 Todo
router.delete('/:id', validateTodoId, todoController.deleteTodo);
module.exports = router;
更新 app.js
:
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const compression = require('compression');
// 自定義中間件
const { requestLogger } = require('./middleware/logger');
const { notFoundHandler, globalErrorHandler } = require('./middleware/errorHandler');
const { rateLimiter } = require('./middleware/security');
// 路由
const apiRoutes = require('./routes');
// 建立 Express 應用實例
const app = express();
// 信任代理(部署到雲端平台時需要)
app.set('trust proxy', 1);
// 安全性中間件
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
credentials: true
}));
// 壓縮回應
app.use(compression());
// 請求解析中間件
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// 日誌中間件
if (process.env.NODE_ENV === 'production') {
app.use(morgan('combined'));
} else {
app.use(morgan('dev'));
}
app.use(requestLogger);
// 速率限制(每 15 分鐘最多 100 個請求)
app.use('/api', rateLimiter(100, 15 * 60 * 1000));
// API 路由
app.use('/api', apiRoutes);
// 首頁路由
app.get('/', (req, res) => {
res.send(`
<h1>TodoList API v2.0</h1>
<p>基於 MVC 架構和進階中間件系統</p>
<div style="margin: 20px 0;">
<h3>可用端點:</h3>
<ul>
<li><a href="/api">API 資訊</a></li>
<li><a href="/api/todos">Todo 列表</a></li>
<li><a href="/api/health">健康檢查</a></li>
</ul>
</div>
<div style="margin: 20px 0;">
<h3>系統特性:</h3>
<ul>
<li>MVC 架構模式</li>
<li>安全性中間件</li>
<li>請求日誌記錄</li>
<li>速率限制保護</li>
<li>資料驗證機制</li>
<li>完整錯誤處理</li>
</ul>
</div>
<footer style="margin-top: 40px; color: #666; font-size: 12px;">
<p>Environment: ${process.env.NODE_ENV || 'development'}</p>
<p>Version: 2.0.0</p>
</footer>
`);
});
// 錯誤處理中間件(必須放在最後)
app.use(notFoundHandler);
app.use(globalErrorHandler);
module.exports = app;
更新 server.js
:
const app = require('./app');
const PORT = process.env.PORT || 3000;
const ENV = process.env.NODE_ENV || 'development';
const server = app.listen(PORT, () => {
console.log('TodoList API v2.0 啟動成功!');
console.log('='.repeat(50));
console.log(`伺服器地址: http://localhost:${PORT}`);
console.log(`環境: ${ENV}`);
console.log(`啟動時間: ${new Date().toLocaleString('zh-TW')}`);
console.log('='.repeat(50));
console.log('API 端點:');
console.log(' GET /api/todos - 取得 Todo 列表');
console.log(' POST /api/todos - 創建新 Todo');
console.log(' GET /api/todos/:id - 取得特定 Todo');
console.log(' PUT /api/todos/:id - 更新 Todo');
console.log(' DELETE /api/todos/:id - 刪除 Todo');
console.log(' GET /api/health - 健康檢查');
console.log('='.repeat(50));
console.log('🛠 系統特性: MVC架構 | 中間件系統 | 安全防護');
console.log('='.repeat(50));
});
// 優雅關閉處理
const gracefulShutdown = (signal) => {
console.log(`\n收到 ${signal} 信號,開始優雅關閉...`);
server.close((err) => {
if (err) {
console.error('關閉伺服器時發生錯誤:', err);
process.exit(1);
}
console.log('伺服器已安全關閉');
process.exit(0);
});
// 10 秒後強制關閉
setTimeout(() => {
console.error('強制關閉伺服器');
process.exit(1);
}, 10000);
};
// 監聽關閉信號
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// 處理未捕獲的異常
process.on('uncaughtException', (err) => {
console.error('未捕獲的異常:', err);
gracefulShutdown('uncaughtException');
});
process.on('unhandledRejection', (reason, promise) => {
console.error('未處理的 Promise 拒絕:', reason);
gracefulShutdown('unhandledRejection');
});