iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
佛心分享-IT 人自學之術

欸欸!! 這是我的學習筆記系列 第 10

Day10 - Express 資料夾結構 & MVC 設計模式

  • 分享至 

  • xImage
  •  

前言

在昨天的文章中,我們把所有程式碼都寫在一個 app.js 檔案裡,當功能簡單時這樣做很快速,但隨著專案成長,問題就會越來越多,所以今天就來探討怎麼拆分到各個地方會比較好維護。

MVC 架構模式介紹

單一檔案的問題

  1. 檔案巨大:幾百甚至上千行程式碼在同一個檔案
  2. 難以維護:修改一個功能可能影響其他功能
  3. 團隊協作困難:多人同時修改容易產生衝突
  4. 測試困難:無法單獨測試某個功能
  5. 程式碼重複:相似的邏輯散落各處

MVC 架構模式介紹

MVC(Model-View-Controller)是一種經典的軟體架構模式,它將應用程式分為三個主要部分:

  • Model(模型):處理資料邏輯、資料庫操作、業務規則
  • View(視圖):在 API 中通常是 JSON 回應格式
  • Controller(控制器):處理 HTTP 請求、調用 Model、回傳回應

在 Express 中的 MVC 實作

在我們的 TodoList API 中:

  • Model:TodoList 的資料結構和操作邏輯
  • View:JSON 格式的 API 回應
  • Controller:處理 /todos 相關的 HTTP 請求
HTTP Request → Router → Controller → Model → Database
                  ↓         ↓         ↓
HTTP Response ← JSON ← Controller ← Model ← Database

以實際例子來說:

  1. 客戶端發送 GET /todos 請求
  2. Router 將請求導向對應的 Controller 方法
  3. Controller 調用 Model 的 findAll() 方法
  4. Model 處理資料邏輯並回傳結果
  5. Controller 將結果包裝成 JSON 格式回應

建立專業的資料夾結構

讓我們重構昨天的專案,建立更專業的資料夾結構:

express-todolist-mvc/
├── app.js                 # Express 應用程式入口點
├── server.js              # 伺服器啟動檔案
├── package.json
├── controllers/           # 控制器層
│   └── todoController.js
├── models/               # 模型層
│   └── TodoModel.js
├── routes/               # 路由層
│   ├── index.js
│   └── todos.js
└── utils/               # 工具函數(明天會用到)
    └── responses.js

實作 Model 層

什麼是 Model?

Model 負責處理所有與資料相關的邏輯,包括:

  • 資料驗證
  • 資料庫操作
  • 業務規則
  • 資料格式化

建立 models/TodoModel.js

class TodoModel {
  constructor() {
    // 模擬資料庫
    this.todos = [
      { id: 1, title: '學習 Node.js', completed: false, createdAt: '2024-09-23T10:00:00Z' },
      { id: 2, title: '寫鐵人賽文章', completed: true, createdAt: '2024-09-23T11:00:00Z' },
      { id: 3, title: '練習 Express.js', completed: false, createdAt: '2024-09-23T12:00:00Z' }
    ];
    this.nextId = 4;
  }

  // 取得所有 Todo,支援篩選功能
  async findAll(filters = {}) {
    let result = [...this.todos];

    // 根據完成狀態過濾
    if (filters.completed !== undefined) {
      result = result.filter(todo => 
        todo.completed === (filters.completed === 'true')
      );
    }

    // 根據關鍵字搜尋
    if (filters.search) {
      result = result.filter(todo => 
        todo.title.toLowerCase().includes(filters.search.toLowerCase())
      );
    }

    return result;
  }

  // 根據 ID 查找 Todo
  async findById(id) {
    return this.todos.find(todo => todo.id === parseInt(id));
  }

  // 創建新 Todo
  async create(todoData) {
    // 資料驗證
    if (!todoData.title || typeof todoData.title !== 'string') {
      throw new Error('標題為必填且必須是字串');
    }

    if (todoData.title.trim().length === 0) {
      throw new Error('標題不能為空');
    }

    if (todoData.title.length > 200) {
      throw new Error('標題長度不能超過 200 字元');
    }

    const newTodo = {
      id: this.nextId++,
      title: todoData.title.trim(),
      completed: todoData.completed || false,
      createdAt: new Date().toISOString()
    };

    this.todos.push(newTodo);
    return newTodo;
  }

