Express.js 自 2010 年发布以来一直是 Node.js 生态中最主流的 Web 框架。它以极简的 API 设计和强大的中间件扩展机制著称。本文从中间件洋葱模型的执行原理出发,深入解析面向生产环境所需的错误处理、安全加固、性能优化和路由架构设计。
1. 中间件洋葱模型与执行顺序
Express 的中间件是基于栈结构顺序执行的。注册顺序决定了调用链:先 app.use() 注册的中间件先执行。当某个中间件调用 next() 后,控制权流向下一个中间件;当后续所有中间件执行完毕,响应流会沿着调用栈逐层返回。这种先进入后退出的结构形似洋葱,因此被称为洋葱模型。
const express = require('express');
const app = express();
app.use((req, res, next) => {
console.log('Layer 1: enter');
next();
console.log('Layer 1: leave');
});
app.use((req, res, next) => {
console.log('Layer 2: enter');
next();
console.log('Layer 2: leave');
});
app.get('/', (req, res) => {
console.log('Handler: response');
res.send('OK');
});
// GET / 时的控制台输出顺序:
// Layer 1: enter
// Layer 2: enter
// Handler: response
// Layer 2: leave
// Layer 1: leave
理解洋葱模型至关重要:如果某个中间件忘记调用 next() 且也没有结束响应,请求将永远挂起;如果某个中间件在 next() 之后执行异步操作并试图修改 res,响应可能已发送至客户端,导致 ERR_HTTP_HEADERS_SENT 错误。
1.1 路径匹配与优先级
Express 的 app.use(path, middleware) 通过路径前缀匹配,而非精确匹配。app.get(path) 等路由方法则同时匹配 HTTP 方法和路径。为了消除歧义,应始终将高特异性的路由放在宽泛的 app.use 之前。
路径匹配遵循字符串前缀规则:/api 中间件会匹配 /api/users、/api/v2/posts 等所有以 /api 开头的路径。app.use() 甚至可以匹配根路径 /,因此将所有中间件、路由和错误处理按照匹配范围从窄到宽排列,是避免意外拦截的核心准则。
// ❌ 错误顺序:通用中间件会拦截所有请求,包括 /health-check
app.use('/api', apiMiddleware);
app.get('/health-check', (req, res) => res.json({ ok: true }));
// ✅ 正确顺序:精确路由在前,通用前缀在后
app.get('/health-check', (req, res) => res.json({ ok: true }));
app.use('/api', apiMiddleware);
正则表达式路由在 Express 5 之前的版本中可用,但已被标记为不推荐使用。生产环境应避免使用路由正则以减少维护困惑。
app.use('/api', genericMiddleware); // 匹配 /api, /api/users, /api/v2/...
app.get('/api/users/:id', getUser); // 精确路由放在通用中间件之后也可以工作,
// 但通用中间件会先执行
2. 错误处理中间件链
Express 的错误处理中间件拥有独特的四参数签名 (err, req, res, next)。它与普通中间件分属两个栈:普通中间件通过 next() 传递控制权,next(err) 则会跳过所有剩余普通中间件,直接进入最近的错误处理中间件。
// 异步错误必须传递给 next(err),否则进程将崩溃
app.get('/user/:id', async (req, res, next) => {
try {
const user = await db.users.findById(req.params.id);
if (!user) {
const err = new Error('User not found');
err.statusCode = 404;
throw err;
}
res.json(user);
} catch (err) {
next(err); // 显式传递,避免 unhandled rejection
}
});
// 集中式错误处理(必须放在所有路由之后)
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({
error: {
message: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
});
多个错误处理中间件可串联使用:第一个处理已知业务异常,第二个处理所有兜底错误,甚至可包含条件分支将特定类型的错误继续向后传递。
app.use((err, req, res, next) => {
if (err instanceof ValidationError) {
return res.status(400).json({ errors: err.details });
}
next(err); // 其他错误继续向后传递
});
app.use((err, req, res, next) => {
logger.error(err);
res.status(500).json({ error: 'Internal Server Error' });
});
错误处理中间件的顺序极其关键。由于 Express 按注册顺序搜索错误处理中间件,业务相关的错误处理(如验证错误、认证错误)必须排在通用兜底之前。一旦某个错误处理中间件调用了 res.send(),响应就已结束,后续错误处理中间件不会再执行。如果需要错误处理的链式调用,当前中间件必须显式调用 next(err)。
另一个常见陷阱是在错误处理中间件内部抛出新的异常。例如将错误日志发送到远程服务时网络超时,这种二次异常会被 Express 捕获并直接输出到 stderr,可能包含敏感信息。应在错误处理中间件内部使用 try/catch 包裹所有可能失败的逻辑:
app.use(async (err, req, res, next) => {
try {
await logger.error(err, { path: req.path, method: req.method });
} catch (logErr) {
console.error('Failed to log error:', logErr.message);
}
res.status(err.statusCode || 500).json({ error: 'Internal Server Error' });
});
3. Router 嵌套挂载
express.Router() 是轻量级的模块化路由对象,可独立定义中间件和路由,然后挂载到主应用。通过嵌套路由器,可以构建清晰的 RESTful 版本控制结构和领域驱动的微服务边界。
const express = require('express');
const app = express();
// v1 路由器
const v1Router = express.Router();
v1Router.use(require('./middlewares/auth'));
v1Router.use('/users', require('./routes/users'));
v1Router.use('/posts', require('./routes/posts'));
// v2 路由器(可能使用新的认证中间件)
const v2Router = express.Router();
v2Router.use(require('./middlewares/jwtAuth'));
v2Router.use('/users', require('./routes/v2/users'));
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
嵌套路由器的中间件只在它自己的子路由中生效,v1Router.use(require('./middlewares/auth')) 不会影响挂载在 v2 下的路由。这种隔离性在大型多人协作项目中至关重要。
对于 /users/:userId/posts/:postId 这类深层嵌套路径,可创建子路由挂载,避免在每个控制器中重复解析同一个参数。
const userRouter = express.Router();
const postRouter = express.Router({ mergeParams: true });
// mergeParams: true 使得 postRouter 能访问父路由的 :userId
postRouter.get('/', (req, res) => {
// req.params.userId 来自父级 userRouter
// req.params 包含: { userId: '...' }
res.json({ userId: req.params.userId, message: 'posts list' });
});
userRouter.use('/:userId/posts', postRouter);
app.use('/users', userRouter);
使用 Router 的一个重要好处是中间件隔离。在 Router 内部注册的 router.use(middleware) 只对该 Router 及其子路由生效,不会泄漏到应用的其他部分。这使得不同领域模块可以采用各自的认证策略和预处理逻辑。例如 /api/public 下的开放接口可以使用轻量级的 API Key 验证,而 /api/private 下的管理接口则强制要求 JWT + RBAC 双重校验,两者互不影响。
3.1 参数处理与验证中间件
路由参数虽然便利,但缺乏内置的类型校验。应始终在参数使用前进行显式校验,避免将非法值传递到数据库查询中引发 SQL 注入或类型异常。
function validateUserId(req: Request, res: Response, next: NextFunction) {
const id = parseInt(req.params.id, 10);
if (isNaN(id) || id <= 0) {
return res.status(400).json({ error: 'Invalid user ID' });
}
req.params.id = id.toString();
next();
}
router.get('/:id', validateUserId, (req, res) => {
// req.params.id 已被确认为合法的正整数
res.json({ userId: req.params.id });
});
4. 异步路由处理器的安全包装
Express 4 的回调签名不支持原生的 async / await 错误冒泡,一旦 async 函数内部抛出异常(例如数据库查询失败)且没有 try/catch 包裹,Express 无法捕获该异常,将导致进程崩溃。Express 5 已修复此问题,但在生产中广泛使用 Express 4 的情况下,必须引入安全包装机制。
// 通用 async handler 包装器
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// 方式一:逐个包装
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user);
}));
// 方式二:装饰器模式(配合 Router)
function applyAsyncWrapper(router) {
['get', 'post', 'put', 'patch', 'delete'].forEach(method => {
const original = router[method].bind(router);
router[method] = (path, ...handlers) => {
const wrapped = handlers.map(h =>
h.length <= 3 ? asyncHandler(h) : h // 保留四参数错误处理中间件
);
return original(path, ...wrapped);
};
});
return router;
}
const router = applyAsyncWrapper(express.Router());
router.get('/:id', async (req, res) => {
const user = await User.findByPk(req.params.id);
if (!user) throw new NotFoundError(`User ${req.params.id} not found`);
res.json(user);
});
express-async-errors 第三方库通过 monkey-patch Layer.prototype.handle_request,全局解决 Express 4 的 async 错误捕获问题,但在引入前需评估其对堆栈追踪的干扰。
5. 安全中间件:Helmet、Rate Limit 与 CORS
生产环境中的 Express 应用必须默认启用安全加固。Helmet 包可设置 15 个以上的 HTTP 安全响应头,防御 XSS、点击劫持、MIME 嗅探和 HTTP 协议降级等常见攻击。
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
限流中间件 express-rate-limit 可基于 IP 或用户身份限制请求频率,保护下游数据库和外部 API 免受突发流量冲击。
const rateLimit = require('express-rate-limit');
const standardLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 100, // 每个 IP 最多 100 次请求
standardHeaders: true, // 返回 RateLimit-* 头
legacyHeaders: false,
keyGenerator: (req) => req.ip, // 或基于用户 ID
});
const strictLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
skipSuccessfulRequests: false,
handler: (req, res) => {
res.status(429).json({ error: 'Too many requests, please slow down' });
},
});
app.use('/api/', standardLimiter);
app.use('/api/login', strictLimiter);
CORS 预检请求(Preflight)处理需要同时处理 OPTIONS 方法和正确的响应头,否则跨域 POST / PUT / DELETE 请求将被浏览器拦截。
const cors = require('cors');
const corsOptions = {
origin: (origin, callback) => {
const whitelist = ['https://app.example.com', 'https://admin.example.com'];
if (!origin || whitelist.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
credentials: true,
maxAge: 86400, // 缓存预检结果 24 小时
};
app.use(cors(corsOptions));
// 确保 OPTIONS 请求能正常响应
app.options('*', cors(corsOptions));
CORS 中间件的错误处理常被忽视。当 origin 回调中抛出异常时,CORS 中间件会调用 next(err),错误会被后续的错误处理中间件捕获。但在生产环境,更优雅的做法是返回 403 状态码而非 500,因为跨域被拒绝属于业务逻辑拒绝而非系统异常。
const cors = require('cors');
const corsOptions = {
origin: (origin, callback) => {
if (origin && !whitelist.includes(origin)) {
return callback(null, false); // 返回 false 会触发默认的 403 响应
}
callback(null, true);
},
};
Helmet 的各个安全头应结合具体业务需求调整。例如 contentSecurityPolicy 在开发阶段可能过于严格,导致内联样式或脚本被浏览器拦截,团队需要约定统一的 nonce 生成策略(通常每请求生成一次性随机值)或者使用严格的哈希白名单。hsts 的 preload 标志会将域名提交到浏览器的 HSTS 预加载列表,一旦生效,即使用户首次通过 HTTP 访问也会被自动重定向到 HTTPS,但在提交前必须确认全站 HTTPS 已稳定运行。
限流策略应根据业务接口的风险等级分层。登录、注册、支付等敏感接口应使用更严格的窗口和更低的阈值,而读取类接口可以放宽限制。在生产环境中,基于 IP 的限流在 CDN 和反向代理背后可能失效(所有请求看起来都来自同一个代理 IP),此时应从请求头中提取真实客户端 IP,或结合用户会话标识进行限流。
const rateLimit = require('express-rate-limit');
const standardLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 100, // 每个 IP 最多 100 次请求
standardHeaders: true, // 返回 RateLimit-* 头
legacyHeaders: false,
keyGenerator: (req) => req.ip, // 代理环境下应使用 x-forwarded-for
});
const strictLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
skipSuccessfulRequests: false,
handler: (req, res) => {
res.status(429).json({ error: 'Too many requests, please slow down' });
},
});
app.use('/api/', standardLimiter);
app.use('/api/login', strictLimiter);
6. 压缩与响应性能优化
启用 Gzip / Brotli 压缩可大幅降低 JSON 和文本响应的传输体积。compression 中间件会自动根据请求头中的 Accept-Encoding 选择压缩算法。
const compression = require('compression');
// 跳过小体积响应和已压缩的资源
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
},
level: 6, // zlib 压缩级别,1 最快,9 最压缩
}));
对于静态资源,应使用 Nginx 或 CDN 的边缘压缩,减轻 Node.js 进程的 CPU 压力。应用服务器应专注于动态内容的压缩。
7. Express 5 的异步错误处理演进
Express 5 移除了对 path-to-regexp@0.1.x 的依赖,重写了路由匹配引擎,最显著的改进是原生支持 promise rejection。async 路由处理器中抛出的异常无需任何包装即可被 Express 错误处理中间件捕获。
// Express 5: 无需任何包装器
app.get('/users/:id', async (req, res) => {
const user = await User.findByPk(req.params.id);
if (!user) throw new NotFoundError(); // 自动进入错误处理中间件
res.json(user);
});
// Express 5 同时支持异步错误处理中间件
app.use(async (err, req, res, next) => {
await logger.logError(err);
res.status(500).json({ error: err.message });
});
从 Express 4 迁移到 Express 5 的主要注意事项包括:路径匹配语法的变更(部分旧正则语法不再支持)、已废弃的函数移除(如 app.del 需改为 app.delete)、以及错误处理中间件必须匹配四参数签名。
常见框架选型对比
| 框架 | 中间件模型 | 类型支持 | 内置功能 | 性能 | 适用场景 |
|---|---|---|---|---|---|
| Express | 函数栈(洋葱模型) | 社区类型定义 | 极简 | 中等 | 中小型 API、快速原型 |
| Fastify | 函数栈 + JSON Schema | 官方类型定义 | Schema 验证、内置日志 | 高 | 性能敏感、大型 API |
| NestJS | 装饰器 + IoC 容器 | 原生 TypeScript | DI、模块化、GraphQL | 中等 | 企业级、团队协作 |
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。