iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0

前言

昨天我們完成了 MVC 架構的基本實作,將 Model 和 Controller 分離。但還有一些重要的功能散落在各處,比如資料驗證、錯誤處理、日誌記錄等。今天我們要深入探討 Express 的中間件系統,建立可重用的中間件模組,讓我們的 TodoList API 更加完善。

Express 中間件深入探討

中間件的執行流程

在 Express 中,中間件就像是流水線上的工作站,每個請求都會依序通過這些工作站:

// 請求處理流水線
HTTP Request 
    ↓
[Middleware 1] → [Middleware 2] → [Route Handler] → [Error Handler]
    ↓                ↓                ↓                ↓
HTTP Response ← JSON Response ← Controller ← Error Response

中間件的類型

  1. 應用級中間件app.use() 綁定的中間件
  2. 路由級中間件router.use() 綁定的中間件
  3. 錯誤處理中間件:有四個參數的中間件
  4. 內建中間件:Express 提供的中間件
  5. 第三方中間件:npm 套件提供的中間件

建立工具函數和常數

首先,讓我們建立一些工具函數來統一管理回應格式和常數。

建立統一回應格式

建立 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
};

重構 Controller 使用新的工具

更新 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');
});

上一篇
Day10 - Express 資料夾結構 & MVC 設計模式
下一篇
Day12 - Express Generator(一)
系列文
欸欸!! 這是我的學習筆記12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言