  // 更新 Todo
  async update(id, todoData) {
    const index = this.todos.findIndex(todo => todo.id === parseInt(id));
    
    if (index === -1) {
      return null;
    }

    // 資料驗證
    if (todoData.title) {
      if (typeof todoData.title !== 'string' || todoData.title.trim().length === 0) {
        throw new Error('標題必須是非空字串');
      }
      if (todoData.title.length > 200) {
        throw new Error('標題長度不能超過 200 字元');
      }
    }

    // 更新資料
    this.todos[index] = {
      ...this.todos[index],
      ...todoData,
      title: todoData.title ? todoData.title.trim() : this.todos[index].title,
      updatedAt: new Date().toISOString()
    };

    return this.todos[index];
  }

  // 刪除 Todo
  async delete(id) {
    const index = this.todos.findIndex(todo => todo.id === parseInt(id));
    
    if (index === -1) {
      return null;
    }

    const deletedTodo = this.todos.splice(index, 1)[0];
    return deletedTodo;
  }

  // 取得總數量
  async count(filters = {}) {
    const filtered = await this.findAll(filters);
    return filtered.length;
  }

  // 檢查 Todo 是否存在
  async exists(id) {
    return this.todos.some(todo => todo.id === parseInt(id));
  }
}

// 建立單例模式,確保整個應用程式使用同一個實例
module.exports = new TodoModel();

Model 設計重點

  1. 單一職責:只處理資料相關邏輯
  2. 資料驗證:在 Model 層進行資料驗證
  3. 異步操作:所有方法都使用 async/await,為將來整合資料庫做準備
  4. 錯誤處理:拋出有意義的錯誤訊息
  5. 單例模式:確保資料一致性

實作 Controller 層

什麼是 Controller?

Controller 負責:

  • 接收 HTTP 請求
  • 調用適當的 Model 方法
  • 處理錯誤
  • 格式化回應
  • 回傳 HTTP 回應

建立 controllers/todoController.js

const TodoModel = require('../models/TodoModel');

class TodoController {
  // GET /todos - 取得 Todo 列表
  async getAllTodos(req, res) {
    try {
      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);

      // 回傳標準化回應
      res.json({
        success: true,
        message: '取得 Todo 列表成功',
        data: {
          todos: paginatedTodos,
          pagination: {
            total,
            page: pageNum,
            limit: limitNum,
            totalPages: Math.ceil(total / limitNum)
          }
        }
      });
    } catch (error) {
      console.error('取得 Todo 列表錯誤:', error);
      res.status(500).json({
        success: false,
        message: '取得 Todo 列表失敗',
        error: error.message
      });
    }
  }

  // GET /todos/:id - 取得特定 Todo
  async getTodoById(req, res) {
    try {
      const { id } = req.params;

      // 驗證 ID 格式
      if (!id || isNaN(parseInt(id))) {
        return res.status(400).json({
          success: false,
          message: '無效的 Todo ID'
        });
      }

      const todo = await TodoModel.findById(id);

      if (!todo) {
        return res.status(404).json({
          success: false,
          message: '找不到指定的 Todo'
        });
      }

      res.json({
        success: true,
        message: '取得 Todo 成功',
        data: todo
      });
    } catch (error) {
      console.error('取得 Todo 錯誤:', error);
      res.status(500).json({
        success: false,
        message: '取得 Todo 失敗',
        error: error.message
      });
    }
  }

  // POST /todos - 創建新 Todo
  async createTodo(req, res) {
    try {
      const todoData = req.body;

      const newTodo = await TodoModel.create(todoData);

      res.status(201).json({
        success: true,
        message: 'Todo 創建成功',
        data: newTodo
      });
    } catch (error) {
      console.error('創建 Todo 錯誤:', error);
      
      // 區分驗證錯誤和其他錯誤
      const statusCode = error.message.includes('標題') ? 400 : 500;
      
      res.status(statusCode).json({
        success: false,
        message: 'Todo 創建失敗',
        error: error.message
      });
    }
  }

  // PUT /todos/:id - 更新 Todo
  async updateTodo(req, res) {
    try {
      const { id } = req.params;
      const todoData = req.body;

      // 驗證 ID 格式
      if (!id || isNaN(parseInt(id))) {
        return res.status(400).json({
          success: false,
          message: '無效的 Todo ID'
        });
      }

      // 檢查 Todo 是否存在
      const exists = await TodoModel.exists(id);
      if (!exists) {
        return res.status(404).json({
          success: false,
          message: '找不到指定的 Todo'
        });
      }

      const updatedTodo = await TodoModel.update(id, todoData);

      res.json({
        success: true,
        message: 'Todo 更新成功',
        data: updatedTodo
      });
    } catch (error) {
      console.error('更新 Todo 錯誤:', error);
      
      const statusCode = error.message.includes('標題') ? 400 : 500;
      
      res.status(statusCode).json({
        success: false,
        message: 'Todo 更新失敗',
        error: error.message
      });
    }
  }

  // DELETE /todos/:id - 刪除 Todo
  async deleteTodo(req, res) {
    try {
      const { id } = req.params;

      // 驗證 ID 格式
      if (!id || isNaN(parseInt(id))) {
        return res.status(400).json({
          success: false,
          message: '無效的 Todo ID'
        });
      }

      const deletedTodo = await TodoModel.delete(id);

      if (!deletedTodo) {
        return res.status(404).json({
          success: false,
          message: '找不到指定的 Todo'
        });
      }

      res.json({
        success: true,
        message: 'Todo 刪除成功',
        data: deletedTodo
      });
    } catch (error) {
      console.error('刪除 Todo 錯誤:', error);
      res.status(500).json({
        success: false,
        message: 'Todo 刪除失敗',
        error: error.message
      });
    }
  }
}

