在昨天的文章中,我們把所有程式碼都寫在一個 app.js
檔案裡,當功能簡單時這樣做很快速,但隨著專案成長,問題就會越來越多,所以今天就來探討怎麼拆分到各個地方會比較好維護。
MVC(Model-View-Controller)是一種經典的軟體架構模式,它將應用程式分為三個主要部分:
在我們的 TodoList API 中:
/todos
相關的 HTTP 請求HTTP Request → Router → Controller → Model → Database
↓ ↓ ↓
HTTP Response ← JSON ← Controller ← Model ← Database
以實際例子來說:
GET /todos
請求findAll()
方法讓我們重構昨天的專案,建立更專業的資料夾結構:
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 負責處理所有與資料相關的邏輯,包括:
建立 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();
Controller 負責:
建立 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();
建立 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;
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 架構的優勢
明天再繼續深入研究中間件的部分