Express.js 完全指南:路由、中间件与 REST API 开发实战

Express.js 是最流行的 Node.js Web 框架。本文详解路由系统、中间件机制、错误处理、请求验证、项目结构组织、生产部署准备,含完整的 CRUD API 代码示例。

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 容器原生 TypeScriptDI、模块化、GraphQL中等企业级、团队协作

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. GraphQL BFF 与微前端:多前端团队的 Schema 分片与协作模式
  2. 前端 GraphQL 客户端集成:Apollo Client、Relay 与 urql 选型
  3. 前端性能调试实战:从首屏加载到运行时瓶颈