module.exports = new TodoController();

Controller 設計重點

  1. 統一錯誤處理:所有方法都有 try-catch
  2. 標準化回應格式:統一的成功/失敗回應結構
  3. 輸入驗證:驗證請求參數的格式和有效性
  4. HTTP 狀態碼:正確使用 HTTP 狀態碼
  5. 日誌記錄:記錄錯誤以便除錯

建立基本路由

建立 routes/todos.js

const express = require('express');
const router = express.Router();
const todoController = require('../controllers/todoController');

// GET /todos
router.get('/', todoController.getAllTodos);

// GET /todos/:id 
router.get('/:id', todoController.getTodoById);

// POST /todos
router.post('/', todoController.createTodo);

// PUT /todos/:id
router.put('/:id', todoController.updateTodo);

// DELETE /todos/:id
router.delete('/:id', todoController.deleteTodo);

module.exports = router;

建立 routes/index.js

const express = require('express');
const router = express.Router();
const todosRouter = require('./todos');

// API 首頁
router.get('/', (req, res) => {
  res.json({
    message: '歡迎使用 TodoList API',
    version: '2.0.0',
    endpoints: {
      todos: '/api/todos',
      health: '/api/health'
    }
  });
});

// 健康檢查
router.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  });
});

// 註冊 todos 路由
router.use('/todos', todosRouter);

module.exports = router;

重構 app.js

const express = require('express');
const apiRoutes = require('./routes');

const app = express();

// 基本中間件
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 簡單的請求記錄
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
  next();
});

// 註冊 API 路由
app.use('/api', apiRoutes);

app.get('/', (req, res) => {
  res.send(`
    <h1>TodoList API v2.0</h1>
    <p>基於 MVC 架構的 TodoList API</p>
    <ul>
      <li><a href="/api">API 資訊</a></li>
      <li><a href="/api/todos">Todo 列表</a></li>
      <li><a href="/api/health">健康檢查</a></li>
    </ul>
  `);
});

// 基本的 404 處理
app.use('*', (req, res) => {
  res.status(404).json({
    success: false,
    message: '找不到請求的資源',
    path: req.originalUrl
  });
});

module.exports = app;

建立 server.js

const app = require('./app');

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`TodoList API 啟動成功!`);
});

總結

MVC 架構的優勢

1. 關注點分離

  • Model: 專注資料處理和業務規則
  • Controller: 專注請求處理和回應格式化
  • Router: 專注路由分發

2. 可重用性

  • Model 可以在不同的 Controller 中重用
  • Controller 可以被不同的路由使用

3. 可測試性

  • 每個層級都可以獨立測試
  • Mock 測試變得容易

4. 可維護性

  • 修改資料邏輯只需要改 Model
  • 修改回應格式只需要改 Controller
  • 程式碼結構清晰易懂

5. 團隊協作

  • 不同開發者可以同時工作在不同層級
  • 減少程式碼衝突

明天再繼續深入研究中間件的部分


上一篇
Day9 - Express.js 基礎知識 & 安裝
系列文
欸欸!! 這是我的學習筆記10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言