Node.js 中间件概念与封装
1. 问题解析
中间件(Middleware)是 Node.js Web 开发中的核心概念,它介于应用系统和系统软件之间,负责处理 HTTP 请求的预处理、后处理以及请求流转。理解中间件机制对于构建可维护、可扩展的 Web 应用至关重要。
2. 核心概念
2.1 什么是中间件
中间件是介于操作系统和应用软件之间的系统软件。在 Web 开发中:
- 传统定义:连接两个独立应用的软件层
- Node.js 定义:处理 HTTP 请求/响应的函数,可以访问请求对象、响应对象和 next 函数
2.2 中间件的核心职责
| 职责 | 说明 |
|---|---|
| 请求预处理 | 解析请求体、验证身份、日志记录 |
| 业务处理 | 路由分发、权限控制 |
| 响应后处理 | 统一响应格式、错误处理、压缩响应 |
2.3 洋葱圈模型
Koa 框架引入的洋葱圈模型是理解中间件执行顺序的关键:
请求 → [中间件1开始] → [中间件2开始] → [中间件3开始]
↓
[业务逻辑]
↓
响应 ← [中间件1结束] ← [中间件2结束] ← [中间件3结束]
3. 详细解答
3.1 Express 中间件
const express = require('express');
const app = express();
// 中间件基本结构
function middleware(req, res, next) {
// 1. 执行预处理逻辑
console.log('请求路径:', req.path);
// 2. 调用 next() 将控制权交给下一个中间件
next();
// 3. 后续逻辑(在响应完成后执行)
console.log('响应已发送');
}
// 应用中间件
app.use(middleware);
// 特定路径中间件
app.use('/api', apiMiddleware);
// 错误处理中间件(4个参数)
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: err.message });
});
3.2 Koa 中间件(洋葱圈模型)
const Koa = require('koa');
const app = new Koa();
// Koa 中间件 - 洋葱圈模型
app.use(async (ctx, next) => {
console.log('>> 中间件 1 开始');
await next(); // 进入下一个中间件
console.log('<< 中间件 1 结束');
});
app.use(async (ctx, next) => {
console.log('>> 中间件 2 开始');
await next();
console.log('<< 中间件 2 结束');
});
app.use(async (ctx) => {
console.log('>> 业务逻辑');
ctx.body = 'Hello World';
console.log('<< 业务逻辑');
});
// 输出顺序:
// >> 中间件 1 开始
// >> 中间件 2 开始
// >> 业务逻辑
// << 业务逻辑
// << 中间件 2 结束
// << 中间件 1 结束
3.3 封装 Token 校验中间件
// middleware/auth.js
const jwt = require('jsonwebtoken');
/**
* JWT Token 校验中间件
* @param {Object} options - 配置选项
* @param {string} options.secret - JWT 密钥
* @param {string[]} options.exclude - 排除校验的路径
*/
function authMiddleware(options = {}) {
const { secret, exclude = [] } = options;
return async function auth(ctx, next) {
// 检查是否在排除列表
if (exclude.some(path => ctx.path.startsWith(path))) {
return await next();
}
try {
const token = ctx.headers.authorization?.replace('Bearer ', '');
if (!token) {
ctx.status = 401;
ctx.body = { error: '缺少访问令牌' };
return;
}
// 验证 token
const decoded = jwt.verify(token, secret);
ctx.state.user = decoded; // 将用户信息挂载到 ctx.state
await next();
} catch (err) {
ctx.status = 401;
ctx.body = { error: '无效的访问令牌' };
}
};
}
module.exports = authMiddleware;
// 使用
const Koa = require('koa');
const app = new Koa();
app.use(authMiddleware({
secret: 'your-secret-key',
exclude: ['/login', '/register', '/health']
}));
3.4 封装日志中间件
// middleware/logger.js
const fs = require('fs');
const path = require('path');
/**
* 日志中间件
* 记录请求方法、路径、耗时、状态码
*/
function loggerMiddleware(options = {}) {
const {
logDir = './logs',
logFile = 'access.log',
consoleOutput = true
} = options;
// 确保日志目录存在
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const logPath = path.join(logDir, logFile);
const writeStream = fs.createWriteStream(logPath, { flags: 'a' });
return async function logger(ctx, next) {
const start = Date.now();
await next();
const duration = Date.now() - start;
const logData = {
timestamp: new Date().toISOString(),
method: ctx.method,
url: ctx.url,
status: ctx.status,
duration: `${duration}ms`,
ip: ctx.ip,
userAgent: ctx.headers['user-agent']
};
const logLine = JSON.stringify(logData) + '\n';
// 写入文件
writeStream.write(logLine);
// 控制台输出
if (consoleOutput) {
console.log(
`[${logData.timestamp}] ${ctx.method} ${ctx.url} - ${ctx.status} - ${duration}ms`
);
}
};
}
module.exports = loggerMiddleware;
3.5 封装 koa-bodyparser
// middleware/bodyparser.js
/**
* 简化版 body-parser 实现
* 解析 JSON 和表单数据
*/
function bodyParser(options = {}) {
const { limit = '1mb' } = options;
const limitBytes = parseBytes(limit);
return async function parseBody(ctx, next) {
// 只处理有请求体的请求
if (!['POST', 'PUT', 'PATCH'].includes(ctx.method)) {
return await next();
}
const contentType = ctx.headers['content-type'] || '';
try {
if (contentType.includes('application/json')) {
ctx.request.body = await parseJSON(ctx, limitBytes);
} else if (contentType.includes('application/x-www-form-urlencoded')) {
ctx.request.body = await parseForm(ctx, limitBytes);
}
} catch (err) {
ctx.throw(400, 'Invalid body');
}
await next();
};
}
function parseJSON(ctx, limit) {
return new Promise((resolve, reject) => {
let body = '';
let length = 0;
ctx.req.on('data', chunk => {
length += chunk.length;
if (length > limit) {
reject(new Error('Request body too large'));
return;
}
body += chunk;
});
ctx.req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (err) {
reject(err);
}
});
ctx.req.on('error', reject);
});
}
function parseForm(ctx, limit) {
return new Promise((resolve, reject) => {
let body = '';
let length = 0;
ctx.req.on('data', chunk => {
length += chunk.length;
if (length > limit) {
reject(new Error('Request body too large'));
return;
}
body += chunk;
});
ctx.req.on('end', () => {
const params = new URLSearchParams(body);
const result = {};
for (const [key, value] of params) {
result[key] = value;
}
resolve(result);
});
ctx.req.on('error', reject);
});
}
function parseBytes(str) {
const units = { b: 1, kb: 1024, mb: 1024 * 1024, gb: 1024 * 1024 * 1024 };
const match = str.toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/);
return match ? parseFloat(match[1]) * units[match[2]] : 1024 * 1024;
}
module.exports = bodyParser;
3.6 封装 koa-static 静态文件服务
// middleware/static.js
const fs = require('fs');
const path = require('path');
const mime = require('mime-types'); // 需要安装: npm install mime-types
/**
* 静态文件服务中间件
* @param {string} root - 静态文件根目录
* @param {Object} options - 配置选项
*/
function serveStatic(root, options = {}) {
const {
index = 'index.html',
maxAge = 0,
hidden = false
} = options;
const rootPath = path.resolve(root);
return async function staticMiddleware(ctx, next) {
// 只处理 GET 和 HEAD 请求
if (ctx.method !== 'GET' && ctx.method !== 'HEAD') {
return await next();
}
// 构建文件路径
let filePath = path.join(rootPath, decodeURIComponent(ctx.path));
// 安全检查:确保在根目录内
if (!filePath.startsWith(rootPath)) {
return await next();
}
// 隐藏文件检查
if (!hidden && path.basename(filePath).startsWith('.')) {
return await next();
}
try {
const stats = await fs.promises.stat(filePath);
if (stats.isDirectory()) {
// 目录则尝试查找 index 文件
filePath = path.join(filePath, index);
try {
await fs.promises.access(filePath);
} catch {
return await next();
}
}
// 设置响应头
const mimeType = mime.lookup(filePath) || 'application/octet-stream';
ctx.set('Content-Type', mimeType);
ctx.set('Content-Length', stats.size);
ctx.set('Last-Modified', stats.mtime.toUTCString());
if (maxAge > 0) {
ctx.set('Cache-Control', `max-age=${Math.floor(maxAge / 1000)}`);
}
// 发送文件
ctx.status = 200;
if (ctx.method === 'HEAD') {
return;
}
ctx.body = fs.createReadStream(filePath);
} catch (err) {
// 文件不存在,继续下一个中间件
await next();
}
};
}
module.exports = serveStatic;
// 使用
// app.use(serveStatic('./public', { maxAge: 86400000 }));
4. 深入理解
4.1 中间件执行流程
// 深入理解中间件执行顺序
const Koa = require('koa');
const app = new Koa();
// 模拟数据库连接中间件
app.use(async (ctx, next) => {
console.log('1. 获取数据库连接');
ctx.db = { query: () => 'data' };
try {
await next();
} finally {
console.log('6. 释放数据库连接');
ctx.db = null;
}
});
// 模拟事务中间件
app.use(async (ctx, next) => {
console.log('2. 开启事务');
ctx.transaction = { commit: () => {}, rollback: () => {} };
try {
await next();
console.log('5. 提交事务');
ctx.transaction.commit();
} catch (err) {
console.log('5. 回滚事务');
ctx.transaction.rollback();
throw err;
}
});
// 业务逻辑
app.use(async (ctx) => {
console.log('3. 执行业务逻辑');
const data = ctx.db.query();
ctx.body = data;
console.log('4. 业务逻辑完成');
});
4.2 中间件组合
// 使用 koa-compose 组合多个中间件
const compose = require('koa-compose');
// 定义多个中间件
const middleware1 = async (ctx, next) => {
console.log('1');
await next();
console.log('5');
};
const middleware2 = async (ctx, next) => {
console.log('2');
await next();
console.log('4');
};
const middleware3 = async (ctx) => {
console.log('3');
ctx.body = 'Done';
};
// 组合中间件
const composed = compose([middleware1, middleware2, middleware3]);
// 在特定路由使用
app.use(async (ctx, next) => {
if (ctx.path === '/special') {
await composed(ctx, next);
} else {
await next();
}
});
4.3 条件中间件
// 条件执行中间件
function conditionalMiddleware(condition, middleware) {
return async (ctx, next) => {
if (condition(ctx)) {
await middleware(ctx, next);
} else {
await next();
}
};
}
// 使用示例
app.use(conditionalMiddleware(
ctx => ctx.path.startsWith('/api'),
authMiddleware()
));
5. 最佳实践
5.1 中间件设计原则
// 1. 单一职责原则
// 好的做法:一个中间件只做一件事
app.use(bodyParser());
app.use(cors());
app.use(logger());
// 2. 错误处理
// 在中间件中正确处理错误
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
// 统一错误处理
ctx.status = err.status || 500;
ctx.body = {
error: process.env.NODE_ENV === 'production'
? 'Internal Server Error'
: err.message
};
}
});
// 3. 避免阻塞
// 使用异步操作
app.use(async (ctx, next) => {
// 好:异步读取
const data = await fs.promises.readFile('file.txt');
// 不好:同步读取会阻塞事件循环
// const data = fs.readFileSync('file.txt');
await next();
});
5.2 中间件配置化
// 可配置的中间件工厂函数
function createRateLimiter(options = {}) {
const {
windowMs = 60000, // 时间窗口
maxRequests = 100, // 最大请求数
keyGenerator = (ctx) => ctx.ip // 限流键生成
} = options;
const requests = new Map();
return async function rateLimiter(ctx, next) {
const key = keyGenerator(ctx);
const now = Date.now();
// 清理过期记录
if (!requests.has(key)) {
requests.set(key, []);
}
const timestamps = requests.get(key);
const validTimestamps = timestamps.filter(
t => now - t < windowMs
);
if (validTimestamps.length >= maxRequests) {
ctx.status = 429;
ctx.body = { error: 'Too many requests' };
return;
}
validTimestamps.push(now);
requests.set(key, validTimestamps);
await next();
};
}
// 使用
app.use(createRateLimiter({
windowMs: 60000,
maxRequests: 10,
keyGenerator: (ctx) => ctx.headers['api-key'] || ctx.ip
}));
5.3 中间件测试
// 使用 supertest 测试中间件
const request = require('supertest');
const Koa = require('koa');
describe('Auth Middleware', () => {
it('should allow request with valid token', async () => {
const app = new Koa();
app.use(authMiddleware({ secret: 'test' }));
app.use(ctx => { ctx.body = 'success'; });
const token = jwt.sign({ userId: 1 }, 'test');
await request(app.callback())
.get('/')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect('success');
});
it('should reject request without token', async () => {
const app = new Koa();
app.use(authMiddleware({ secret: 'test' }));
await request(app.callback())
.get('/')
.expect(401);
});
});
6. 面试要点
-
洋葱圈模型的理解
- 中间件按照注册顺序执行
await next()是进入下一层的"入口"next()之后的代码在"回溯"阶段执行- 适合实现前置处理和后置清理(如日志、事务)
-
Express vs Koa 中间件差异
- Express:回调函数风格,通过
next()传递,无原生异步支持 - Koa:async/await 风格,洋葱圈模型,更好的异步处理
- Express 中
res已发送后不能修改,Koa 中可以在后续中间件修改ctx.body
- Express:回调函数风格,通过
-
中间件实现的关键点
- 函数返回 async 函数(Koa)或接受 next 回调(Express)
- 正确处理错误,使用 try-catch
- 记得调用
next()或await next(),否则请求会挂起 - 合理使用
ctx.state或req.locals传递数据
-
常见中间件实现场景
- 身份认证:解析 JWT,验证用户身份
- 日志记录:记录请求信息、响应时间
- 限流控制:防止接口被滥用
- 错误处理:统一错误响应格式
- 请求解析:解析 JSON、表单、文件上传
-
性能考虑
- 中间件按顺序执行,注意注册顺序
- 避免在中间件中进行耗时同步操作
- 合理使用条件判断跳过不必要的中间件
- 注意内存泄漏(如闭包中持有大量数据)