返回首页

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. 面试要点

  1. 洋葱圈模型的理解

    • 中间件按照注册顺序执行
    • await next() 是进入下一层的"入口"
    • next() 之后的代码在"回溯"阶段执行
    • 适合实现前置处理和后置清理(如日志、事务)
  2. Express vs Koa 中间件差异

    • Express:回调函数风格,通过 next() 传递,无原生异步支持
    • Koa:async/await 风格,洋葱圈模型,更好的异步处理
    • Express 中 res 已发送后不能修改,Koa 中可以在后续中间件修改 ctx.body
  3. 中间件实现的关键点

    • 函数返回 async 函数(Koa)或接受 next 回调(Express)
    • 正确处理错误,使用 try-catch
    • 记得调用 next()await next(),否则请求会挂起
    • 合理使用 ctx.statereq.locals 传递数据
  4. 常见中间件实现场景

    • 身份认证:解析 JWT,验证用户身份
    • 日志记录:记录请求信息、响应时间
    • 限流控制:防止接口被滥用
    • 错误处理:统一错误响应格式
    • 请求解析:解析 JSON、表单、文件上传
  5. 性能考虑

    • 中间件按顺序执行,注意注册顺序
    • 避免在中间件中进行耗时同步操作
    • 合理使用条件判断跳过不必要的中间件
    • 注意内存泄漏(如闭包中持有大量数据)