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你可以针对不同类型的异常定义不同的过滤器框架会自动捕获并格式化错误响应。下面这张表可以帮你快速对比三者的核心机制对比维度ExpressKoa2Nest.js中间件模型线性洋葱模型分层组件异步支持回调/Promiseasync/awaitasync/await路由声明手动注册手动注册装饰器声明错误处理四参数中间件try-catchExceptionFilter依赖注入无无内置学习曲线低中高适合场景小型项目/原型中型项目/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/awaitconst 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: RepositoryUser, ) {} 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 集成 pinoconst 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 集成 pinoconst 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 需要自定义 LoggerServiceimport { 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-hookedconst 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(); }); });然后在日志中间件里读取 traceIdapp.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): Observableany { 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 响应框架每秒请求数约平均延迟内存占用Express15,0000.6ms45MBKoa222,0000.4ms38MBNest.js (Express)12,0000.8ms65MBNest.js (Fastify)28,0000.3ms55MBKoa2 比 Express 快主要因为它的中间件模型更轻量没有 Express 那么多历史包袱。Nest.js 默认跑在 Express 上所以性能略低于纯 Express但如果切换到 Fastify 适配器性能会大幅提升。不过这些数字只是参考实际性能取决于你的业务逻辑。一个包含三次数据库查询的接口框架差异可能只占总耗时的 1%。所以不要为了追求框架性能而牺牲开发效率和可维护性。5.2 集群模式与负载均衡配置Node.js 是单线程的虽然异步 IO 不阻塞但 CPU 密集型任务会卡住整个事件循环。生产环境一定要用集群模式充分利用多核 CPU。Express 和 Koa2 可以用cluster模块或者pm2pm2 start app.js -i max --name api-server-i max会根据 CPU 核心数自动启动对应数量的进程。pm2 还提供了进程守护、自动重启、日志管理、零停机重载等功能是 Node.js 生产部署的标配工具。Nest.js 同样可以用 pm2但更推荐用容器化部署。在 Dockerfile 里设置NODE_ENVproduction然后用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 生产环境部署检查清单上线之前对照这份清单逐项检查检查项ExpressKoa2Nest.js进程守护pm2/systemdpm2/systemdpm2/K8s集群模式cluster/pm2cluster/pm2pm2/K8s环境变量dotenvdotenvnestjs/config日志采集pino/winstonpino/winstonpino/winston错误上报SentrySentrySentry健康检查/health/healthnestjs/terminus优雅关闭server.closeserver.closeapp.close安全头helmetkoa-helmethelmet限流express-rate-limitkoa-ratelimitnestjs/throttlerCORScorskoa/corsapp.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 中间件顺序导致的诡异 BugExpress 和 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-catchrouter.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 导入了 OrdersModuleOrdersModule 又导入了 UsersModule启动时就会报错。解决方案是用forwardRefModule({ imports: [forwardRef(() OrdersModule)], providers: [UsersService], exports: [UsersService], }) export class UsersModule {}然后在服务注入时也用forwardRefconstructor( 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-dataValidationPipe 会尝试解析并校验导致文件字段被剥离。解决方案是在文件上传接口上跳过 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因为它的模块化和依赖注入能强制统一代码风格减少“每个人写一套”的问题。业务复杂度方面如果业务逻辑简单主要是 CRUDExpress 和 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 反而更多了。如果你现在的项目跑得稳团队熟悉现有框架业务没有遇到明显的架构瓶颈那就不要换。把精力放在业务功能、性能优化、用户体验上比换框架的收益大得多。只有当现有框架确实阻碍了业务发展——比如代码混乱到无法维护、新人上手要一个月、每次加功能都要改十几个文件——这时候才考虑迁移。而且迁移之前一定要做充分的评估和试点不要拿核心业务冒险。框架只是工具业务价值才是目的。选一个团队用得顺手的比选一个“技术最先进”的更重要。
