与 Spring 的主要差异
大多数 Nest 概念在 Spring 里都有对应物,靠对照表就能找到方向。但有三个差异会 改变写代码的方式,也解释了 Java 开发者初到 Node 时遇到的大部分 bug:执行模型(一个事件循环,而不是每个请求一个 线程)、一个在运行时拿不到类型的注入容器,以及一个没有 JVM、装饰器只是普通函数的生态。
事件循环 vs 每请求一线程
Spring MVC 的模型
运行在 Tomcat 上的 Spring MVC 为每个请求分配一个线程,从线程池中取出(Spring Boot 默认 200 个线程)。一次 JDBC 调用在等待数据库响应时会阻塞所在的线程,但其他请求在其他线程上照常执行。只要线程池没有耗尽,阻塞的代价是内存和 上下文切换,而不是拖慢其他请求。
Node 的模型
Node 在单个线程上执行你的 JavaScript,这个线程运行着事件循环。I/O(网络、数据库、文件)是非阻塞的:请求发出 之后,线程转去做别的事,等结果返回时,回调(或者 async 函数在 await 之后的部分)被放入队列。这样一个线程就能 服务成千上万个并发请求,前提是没有哪个请求独占它。
代价是:一个 CPU 密集的循环或一个同步调用会阻塞所有请求,而不只是它自己那一个。事件循环的详细机制(各个阶段、 微任务和宏任务)在 J1 中讲解。
在 TaskFlow 上实测
J1 的练习保留在 TaskFlow 里:
// The J1 exercise. Call /demo/block, then /demo/ping in another terminal:
// ping waits ~5 s, because the only JavaScript thread is busy looping.
// With /demo/sleep, ping answers at once: awaiting frees the thread.
@Public()
@SkipThrottle()
@Controller({ path: 'demo', version: VERSION_NEUTRAL })
export class EventLoopDemoController {
@Get('ping')
ping() {
return { pong: new Date().toISOString() };
}
@Get('block')
block() {
const end = Date.now() + 5_000;
while (Date.now() < end) {
// Synchronous CPU work: nothing else can run meanwhile.
}
return { blockedMs: 5_000 };
}
@Get('sleep')
async sleep() {
await sleep(5_000); // The thread serves other requests during the wait.
return { sleptMs: 5_000 };
}
}启动应用,调用 /demo/block,然后在另一个终端调用 /demo/ping:
curl http://localhost:3000/demo/block &
curl -w '%{time_total}s\n' http://localhost:3000/demo/ping在 TaskFlow 上的实测结果:/demo/block 执行期间,/demo/ping 等了 4.7 秒;/demo/sleep 执行期间,它在 约 1 毫秒内就返回了。while 循环一直占着线程;await sleep(5_000) 把线程交还给事件循环,事件循环在等待期间 处理了 /demo/ping。演示模块只在非生产环境加载(app.module.ts 里的 ConditionalModule)。
实际影响
- 所有 I/O 都要经过
await。 Node 的同步 API(readFileSync、execSync等)会阻塞线程:只在启动阶段或脚本里 使用。 - 处理函数里不要
Thread.sleep,也不要做繁重计算。 等待要写成await setTimeout(ms)(node:timers/promises); 耗时的计算交给 worker 或队列(见下文)。 - 没有被 await 的 Promise 是典型 bug。 没有
await,函数不等结果就继续往下执行:外层的try/catch看不到错误, HTTP 响应可能在写入完成之前就发出去了(陷阱)。TaskFlow 启用的 lint 规则no-floating-promises会报告这种情况。 - 并发要用
Promise.all的思路,而不是ExecutorService。 并行发起两个 I/O 既不需要线程池也不需要线程:只要 不一个接一个地等待就行。
// Spring:在线程池上并行执行两个调用
var user = CompletableFuture.supplyAsync(() -> users.findById(id), executor);
var tasks = CompletableFuture.supplyAsync(() -> taskRepo.findByAssignee(id), executor);
CompletableFuture.allOf(user, tasks).join();// TaskFlow 之外的示例:两个查询同时发出,只用一个线程
const [user, tasks] = await Promise.all([
this.users.findById(id),
this.tasks.findBy({ assigneeId: id }),
]);Promise.all 在第一个 Promise 被拒绝时就失败;Promise.allSettled 会等待所有结果。在 TypeORM 事务里,所有查询都 走同一个连接:Promise.all 带不来任何并行。
CPU 计算:worker_threads 或队列
和 @Async 不同,async 不会把工作挪到另一个线程上。对于耗时的计算(生成 PDF、处理图片、大量解析):
worker_threads在同一进程的另一个线程上执行 JavaScript,它有自己的事件循环;通过消息通信 (Node 文档)。- 任务队列(J9 的 BullMQ)把工作移出请求:处理函数投递一个任务后立即响应, worker 稍后处理,并支持重试。不过,在同一进程中运行的 BullMQ worker 共享该进程的事件循环:繁重的计算要放到 单独的进程里运行。
Java 里最接近的:WebFlux
Java 里最接近的模型是运行在 Netty 上的 WebFlux:几个事件循环线程、非阻塞 I/O,以及同样绝对不能阻塞的规则。 区别在语法上:WebFlux 串联 Mono 和 Flux,Node 则用 async/await 写顺序代码。Nest 在拦截器里用到的 RxJS 扮演的是 Reactor 的角色。
Java 21 的虚拟线程从另一端解决同一个问题:它们保留了每请求一线程的阻塞式写法,I/O 期间由 JVM 把虚拟线程从载体 线程上卸下。一个 CPU 循环会占住一个载体线程,但载体线程和 CPU 核数一样多;而 Node 只有一个 JavaScript 线程,也 没有任何机制能抢占它。
注入容器
类型在运行时消失
Java 在运行时保留类和接口(以及签名里的泛型参数,Spring 能够利用它们):容器知道某个构造函数需要一个 ProjectsStore。TypeScript 在编译时擦除所有类型:接口、type 别名、泛型、类型注解。只剩下类,因为类是 JavaScript 的值。
为了让 Nest 找到构造函数的依赖,tsconfig.json 开启了 emitDecoratorMetadata:编译器在每个带装饰器的类上记录 参数类型列表(design:paramtypes),Nest 启动时通过 reflect-metadata 读取。下面是 nest build 为 TasksService 生成的内容(摘自 dist/ 中编译后的 JavaScript):
// src/tasks/tasks.service.ts 的编译输出(节选)
__param(0, (0, typeorm_1.InjectRepository)(task_entity_1.Task)),
__param(3, (0, common_1.Inject)(common_module_1.CLOCK)),
__metadata("design:paramtypes", [typeorm_2.Repository,
projects_service_1.ProjectsService,
event_emitter_1.EventEmitter2, Function])Nest 能从中得到什么:
| 源码中的参数 | 输出的元数据 | 解析方式 |
|---|---|---|
Repository<Task> | Repository(泛型消失了) | 无法按类型解析:由 @InjectRepository(Task) 提供 token |
ProjectsService | ProjectsService | 按类型解析,和 Spring 一样 |
EventEmitter2 | EventEmitter2 | 按类型解析 |
Clock(() => Date 的别名) | Function | 无法按类型解析:由 @Inject(CLOCK) 提供 token |
接口通过 token 注入
接口在运行时不存在:对于参数 store: ProjectsStore,输出的元数据是 Object,它什么也指代不了。需要一个 token(一个字符串,或者更好的选择:一个 Symbol),关联到一个 useClass、useValue 或 useFactory provider,并在参数上加 @Inject(TOKEN):
// Interfaces disappear at runtime, so the container cannot resolve one by
// type. A token stands in for it: @Inject(PROJECTS_STORE) store: ProjectsStore.
export const PROJECTS_STORE = Symbol('PROJECTS_STORE');
export interface ProjectsStore {
create(data: CreateProjectDto, ownerId: string): Promise<Project>;
/** memberId undefined: every project (admin view). */
findPage(query: ProjectQueryDto, memberId?: string): Promise<Page<Project>>;
findById(id: string): Promise<Project | null>;
update(id: string, changes: UpdateProjectDto): Promise<Project | null>;
softDelete(id: string): Promise<boolean>;
/** Archives the project and, with TypeORM, all its tasks in one transaction. */
archive(id: string): Promise<Project | null>;
isMember(projectId: string, userId: string): Promise<boolean>;
addMember(projectId: string, userId: string): Promise<void>;
}这相当于 Spring 中 @Qualifier 的作用,但在这里,只要类型不是类就必须使用。provider 的细节见 J3。
design:type 陷阱
编译器只输出你写出来的东西。对于类型是推断出来的属性,它不知道该输出什么类型,于是写入 design:type = Object。在 TaskFlow 的环境变量校验上验证过(TypeScript 6.0):
// 示意:同一个属性的两种写法
@IsInt() PORT = 3000; // 推断类型:design:type = Object
@IsInt() PORT: number = 3000; // 显式类型:design:type = Number用第一种写法,plainToInstance(..., { enableImplicitConversion: true }) 会让 "8080" 保持字符串,@IsInt() 校验 失败。用第二种写法,转换就会进行。同样的原因还解释了两件事:构造函数参数的类型是接口或来自 import type 时出现的 “Nest can't resolve dependencies of …” 错误,以及 string | null 属性(输出为 Object)为什么要在 @Column 里写 type。细节和修正方法:元数据陷阱、 J7。
声明与导出:没有 component scan
Spring Boot 通过扫描包来发现 bean。Nest 什么都不扫描:一个 provider 之所以存在,是因为它列在某个模块的 providers 里;只有当它出现在 exports 中、并且另一个模块导入了这个模块时,另一个模块才能看到它。
// No component scan: a provider is only visible to modules that import this
// one, and only because it is listed in `exports`.
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}AuthModule 导入 UsersModule 以获得 UsersService。忘了导出或导入,会在启动时报错,而不是编译时。@Global() 让模块在所有地方可见(TaskFlow 把它用在 CommonModule 及其 CLOCK token 上);只把它留给横切的工具类模块,否则 模块之间的依赖关系就看不清了。见 J3,模块。
循环依赖:forwardRef,或者更好的做法是换一种拆分
如果 ProjectsModule 导入 TasksModule,TasksModule 又反过来导入 ProjectsModule,Nest 就构建不出依赖图。两边 都用 forwardRef(() => TasksModule) 可以解开僵局,但循环仍然留在设计里。在 TaskFlow 中,依赖只有一个方向: TasksModule 导入 ProjectsModule 以调用 assertAccess(),没有任何反向依赖。对任务变化作出反应的通知没有注入到 TasksService 里:service 发出一个事件,由 NotificationsListener 监听,两个模块之间没有任何导入。
文件层面也存在循环导入:Task 导入 Project,Project 又导入 Task。这就是 TypeORM 关系接收一个函数 (@ManyToOne(() => Project, …))的原因:类只在函数被调用时才读取,那时两个文件都已加载完毕。见 循环依赖陷阱。
生态
npm 与锁文件
package.json 扮演 pom.xml 的角色:依赖、开发依赖和脚本。package-lock.json(用 pnpm 时是 pnpm-lock.yaml) 锁定已安装版本的完整依赖树,包括传递依赖。一定要把它纳入版本控制,并用 npm ci 安装,它会严格按照锁文件还原: TaskFlow 的 Dockerfile 和 CI 就是这么做的。此外,TaskFlow 在 package.json 里写的都是精确版本(没有 ^)。
依赖安装在每个项目自己的 node_modules 里,而不是像 ~/.m2 那样的共享仓库。有些依赖是可选的 “peer dependencies”,需要你自己安装:BullMQ 6 和 ioredis 就是一个验证过的例子(陷阱)。
没有 JVM
没有一个执行可移植字节码的通用运行时:TypeScript 被编译成 JavaScript(nest build 生成 dist/),再由 Node 执行 这些 JavaScript。类型检查只存在于编译阶段(npm run typecheck);到了运行时,不再有任何东西检查类型,所以输入要 显式校验(J2)。
nest new(CLI 12)会询问模块系统:默认是 ESM 配 Vitest,备选是 CommonJS 配 Jest。TaskFlow 和计划一样,用的是 CommonJS(tsconfig.json 里 module: nodenext)配 Jest。ES 模块和 CommonJS 的区别在 J1 中讲解。
装饰器是函数
Java 注解是惰性的数据,框架在需要时通过反射读取。TypeScript 装饰器是一个在类加载时执行的函数,也就是在文件 被导入的时候。Nest 的装饰器在类或方法上登记元数据;Nest 启动时再读回这些元数据,用来构建路由、注入和 guard。 所以写一个装饰器就是写一个函数:
// Metadata read by the global JwtAuthGuard: this route needs no token.
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);Nest 依赖的是 TypeScript 的“实验性”装饰器(tsconfig.json 里的 experimentalDecorators 和 emitDecoratorMetadata),而不是 ECMAScript 标准装饰器。
Lint:Nest 12 starter 中的 oxlint
CLI 12 生成的项目用 oxlint 代替 ESLint。它的配置启用了 typescript/no-floating-promises,和计划里提到的 @typescript-eslint/no-floating-promises 是同一条规则:不带 await 的 save(); 调用会被报为错误(已验证)。
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"rules": {
"typescript/no-explicit-any": "off",
"typescript/no-floating-promises": "error"
},
"env": {
"node": true
}
}TaskFlow 运行 oxlint --type-aware src/ test/(npm run lint)。选择 CommonJS + Jest 时,生成的测试脚本会通过 node --experimental-vm-modules 运行。