1. 三个框架的底层设计哲学差异
1.1 从回调地狱到洋葱模型再到企业级架构
Express 诞生于 2010 年,那时候 Node.js 本身还在 0.x 版本,整个生态都在摸索阶段。Express 的设计思路非常朴素——它本质上是一个路由加中间件的调度器,中间件按照注册顺序线性执行,每个中间件拿到req、res和next三个参数,调用next()就把控制权交给下一个。这种线性模型的好处是直观,坏处是异步操作一多,错误处理就变得极其分散。你写一个包含数据库查询、文件读写、第三方接口调用的接口,try-catch 要写好几层,稍微不注意就漏掉某个分支的错误捕获。
Koa2 是 Express 原班人马在 2013 年前后重新设计的产物。它最大的变化是引入了async/await和洋葱模型。所谓洋葱模型,就是中间件的执行路径像穿过一层层洋葱:请求从外层中间件进入,逐层深入到最内层,然后再逐层向外返回。这意味着你可以在await next()之前做请求预处理,在之后做响应后处理,而且错误可以通过try-catch在任意一层统一捕获。这个设计在当年是非常超前的,它让异步流程控制变得像写同步代码一样自然。
Nest.js 则是 2017 年之后出现的,它面向的是中大型团队和复杂业务系统。Nest.js 的核心思路是“用 Angular 的架构思想来写服务端”——模块化、依赖注入、装饰器、分层架构。它底层默认跑在 Express 上,也可以切换到 Fastify,但对外暴露的编程模型完全统一。Nest.js 解决的不是“怎么处理一个请求”的问题,而是“怎么组织一百个模块、三百个服务、上千个接口”的问题。
这三个框架的演进路径,其实对应了 Node.js 服务端开发从“能跑就行”到“跑得优雅”再到“跑得可维护”的三个阶段。你选哪个,取决于你的项目处在哪个阶段,以及你的团队规模和技术储备。
1.2 核心机制对比:中间件、路由与错误处理
先看中间件模型。Express 的中间件是线性的,next()调用后不会回来,所以你不能在中间件里做“响应后”的操作。Koa2 的中间件是栈式的,await next()之后的代码会在内层中间件执行完毕后继续执行,这让你可以轻松实现请求耗时统计、统一响应包装、日志记录等横切关注点。Nest.js 的中间件概念更丰富,它区分了 Middleware、Guard、Interceptor、Pipe、Filter 五种可插拔组件,每种组件在请求生命周期中有明确的执行位置。
路由方面,Express 和 Koa2 都需要手动注册路由,或者借助express.Router、koa-router这类工具做模块化拆分。Nest.js 则通过装饰器@Controller、@Get、@Post等直接声明路由,路由信息与控制器类绑定,天然支持模块化。你写一个UserController,所有用户相关的接口都集中在这个类里,路由前缀、参数装饰器、返回值处理全部由框架统一管理。
错误处理是三者差异最大的地方。Express 的错误处理依赖一个四参数中间件(err, req, res, next),你必须显式调用next(err)才能把错误传递到错误处理中间件,而且异步代码里的错误需要手动捕获后传递。Koa2 的错误处理非常自然,任何一层中间件抛出的异常都会被最外层的try-catch捕获,你可以在顶层中间件里统一处理所有错误。Nest.js 则提供了 ExceptionFilter,你可以针对不同类型的异常定义不同的过滤器,框架会自动捕获并格式化错误响应。
下面这张表可以帮你快速对比三者的核心机制:
| 对比维度 | Express | Koa2 | Nest.js |
|---|---|---|---|
| 中间件模型 | 线性 | 洋葱模型 | 分层组件 |
| 异步支持 | 回调/Promise | async/await | async/await |
| 路由声明 | 手动注册 | 手动注册 | 装饰器声明 |
| 错误处理 | 四参数中间件 | try-catch | ExceptionFilter |
| 依赖注入 | 无 | 无 | 内置 |
| 学习曲线 | 低 | 中 | 高 |
| 适合场景 | 小型项目/原型 | 中型项目/API | 中大型企业级 |
1.3 选型背后的真实考量:不只是技术问题
很多人在选框架的时候只看技术特性,但实际工作中,选型往往是一个综合决策。我经历过一个项目,最初用 Express 写了一个内部管理系统,接口不到五十个,团队三个人,跑得很稳。后来业务扩张,接口数量翻了三倍,团队增加到八个人,代码开始出现混乱——路由文件互相引用、中间件顺序经常被改错、新人上手要花两周才能理清结构。这时候我们评估了 Koa2 和 Nest.js,最终选择了 Nest.js,原因不是 Koa2 不好,而是 Nest.js 的模块化和依赖注入能强制团队遵循统一的代码组织方式。
另一个需要考虑的因素是生态和社区。Express 的中间件生态是最丰富的,你几乎能找到任何功能的现成中间件,从身份认证到文件上传到限流,npm 上都有成熟的包。Koa2 的生态相对小一些,但核心中间件也很齐全。Nest.js 的生态是自成体系的,它有自己的官方模块@nestjs/*,覆盖了配置、数据库、缓存、消息队列、微服务等常见需求,质量有保障,但如果你需要某个冷门功能,可能需要自己封装。
还有一个容易被忽略的点是招聘和团队培养。Express 的开发者基数最大,招人容易,新人上手快。Nest.js 的开发者相对少一些,但会 Nest.js 的人通常对架构有更深的理解。Koa2 处在一个中间位置,会的人不少,但真正用好洋葱模型的人不多。
2. 项目初始化与目录结构设计
2.1 Express 项目的轻量起步
Express 的初始化非常简单,你甚至不需要脚手架。创建一个新目录,执行npm init -y,然后安装 Express 和几个基础依赖:
npm install express body-parser cors helmet morgan一个典型的 Express 项目目录结构可以这样组织:
project/ ├── app.js ├── routes/ │ ├── index.js │ └── users.js ├── middlewares/ │ ├── auth.js │ └── errorHandler.js ├── controllers/ │ └── userController.js ├── services/ │ └── userService.js └── config/ └── index.jsapp.js是入口文件,负责创建应用实例、注册全局中间件、挂载路由。路由文件负责定义 URL 和 HTTP 方法的映射,控制器负责解析请求参数和返回响应,服务层负责业务逻辑。这种分层不是 Express 强制的,但如果你不主动分层,代码很快就会变成一锅粥。
我个人的经验是,Express 项目一定要在早期就定好分层规范。哪怕项目再小,也要把路由和业务逻辑分开。我见过太多项目把数据库查询直接写在路由回调里,后期想加缓存、想换数据库、想写单元测试,全部要重写。
2.2 Koa2 项目的洋葱模型实践
Koa2 的初始化同样简单,但它的中间件写法需要你理解洋葱模型。安装依赖:
npm install koa koa-router koa-bodyparser koa-helmet koa-logger一个 Koa2 项目的入口文件通常长这样:
const Koa = require('koa'); const Router = require('koa-router'); const bodyParser = require('koa-bodyparser'); const logger = require('koa-logger'); const app = new Koa(); const router = new Router(); // 顶层错误处理中间件 app.use(async (ctx, next) => { try { await next(); } catch (err) { ctx.status = err.status || 500; ctx.body = { code: ctx.status, message: err.message || 'Internal Server Error' }; ctx.app.emit('error', err, ctx); } }); // 响应耗时统计中间件 app.use(async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; ctx.set('X-Response-Time', `${ms}ms`); }); app.use(logger()); app.use(bodyParser()); // 路由 router.get('/api/users', async (ctx) => { ctx.body = { users: [] }; }); app.use(router.routes()).use(router.allowedMethods()); app.listen(3000);注意上面两个中间件的顺序:错误处理中间件在最外层,耗时统计在第二层。因为洋葱模型是先进后出,所以错误处理中间件能捕获到所有内层中间件抛出的异常,耗时统计能精确计算从请求进入到响应返回的时间。
Koa2 的目录结构可以比 Express 更灵活,因为它的中间件机制本身就适合做横切关注点的抽离。我通常会把中间件单独放在middlewares/目录下,每个中间件一个文件,然后在入口文件里按顺序组合。
2.3 Nest.js 项目的工程化初始化
Nest.js 提供了官方 CLI,初始化项目只需要一条命令:
npx @nestjs/cli new my-projectCLI 会问你用 npm 还是 yarn,然后自动生成一套完整的项目骨架。生成后的目录结构是这样的:
src/ ├── app.module.ts ├── app.controller.ts ├── app.service.ts ├── main.ts ├── common/ │ ├── filters/ │ ├── guards/ │ ├── interceptors/ │ └── pipes/ ├── config/ ├── modules/ │ └── users/ │ ├── users.module.ts │ ├── users.controller.ts │ ├── users.service.ts │ └── dto/ └── utils/main.ts是入口文件,负责创建 Nest 应用实例、注册全局管道/过滤器/拦截器、启动 HTTP 服务。app.module.ts是根模块,所有其他模块都在这里导入。每个业务模块有自己的 Module、Controller、Service,模块之间通过依赖注入解耦。
Nest.js 的模块化设计有一个很大的好处:你可以把每个业务域做成一个独立的模块,模块内部高内聚,模块之间低耦合。比如用户模块只暴露 UsersService,其他模块想用用户数据就导入 UsersModule,而不是直接去操作数据库。这种设计在团队协作时特别有用,每个人负责自己的模块,接口边界清晰,不容易互相干扰。
2.4 三种目录结构的适用场景分析
Express 的目录结构最自由,适合快速原型和小型项目。你不需要一开始就设计复杂的模块划分,随着业务增长逐步重构即可。但自由也意味着风险,如果团队没有统一的规范,每个人的写法可能都不一样。
Koa2 的目录结构介于 Express 和 Nest.js 之间,它的中间件机制天然适合做请求级别的横切处理,但业务逻辑的组织仍然需要你自己规划。我建议 Koa2 项目至少要有routes/、controllers/、services/三层,中间件单独管理。
Nest.js 的目录结构最规范,适合中大型项目和多人协作。它的模块化、依赖注入、装饰器体系需要一定的学习成本,但一旦团队熟悉了这套模式,代码的可维护性和可测试性会显著提升。
提示:不要因为 Nest.js 看起来“重”就排斥它。如果你的项目预计会超过 50 个接口,或者团队超过 3 个人,Nest.js 的规范化收益会远大于它的学习成本。
3. 路由、中间件与请求处理实战
3.1 Express 路由与中间件的典型写法
Express 的路由注册非常直接:
const express = require('express'); const router = express.Router(); router.get('/users', async (req, res, next) => { try { const { page = 1, limit = 20 } = req.query; const users = await userService.list({ page, limit }); res.json({ code: 0, data: users }); } catch (err) { next(err); } }); router.post('/users', async (req, res, next) => { try { const user = await userService.create(req.body); res.status(201).json({ code: 0, data: user }); } catch (err) { next(err); } }); module.exports = router;注意每个异步路由都要写try-catch并调用next(err),这是 Express 错误处理的标准模式。如果你忘了写,错误就会被吞掉,客户端会一直等到超时。这个问题在 Express 项目里非常常见,我见过不少线上事故都是因为某个异步路由没有正确传递错误。
中间件的写法也很直接:
function authMiddleware(req, res, next) { const token = req.headers.authorization; if (!token) { return res.status(401).json({ code: 401, message: 'Unauthorized' }); } try { const payload = verifyToken(token); req.user = payload; next(); } catch (err) { next(err); } }Express 中间件的执行顺序完全取决于注册顺序,所以你要特别注意app.use()的调用顺序。通常的顺序是:日志 -> 安全 -> 解析 body -> 认证 -> 路由 -> 错误处理。
3.2 Koa2 洋葱模型下的中间件组合
Koa2 的中间件写法充分利用了async/await:
const Koa = require('koa'); const Router = require('koa-router'); const app = new Koa(); const router = new Router(); // 统一响应格式中间件 app.use(async (ctx, next) => { await next(); if (ctx.body && !ctx.body.code) { ctx.body = { code: 0, data: ctx.body, message: 'success' }; } }); // 认证中间件 app.use(async (ctx, next) => { const token = ctx.headers.authorization; if (!token) { ctx.throw(401, 'Unauthorized'); } try { ctx.state.user = verifyToken(token); await next(); } catch (err) { ctx.throw(401, 'Invalid token'); } }); router.get('/api/users', async (ctx) => { const { page = 1, limit = 20 } = ctx.query; const users = await userService.list({ page, limit }); ctx.body = users; }); app.use(router.routes()).use(router.allowedMethods());Koa2 的ctx.throw()会直接抛出 HTTP 异常,被顶层的错误处理中间件捕获。这种写法比 Express 的next(err)更简洁,也更符合直觉。另外ctx.state是 Koa2 推荐的请求级状态存储位置,你可以在上游中间件里往ctx.state写数据,下游中间件直接读取。
洋葱模型的一个典型应用场景是响应时间统计和统一响应包装。你可以在最外层中间件里记录开始时间,await next()之后计算耗时并设置响应头;在第二层中间件里await next()之后把ctx.body包装成统一格式。这些操作在 Express 里需要借助res.on('finish')事件或者重写res.json方法,远不如 Koa2 优雅。
3.3 Nest.js 控制器、服务与模块的协作
Nest.js 的控制器用装饰器声明路由:
import { Controller, Get, Post, Body, Query, Param } from '@nestjs/common'; import { UsersService } from './users.service'; import { CreateUserDto } from './dto/create-user.dto'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Get() async findAll(@Query('page') page: number = 1, @Query('limit') limit: number = 20) { return this.usersService.list({ page, limit }); } @Get(':id') async findOne(@Param('id') id: string) { return this.usersService.findById(id); } @Post() async create(@Body() createUserDto: CreateUserDto) { return this.usersService.create(createUserDto); } }服务层负责业务逻辑:
import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './entities/user.entity'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private usersRepository: Repository<User>, ) {} async list({ page, limit }: { page: number; limit: number }) { const [items, total] = await this.usersRepository.findAndCount({ skip: (page - 1) * limit, take: limit, }); return { items, total, page, limit }; } async findById(id: string) { const user = await this.usersRepository.findOne({ where: { id } }); if (!user) { throw new NotFoundException('User not found'); } return user; } async create(createUserDto: CreateUserDto) { const user = this.usersRepository.create(createUserDto); return this.usersRepository.save(user); } }模块把控制器和服务组织在一起:
import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { User } from './entities/user.entity'; @Module({ imports: [TypeOrmModule.forFeature([User])], controllers: [UsersController], providers: [UsersService], exports: [UsersService], }) export class UsersModule {}Nest.js 的依赖注入让服务之间的调用非常清晰。UsersService 需要数据库连接,通过@InjectRepository注入;UsersController 需要 UsersService,通过构造函数注入。所有依赖都由 Nest 的 IoC 容器管理,你不需要手动 new 任何东西,测试时也可以轻松替换成 mock 实现。
3.4 请求参数校验与数据转换的三种方案
参数校验是服务端开发中非常重要的一环。Express 和 Koa2 通常需要借助第三方库,比如joi、celebrate、class-validator。Nest.js 内置了ValidationPipe,配合class-validator和class-transformer可以实现声明式校验。
Express 配合 joi 的写法:
const Joi = require('joi'); const createUserSchema = Joi.object({ name: Joi.string().min(2).max(50).required(), email: Joi.string().email().required(), age: Joi.number().integer().min(0).max(150), }); router.post('/users', async (req, res, next) => { const { error, value } = createUserSchema.validate(req.body); if (error) { return res.status(400).json({ code: 400, message: error.details[0].message }); } // 使用 value 而不是 req.body const user = await userService.create(value); res.status(201).json({ code: 0, data: user }); });Koa2 可以用koa-joi-router或者手动校验,思路类似。Nest.js 的 DTO 写法:
import { IsString, IsEmail, IsInt, Min, Max, IsOptional } from 'class-validator'; export class CreateUserDto { @IsString() @MinLength(2) @MaxLength(50) name: string; @IsEmail() email: string; @IsOptional() @IsInt() @Min(0) @Max(150) age?: number; }然后在main.ts里全局启用校验管道:
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true, }));whitelist: true会自动剥离 DTO 中未定义的属性,transform: true会自动把字符串类型的查询参数转换成数字,forbidNonWhitelisted: true会在请求包含未定义属性时直接报错。这三个配置组合起来,能帮你挡掉大量脏数据和潜在的安全问题。
注意:Nest.js 的 ValidationPipe 默认不会转换查询参数的类型,因为查询参数永远是字符串。你需要开启
transform: true并配合@Type(() => Number)装饰器,或者使用ParseIntPipe在控制器参数级别做转换。
4. 错误处理、日志与可观测性
4.1 统一错误处理的设计模式
错误处理是区分框架成熟度的重要指标。Express 的错误处理中间件必须放在所有路由之后:
app.use((err, req, res, next) => { const status = err.status || 500; const message = err.message || 'Internal Server Error'; // 记录错误日志 logger.error({ message: err.message, stack: err.stack, url: req.originalUrl, method: req.method, ip: req.ip, }); res.status(status).json({ code: status, message: process.env.NODE_ENV === 'production' && status === 500 ? 'Internal Server Error' : message, }); });这里有一个细节:生产环境下不要把 500 错误的原始信息返回给客户端,因为可能包含数据库连接字符串、文件路径等敏感信息。但 4xx 错误可以返回具体信息,方便前端调试。
Koa2 的错误处理通常在最外层中间件里:
app.use(async (ctx, next) => { try { await next(); } catch (err) { ctx.status = err.status || 500; ctx.body = { code: ctx.status, message: ctx.status === 500 && process.env.NODE_ENV === 'production' ? 'Internal Server Error' : err.message, }; ctx.app.emit('error', err, ctx); } }); app.on('error', (err, ctx) => { logger.error({ message: err.message, stack: err.stack, url: ctx.originalUrl, method: ctx.method, }); });Nest.js 的 ExceptionFilter 可以针对不同异常类型做不同处理:
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; @Catch() export class AllExceptionsFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const message = exception instanceof HttpException ? exception.message : 'Internal Server Error'; logger.error({ message, stack: exception instanceof Error ? exception.stack : '', url: request.url, method: request.method, }); response.status(status).json({ code: status, message, timestamp: new Date().toISOString(), path: request.url, }); } }然后在main.ts里全局注册:
app.useGlobalFilters(new AllExceptionsFilter());Nest.js 还内置了很多标准异常类,比如NotFoundException、BadRequestException、UnauthorizedException,你可以在业务代码里直接抛出,框架会自动转换成对应的 HTTP 状态码和响应格式。
4.2 日志采集与结构化输出
日志是排查线上问题的生命线。三个框架都可以用winston或pino做结构化日志。我推荐pino,因为它的性能更好,输出格式也更适合被日志采集系统解析。
Express 集成 pino:
const pino = require('pino'); const expressPino = require('express-pino-logger'); const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); const expressLogger = expressPino({ logger }); app.use(expressLogger);Koa2 集成 pino:
const pino = require('pino'); const logger = pino(); app.use(async (ctx, next) => { const start = Date.now(); await next(); logger.info({ method: ctx.method, url: ctx.url, status: ctx.status, duration: Date.now() - start, }); });Nest.js 集成 pino 需要自定义 LoggerService:
import { Injectable, LoggerService } from '@nestjs/common'; import pino from 'pino'; @Injectable() export class PinoLogger implements LoggerService { private logger = pino({ level: 'info' }); log(message: string, context?: string) { this.logger.info({ context }, message); } error(message: string, trace?: string, context?: string) { this.logger.error({ context, trace }, message); } warn(message: string, context?: string) { this.logger.warn({ context }, message); } }结构化日志的关键是每条日志都是一个 JSON 对象,包含时间戳、级别、消息、上下文、请求 ID 等字段。这样日志采集系统才能做聚合、搜索和告警。我见过很多项目用console.log打日志,线上出问题时只能靠grep翻文件,效率极低。
4.3 链路追踪与性能监控的接入方式
链路追踪的核心是给每个请求分配一个唯一的 trace ID,并在所有日志和下游调用中传递这个 ID。Express 和 Koa2 可以用cls-hooked或async_hooks实现请求级别的上下文存储。Nest.js 可以用nestjs-cls这个库。
Express 集成 cls-hooked:
const cls = require('cls-hooked'); const namespace = cls.createNamespace('request'); app.use((req, res, next) => { namespace.run(() => { namespace.set('traceId', req.headers['x-trace-id'] || generateTraceId()); next(); }); });然后在日志中间件里读取 traceId:
app.use((req, res, next) => { const traceId = namespace.get('traceId'); req.log = logger.child({ traceId }); next(); });Nest.js 可以用 Interceptor 实现类似效果:
@Injectable() export class TraceInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const request = context.switchToHttp().getRequest(); const traceId = request.headers['x-trace-id'] || generateTraceId(); request.traceId = traceId; return next.handle(); } }性能监控方面,你可以接入 APM 工具,或者自己用prom-client暴露 Prometheus 指标。关键指标包括:请求量、响应时间 P50/P95/P99、错误率、事件循环延迟、内存使用量。这些指标能帮你快速定位性能瓶颈和异常波动。
提示:链路追踪的 trace ID 一定要在网关层生成并透传到所有下游服务。如果每个服务自己生成,跨服务排查问题时就无法关联同一条请求链路。
5. 性能优化与生产环境部署
5.1 三个框架的性能基准与压测对比
先说明一点:框架本身的性能差异在大多数业务场景下并不是瓶颈。真正的瓶颈通常在数据库查询、外部接口调用、序列化/反序列化、文件 IO 这些环节。但了解框架的基准性能仍然有意义,尤其是在高并发场景下。
根据社区常见的压测数据(使用autocannon或wrk,单核,简单 JSON 响应):
| 框架 | 每秒请求数(约) | 平均延迟 | 内存占用 |
|---|---|---|---|
| Express | 15,000 | 0.6ms | 45MB |
| Koa2 | 22,000 | 0.4ms | 38MB |
| Nest.js (Express) | 12,000 | 0.8ms | 65MB |
| Nest.js (Fastify) | 28,000 | 0.3ms | 55MB |
Koa2 比 Express 快,主要因为它的中间件模型更轻量,没有 Express 那么多历史包袱。Nest.js 默认跑在 Express 上,所以性能略低于纯 Express,但如果切换到 Fastify 适配器,性能会大幅提升。
不过这些数字只是参考,实际性能取决于你的业务逻辑。一个包含三次数据库查询的接口,框架差异可能只占总耗时的 1%。所以不要为了追求框架性能而牺牲开发效率和可维护性。
5.2 集群模式与负载均衡配置
Node.js 是单线程的,虽然异步 IO 不阻塞,但 CPU 密集型任务会卡住整个事件循环。生产环境一定要用集群模式充分利用多核 CPU。
Express 和 Koa2 可以用cluster模块或者pm2:
pm2 start app.js -i max --name "api-server"-i max会根据 CPU 核心数自动启动对应数量的进程。pm2 还提供了进程守护、自动重启、日志管理、零停机重载等功能,是 Node.js 生产部署的标配工具。
Nest.js 同样可以用 pm2,但更推荐用容器化部署。在 Dockerfile 里设置NODE_ENV=production,然后用node dist/main.js启动。容器编排平台(如 Kubernetes)会自动处理副本数和负载均衡。
负载均衡层面,你可以在 Node.js 前面放一层 Nginx 做反向代理,负责 SSL 终止、静态文件服务、请求限流、健康检查。Nginx 的upstream配置可以指向多个 Node.js 实例:
upstream api_servers { least_conn; server 127.0.0.1:3000; server 127.0.0.1:3001; server 127.0.0.1:3002; keepalive 64; } server { listen 80; location /api/ { proxy_pass http://api_servers; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }least_conn策略会把新请求发给当前连接数最少的实例,适合请求处理时间不均匀的场景。keepalive保持长连接,减少 TCP 握手开销。
5.3 内存泄漏排查与事件循环监控
Node.js 服务跑久了内存持续上涨,大概率是内存泄漏。常见原因包括:全局变量缓存没有清理、事件监听器没有移除、闭包引用了大对象、定时器没有清除。
排查内存泄漏的第一步是确认现象。你可以用process.memoryUsage()定期打印内存使用情况,或者接入 Prometheus 的process_resident_memory_bytes指标。如果 RSS 持续上涨且不回落,基本可以确定有泄漏。
第二步是抓取堆快照。用node --inspect启动服务,然后用 Chrome DevTools 的 Memory 面板抓取两个时间点的堆快照,对比对象数量和大小,找出持续增长的对象类型。
第三步是定位代码。常见的内存泄漏模式:
// 错误示例:全局缓存没有过期策略 const cache = {}; app.get('/data/:id', async (req, res) => { if (!cache[req.params.id]) { cache[req.params.id] = await fetchData(req.params.id); } res.json(cache[req.params.id]); });这个缓存会无限增长,最终耗尽内存。正确的做法是用 LRU 缓存并设置最大容量:
const LRU = require('lru-cache'); const cache = new LRU({ max: 500, ttl: 1000 * 60 * 5 });事件循环监控也很重要。如果事件循环延迟持续超过 100ms,说明有 CPU 密集型任务在阻塞。你可以用perf_hooks监控:
const { monitorEventLoopDelay } = require('perf_hooks'); const h = monitorEventLoopDelay({ resolution: 20 }); h.enable(); setInterval(() => { logger.info({ eventLoopDelay: { min: h.min, max: h.max, mean: h.mean, p99: h.percentile(99), }, }); h.reset(); }, 60000);如果发现事件循环延迟很高,需要把 CPU 密集型任务拆分成小批次,或者放到 Worker Thread 里执行。
5.4 生产环境部署检查清单
上线之前,对照这份清单逐项检查:
| 检查项 | Express | Koa2 | Nest.js |
|---|---|---|---|
| 进程守护 | pm2/systemd | pm2/systemd | pm2/K8s |
| 集群模式 | cluster/pm2 | cluster/pm2 | pm2/K8s |
| 环境变量 | dotenv | dotenv | @nestjs/config |
| 日志采集 | pino/winston | pino/winston | pino/winston |
| 错误上报 | Sentry | Sentry | Sentry |
| 健康检查 | /health | /health | @nestjs/terminus |
| 优雅关闭 | server.close | server.close | app.close |
| 安全头 | helmet | koa-helmet | helmet |
| 限流 | express-rate-limit | koa-ratelimit | @nestjs/throttler |
| CORS | cors | @koa/cors | app.enableCors |
优雅关闭经常被忽略,但它很重要。当服务收到 SIGTERM 信号时,应该停止接受新请求,等待正在处理的请求完成,然后关闭数据库连接,最后退出进程。Express 和 Koa2 需要手动实现:
process.on('SIGTERM', () => { server.close(() => { logger.info('Server closed'); db.close(); process.exit(0); }); setTimeout(() => { logger.error('Forced shutdown'); process.exit(1); }, 30000); });Nest.js 提供了app.close()方法,配合enableShutdownHooks()可以自动处理:
app.enableShutdownHooks();6. 常见问题与排查技巧实录
6.1 中间件顺序导致的诡异 Bug
Express 和 Koa2 的中间件顺序问题是最常见的坑。我遇到过好几次:认证中间件放在路由之后,导致所有接口都不需要认证就能访问;body 解析中间件放在路由之后,导致req.body永远是空对象。
Express 的中间件顺序规则很简单:app.use()和app.METHOD()按照代码书写顺序执行。所以你必须确保:
- 日志中间件在最前面
- 安全头中间件紧随其后
- body 解析在路由之前
- 认证中间件在需要保护的路由之前
- 错误处理中间件在所有路由之后
Koa2 的顺序规则类似,但因为洋葱模型的特性,你还需要注意await next()的位置。如果你在中间件里忘了写await next(),后面的中间件和路由都不会执行,请求会挂起直到超时。
注意:Koa2 中间件里调用
next()一定要加await,否则错误不会被正确捕获,而且执行顺序会乱。这是新手最容易犯的错误之一。
6.2 异步错误捕获的遗漏与修复
Express 4.x 不会自动捕获异步路由里抛出的错误。如果你写:
router.get('/users', async (req, res) => { const users = await userService.list(); // 如果这里抛错 res.json(users); });错误不会被错误处理中间件捕获,而是变成 unhandledRejection,最终导致进程崩溃。解决方案有三种:
第一种是每个异步路由都包 try-catch:
router.get('/users', async (req, res, next) => { try { const users = await userService.list(); res.json(users); } catch (err) { next(err); } });第二种是封装一个 asyncHandler 高阶函数:
const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; router.get('/users', asyncHandler(async (req, res) => { const users = await userService.list(); res.json(users); }));第三种是升级到 Express 5.x,它原生支持异步错误捕获。但 Express 5.x 目前还在 beta 阶段,生产环境慎用。
Koa2 和 Nest.js 不存在这个问题,因为它们的中间件和控制器都基于 async/await,框架会自动捕获异步错误。
6.3 依赖注入与模块循环引用的解决
Nest.js 的模块循环引用是一个比较隐蔽的问题。比如 UsersModule 导入了 OrdersModule,OrdersModule 又导入了 UsersModule,启动时就会报错。
解决方案是用forwardRef:
@Module({ imports: [forwardRef(() => OrdersModule)], providers: [UsersService], exports: [UsersService], }) export class UsersModule {}然后在服务注入时也用forwardRef:
constructor( @Inject(forwardRef(() => OrdersService)) private ordersService: OrdersService, ) {}但更好的做法是重新设计模块边界,把共享的逻辑抽到一个独立的 SharedModule 里,让 UsersModule 和 OrdersModule 都依赖 SharedModule,而不是互相依赖。循环引用通常是架构设计有问题的信号,不要只靠forwardRef掩盖问题。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 请求一直挂起不返回 | 中间件忘了 await next() | 检查 Koa2 中间件 | 补上 await next() |
| req.body 为空 | body 解析中间件顺序错误 | 检查 app.use 顺序 | 把 bodyParser 放到路由之前 |
| 异步错误导致进程崩溃 | Express 未捕获异步异常 | 查看 unhandledRejection | 用 asyncHandler 包装 |
| Nest.js 启动报循环依赖 | 模块互相导入 | 查看模块依赖图 | 用 forwardRef 或重构模块 |
| 内存持续上涨 | 缓存无上限/监听器未移除 | 抓堆快照对比 | 用 LRU 缓存/移除监听器 |
| 事件循环延迟高 | CPU 密集型任务阻塞 | 监控 eventLoopDelay | 拆分批处理/Worker Thread |
| 接口响应慢但 CPU 不高 | 数据库查询慢/外部接口慢 | 加链路追踪 | 优化查询/加缓存 |
| 生产环境错误信息泄露 | 未区分环境返回错误 | 检查错误处理中间件 | 生产环境隐藏 500 错误详情 |
6.5 我踩过的三个真实坑
第一个坑是 Koa2 的ctx.body赋值。有一次我写了一个中间件,在await next()之后判断ctx.body是否存在,如果存在就包装成统一格式。结果发现文件下载接口的响应被破坏了,因为文件流被当成了普通对象包装。后来改成判断ctx.body instanceof Stream就跳过包装。这个问题的教训是:统一响应格式中间件要排除流式响应和静态文件。
第二个坑是 Nest.js 的 ValidationPipe 和文件上传冲突。文件上传接口的 body 是multipart/form-data,ValidationPipe 会尝试解析并校验,导致文件字段被剥离。解决方案是在文件上传接口上跳过 ValidationPipe,或者用@UseInterceptors(FileInterceptor())单独处理。
第三个坑是 Express 的res.json()被重写后导致的性能问题。有个项目为了统一响应格式,重写了res.json方法,每次调用都要做深拷贝和格式转换。在高并发场景下,这个重写导致 CPU 使用率飙升。后来改成在中间件里包装ctx.body,性能恢复正常。这个教训是:不要轻易重写框架的原生方法,尽量用中间件或拦截器实现横切逻辑。
7. 框架选型的决策框架与迁移策略
7.1 按项目规模和团队结构做选择
选框架不是选最好的,而是选最合适的。我通常用三个维度来评估:项目规模、团队规模、业务复杂度。
项目规模方面,如果接口数量少于 30 个,Express 足够了,没必要引入 Nest.js 的复杂度。如果接口数量在 30 到 100 之间,Koa2 是一个很好的平衡点,洋葱模型能帮你优雅地处理横切关注点。如果接口数量超过 100 个,或者预计会快速增长,Nest.js 的模块化设计能帮你控制复杂度。
团队规模方面,1 到 3 人的小团队用 Express 或 Koa2 效率最高,沟通成本低,不需要太重的规范。5 人以上的团队建议用 Nest.js,因为它的模块化和依赖注入能强制统一代码风格,减少“每个人写一套”的问题。
业务复杂度方面,如果业务逻辑简单,主要是 CRUD,Express 和 Koa2 都能胜任。如果业务逻辑复杂,涉及多个领域模型、事务、事件驱动、微服务,Nest.js 的分层架构和模块化设计会更有优势。
7.2 从 Express 迁移到 Nest.js 的渐进路径
如果你有一个运行中的 Express 项目,想迁移到 Nest.js,不建议一次性重写。可以采用渐进式迁移策略:
第一步,在 Nest.js 项目里用@nestjs/platform-express创建一个兼容层,把现有 Express 中间件挂载到 Nest 应用上:
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import * as express from 'express'; async function bootstrap() { const app = await NestFactory.create(AppModule); const expressApp = app.getHttpAdapter().getInstance(); // 挂载旧的 Express 路由 expressApp.use('/legacy', require('./legacy/routes')); await app.listen(3000); }第二步,逐个模块迁移。先把一个独立的业务域(比如用户管理)用 Nest.js 重写,其他模块继续跑在 Express 路由上。Nest.js 和 Express 共享同一个 HTTP 服务器,所以可以共存。
第三步,迁移完成后,移除兼容层和旧路由。整个过程可以持续几周甚至几个月,不影响线上业务。
7.3 什么情况下不该换框架
最后说一个反直觉的观点:大多数情况下,你不该换框架。我见过太多团队因为“Nest.js 更流行”或者“Koa2 性能更好”就决定重写项目,结果花了几个月时间,业务没增长,bug 反而更多了。
如果你现在的项目跑得稳,团队熟悉现有框架,业务没有遇到明显的架构瓶颈,那就不要换。把精力放在业务功能、性能优化、用户体验上,比换框架的收益大得多。
只有当现有框架确实阻碍了业务发展——比如代码混乱到无法维护、新人上手要一个月、每次加功能都要改十几个文件——这时候才考虑迁移。而且迁移之前一定要做充分的评估和试点,不要拿核心业务冒险。
框架只是工具,业务价值才是目的。选一个团队用得顺手的,比选一个“技术最先进”的更重要。