J1 · Node 与 Nest 基础
写 Nest 之前,先要换一套执行模型:一个 JavaScript 线程服务所有请求,所有需要等待的操作(数据库、网络、文件) 都通过 Promise 完成。本章打好这些基础,生成 TaskFlow 项目,用 Docker Compose 启动 PostgreSQL,并暴露 GET /health。学完后,你能解释为什么一个 5 秒的同步循环会卡住整个 API,以及如何避免。
目标
- 理解 Node 的事件循环:各个阶段、微任务、宏任务,以及什么会阻塞线程。
- 用
Promise和async/await写异步代码,不丢任何错误。 - 区分 ES 模块和 CommonJS,知道自己的项目用的是哪一种。
- 用 Nest CLI 生成项目并读懂它的结构:
main.ts、AppModule、AppController、AppService。 - 用 Docker Compose 启动 PostgreSQL 和 Adminer,暴露
GET /health,初始化 git 仓库。
Spring → NestJS
| Spring | NestJS / Node | 要点 |
|---|---|---|
SpringApplication.run(App.class, args) | NestFactory.create(AppModule),再 app.listen(port) | 分两步:先构建容器,再监听端口 |
@SpringBootApplication 类 | AppModule(@Module) | 模块图的根,没有 component scan |
| Tomcat 线程池(每个请求一个线程) | 一个 JavaScript 线程加事件循环 | 一段同步计算会阻塞所有请求 |
CompletableFuture、Mono | Promise、async/await | await 挂起的是函数,不是线程 |
Thread.sleep(5000) | await sleep(5000)(node:timers/promises) | 等待期间线程空出来,可以服务其他请求 |
ExecutorService.invokeAll() | Promise.all() | 并发要用 Promise 来思考,而不是线程 |
@Async | 没有对应物:async 不会切换线程 | async 函数在遇到第一个 await 之前一直在同一个线程上运行 |
| Spring Initializr | nest new | 生成项目、安装依赖、初始化 git |
Maven / Gradle、pom.xml | npm、package.json + package-lock.json | npm ci 严格按 lockfile 安装 |
mvn spring-boot:run + devtools | npm run start:dev(nest start --watch) | 每次修改都重新编译并重启 |
/actuator/health | GET /health(J11 改用 Terminus) | 不参与版本:没有 /v1 前缀 |
原理
事件循环、微任务与宏任务
Node 在单个线程上执行你的 JavaScript 代码。I/O(socket、SQL 查询、文件)交给操作系统或 libuv(Node 的 C 库)处理。某个操作完成后,它的回调被放进队列。事件循环就是这样一个循环:从队列里逐个取出回调,在这唯一的 线程上执行。
每一轮循环会依次经过几个阶段,每个阶段有自己的队列:
| 阶段 | 在这里执行什么 |
|---|---|
| timers | 已到期的 setTimeout 和 setInterval 回调 |
| pending callbacks | 推迟到下一轮的某些系统错误回调 |
| idle, prepare | Node 内部使用 |
| poll | I/O 回调(socket 可读、收到 PostgreSQL 的响应);没有别的事可做时,循环就停在这里等待 |
| check | setImmediate 的回调 |
| close callbacks | 关闭事件,比如 socket.on('close') |
这些回调都是宏任务(macrotask)。每执行完一个宏任务,Node 都会把微任务(microtask)队列彻底清空: 先是 process.nextTick,再是 Promise 的后续(.then、await 之后的代码)。所以微任务总是先于下一个定时器 或下一个 I/O 执行。
// TaskFlow 之外的示例:用 node 运行的 CommonJS 文件
setTimeout(() => console.log('4. macrotask (timers)'), 0);
Promise.resolve().then(() => console.log('3. microtask (promise)'));
process.nextTick(() => console.log('2. process.nextTick'));
console.log('1. 同步代码');输出顺序和编号一致。在 ES 模块里,3 会先于 2 打印:ES 模块本身的求值就发生在一个微任务里,而 Promise 队列会先于 nextTick 队列被清空。不要让逻辑依赖这种细微的顺序。记住规则就行:先同步代码,再微任务,最后宏任务。
直接的后果是:只要有同步代码在跑,别的什么都跑不了。其他 HTTP 请求不行,定时器也不行。
// TaskFlow 之外的示例
const start = Date.now();
setTimeout(() => console.log(`timer 在 ${Date.now() - start} ms 后执行`), 10);
const end = Date.now() + 200;
while (Date.now() < end) {} // 200 ms 的同步计算设为 10 ms 的定时器大约 200 ms 后才执行:setTimeout 给的是最短延迟,不是有保证的执行时间。
在 Spring MVC 里,每个请求有自己的 Tomcat 线程:一次阻塞调用只会卡住这一个线程。在这里,处理函数里的一个 CPU 循环、一个 *Sync API(fs.readFileSync、crypto.pbkdf2Sync),或者对超大请求体做 JSON.parse,都会阻塞 所有正在处理的请求。最接近的 Java 模型是跑在 Netty 上的 WebFlux:事件循环线程很少,绝不能阻塞。真正的 重计算要挪到 worker_threads 或队列里(J9)。
并不完全是单线程
libuv 内部有一个小线程池(默认 4 个线程),用于操作系统没有提供原生异步 API 的操作:文件访问、dns.lookup、 部分 crypto、zlib。但你的 JavaScript 代码始终只跑在一个线程上。
执行模型差异的全貌见主要差异。今天的练习会让 这些差异变得直观可见。
Promise 与 async/await
Promise 代表一个未来的结果:先处于等待状态,之后被兑现(得到一个值)或被拒绝(得到一个错误)。async 函数总是返回 Promise。await 挂起的是这个函数,直到 Promise 落定,同时把控制权交还事件循环,让它在 等待期间服务其他请求;函数剩下的部分稍后以微任务的形式继续执行。被拒绝的 Promise 会在 await 处变成异常: try/catch 的用法和 Java 一样。
和 Java 相比,有两点要尽早记住:
async不会创建线程。async函数在遇到第一个await之前都是同步执行的。标成async并不会把它挪到 别处执行,这和@Async不同。async函数里的 CPU 循环照样会阻塞。- 并发用
Promise.all来写,而不是ExecutorService。几个请求同时发出,等待期间线程是空闲的。
// TaskFlow 之外的示例
// 串行:第一个查询完成后才发出第二个。
const user = await usersRepo.findOneBy({ id });
const tasks = await tasksRepo.findBy({ assigneeId: id });
// 并发:两个查询同时发出,总耗时 = 最慢的那个。
const [owner, assigned] = await Promise.all([
usersRepo.findOneBy({ id }),
tasksRepo.findBy({ assigneeId: id }),
]);Promise.all 在第一个 Promise 被拒绝时立即失败;Promise.allSettled 会等所有 Promise 结束,并返回每一个的 状态。
经典 bug 是忘了 await:
// TaskFlow 之外的示例
async create(dto: CreateProjectDto) {
this.repository.save(dto); // 忘了 await
return { ok: true }; // 响应在 INSERT 之前就发出去了
}响应在写入之前就已发出;如果 INSERT 失败,错误谁也收不到:调用方的 try/catch 收不到,Nest 的异常过滤器 也收不到。从 Node 15 起,一个没有任何地方等待的被拒绝 Promise 默认甚至会让进程退出。CLI 12 生成的项目会在 lint 阶段发现这个问题:它用的是 oxlint,配置里启用了 typescript/no-floating-promises(和计划里提到的 @typescript-eslint/no-floating-promises 是同一条规则)。没有 await 的 save(); 调用会被报为错误。TaskFlow 保留了这份配置:
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"rules": {
"typescript/no-explicit-any": "off",
"typescript/no-floating-promises": "error"
},
"env": {
"node": true
}
}如果某个 Promise 确实是有意不等待的,就用 void 明确写出来:main.ts 里的 void bootstrap(); 就是这样做的。 另见常见陷阱。
ES 模块与 CommonJS
Node 有两套模块系统:
| CommonJS (CJS) | ES 模块 (ESM) | |
|---|---|---|
| 实际执行的语法 | require()、module.exports | import、export |
| 启用方式 | 默认(没有 "type": "module" 的 .js,或 .cjs) | package.json 里的 "type": "module",或 .mjs |
| 加载方式 | 同步 | 异步,允许模块顶层 await |
| 相对导入 | 扩展名可省略(./app.module) | 必须写扩展名(./app.module.js) |
__dirname、__filename | 可用 | 不可用(改用 import.meta.dirname) |
在 TypeScript 里你写的总是 import / export。最终输出哪种格式由编译器决定。设置 "module": "nodenext" 时 (TaskFlow 的设置),编译器逐个文件根据最近的 package.json 决定:没有 "type": "module",就生成 require()。
TaskFlow 用的是 CommonJS:它的 package.json 没有 type 字段,dist/main.js 用的是 require。代码里也 看得出来:postgres-options.ts 和 data-source.ts 用到了 __dirname,TypeORM CLI 通过 typeorm-ts-node-commonjs 启动(J5)。
TaskFlow 安装的 CLI 12 schematics(@nestjs/schematics 12.0.7)会在创建项目时询问模块系统:ESM 配 Vitest (默认选项),或 CommonJS 配 Jest。计划和 TaskFlow 都用 Jest(J10):选 CommonJS。 ESM 版本的特征是相对导入带 .js 后缀,以及模块顶层的 await bootstrap();。
两者混用时的典型错误:
SyntaxError: Cannot use import statement outside a module:ESM 代码被当成 CommonJS 加载。ReferenceError: require is not defined in ES module scope(或__dirname is not defined):CommonJS 代码被 当成 ESM 加载。- 只以 ESM 形式发布的包,被 CommonJS 代码导入:较新的 Node 版本(包括 Node 24)允许
require()一个没有顶层await的 ES 模块;更早的版本会报ERR_REQUIRE_ESM。
Java 里没有直接对应的东西:classpath 只认一种格式。从网上复制示例之前,先看一眼 package.json 的 type 字段。
Nest 项目结构与启动
以 CommonJS 方式运行 nest new,会生成:
taskflow/
├── src/
│ ├── app.controller.spec.ts 控制器的单元测试
│ ├── app.controller.ts
│ ├── app.module.ts
│ ├── app.service.ts
│ └── main.ts 入口
├── test/
│ ├── app.e2e-spec.ts 端到端测试(supertest)
│ └── jest-e2e.json
├── .gitattributes
├── .gitignore CLI 执行 git init 时写入
├── .oxlintrc.json
├── .prettierrc
├── README.md
├── jest.config.ts
├── nest-cli.json
├── package.json
├── tsconfig.json
└── tsconfig.build.json生成的三个类体现了 Nest 的分层方式。TaskFlow 里已经没有它们了,取而代之的是按领域划分的模块:
// nest new 生成的代码(TaskFlow 中已删除)
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}AppController相当于@RestController:不带路径的@Get()响应GET /。AppService相当于@Service:@Injectable()让它可以交给容器管理。constructor(private readonly appService: AppService)一行完成字段的声明和赋值(TypeScript 的 “parameter property”,相当于 Lombok 的@RequiredArgsConstructor)。Nest 依靠编译器生成的元数据找到AppService这个类型,详见 J3。AppModule显式声明自己的控制器和 provider。没有 component scan:不属于任何模块的类,对 Nest 来说就 不存在(J3)。
入口文件取代了 SpringApplication.run。TaskFlow 的入口:
// SpringApplication.run(TaskflowApplication.class, args)
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
setupApp(app);
setupSwagger(app);
await app.listen(app.get(ConfigService).getOrThrow<number>('PORT'));
}
// `void` marks the promise as deliberately not awaited (no-floating-promises).
void bootstrap();NestFactory.create(AppModule)从根模块出发,遍历它的imports,解析依赖,在 HTTP 适配器(Express 5) 之上实例化 provider 和控制器。它和SpringApplication.run一样会构建容器;但和后者不同,它不监听端口。setupApp(app)和setupSwagger(app)应用那些不属于 provider 的 HTTP 设置:logger、helmet、CORS、 版本(J2)、接口文档(J11)。bufferLogs: true会先缓存启动日志,等 pino logger 接上后再输出(J7)。app.listen(port)在应用尚未初始化时先完成初始化(注册路由,执行onModuleInit和onApplicationBootstrap钩子,见 J7),然后监听端口。端口来自 校验过的配置;生成的版本读取的是process.env.PORT ?? 3000。void bootstrap();显式地启动这个 Promise 而不等待它。CommonJS 不支持模块顶层await,所以才要包一个函数。
对于没有 HTTP 服务器的脚本(批处理、worker),NestFactory.createApplicationContext(AppModule) 只构建容器, 不带 HTTP 适配器,相当于非 Web 的 Spring Boot 应用。
骨架之外,TaskFlow 按领域组织代码(projects/、tasks/、users/ 等),另有横切目录(common/、config/、 database/):见目录结构和命名约定。
Nest CLI
CLI 负责生成项目和各个组件,并把它们声明到正确的模块里:
npm i -g @nestjs/cli
nest new taskflow # 完整项目:文件、npm install、git init
nest g module projects # 简写:nest g mo projects
nest g controller projects # 简写:nest g co projects
nest g service projects # 简写:nest g s projects
nest g resource projects # 一次生成模块、控制器、service、DTO 和实体
nest g service projects --dry-run # 只列出文件,不写入任何东西nest g module会把新模块加进父模块(默认是AppModule)的imports;nest g controller和nest g service会把类加进最近那个模块的controllers/providers。--skip-import关闭这种自动声明。--flat生成文件时不建子目录,--no-spec不生成测试文件。nest g resource会提几个问题(REST 还是 GraphQL、要不要 CRUD 入口): J2 会用到。nest start --watch(start:dev脚本)每次保存都重新编译并重启;nest build编译到dist/,同时应用nest-cli.json里声明的插件。
生成的文件只是起点:之后可以随意修改,CLI 不会重新生成它们。
所有命令集中在一页
Nest CLI 速查列出了各个 schematic 及其别名(gu 对应 guard,itc 对应 interceptor 等), 说明哪些会自动声明到模块里,并给出了生成 TaskFlow 骨架的命令。其中的内容都在 CLI 12 上验证过。
TaskFlow 案例
生成项目
npm i -g @nestjs/cli
nest new taskflow --package-manager npm --no-observe
cd taskflow
npm run start:dev # 然后访问 http://localhost:3000 → "Hello World!"- 询问模块系统时,选 CJS(CommonJS)配 Jest,和计划保持一致(ES 模块与 CommonJS)。
--no-observe跳过安装@nestjs/observe的提示,TaskFlow 用不到它。- CLI 会安装依赖、执行
git init并写入.gitignore(使用--skip-git时除外)。
TaskFlow 从生成的项目里保留了:tsconfig.json(module: nodenext、strict,以及依赖注入必不可少的 experimentalDecorators 和 emitDecoratorMetadata)、oxlint 配置,以及用 node --experimental-vm-modules 运行的 Jest 脚本。AppController 和 AppService 已被删除,换成了各个领域模块。
Docker Compose:PostgreSQL 与 Adminer
TaskFlow 的这个文件沿用了计划里的版本,并给可选服务加了 profile:
# docker compose up -d -> PostgreSQL + Adminer for `npm run start:dev`
# docker compose --profile queue up -d -> + Redis for the BullMQ queue (J9)
# docker compose --profile app up -d -> + the API itself, built from the Dockerfile (J11)
services:
db:
image: postgres:16-alpine
container_name: taskflow-db
environment:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskflow -d taskflow"]
interval: 5s
timeout: 3s
retries: 10
# Throwaway database for e2e tests: data lives in memory (tmpfs).
db-test:
image: postgres:16-alpine
container_name: taskflow-db-test
environment:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow_test
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql/data
adminer:
image: adminer
ports:
- "8081:8080"
depends_on:
- db
redis:
image: redis:7-alpine
profiles: [queue]
ports:
- "6379:6379"
app:
build: .
profiles: [app]
environment:
NODE_ENV: production
PORT: 3000
DB_HOST: db
DB_PORT: 5432
DB_USER: taskflow
DB_PASSWORD: taskflow
DB_NAME: taskflow
# Empty unless exported: the app then refuses to start (validateEnv), while
# the other services still work without it.
JWT_SECRET: ${JWT_SECRET:-}
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
volumes:
pgdata:| 服务 | 作用 | 从本机访问 |
|---|---|---|
db | 开发用的 PostgreSQL 16,数据存在 pgdata 卷里 | localhost:5432 |
db-test | 端到端测试用的一次性数据库,数据放在内存里(tmpfs) | localhost:5433 |
adminer | PostgreSQL 的 Web 管理界面 | http://localhost:8081 |
redis | BullMQ 队列,profile queue(J9) | localhost:6379 |
app | 容器化的 API,profile app(J11) | localhost:3000 |
docker compose up -d # 启动 db、db-test 和 adminer(各 profile 里的服务不启动)
docker compose ps # 等 db 变成 "healthy"
docker compose logs -f db
docker compose down # 停止容器,保留 pgdata 卷在 Adminer 里选择 PostgreSQL 系统,服务器填 db,不是 localhost:Adminer 跑在容器里,通过 Compose 网络 访问数据库。用户名、密码和数据库名都是 taskflow。你在本机用 npm run start:dev 启动的 Nest 应用,则通过 localhost:5432 访问同一个数据库。
这些凭据必须和环境变量文件里的一致。复制纳入版本管理的模板:
cp .env.example .env.development# Copy to .env.development (npm run start:dev) and .env.test (npm run test:e2e).
# Only this example file is versioned.
NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_USER=taskflow
DB_PASSWORD=taskflow
DB_NAME=taskflow
JWT_SECRET=change-me
JWT_EXPIRES_IN=15m
# typeorm (PostgreSQL) or memory (the J2-J3 in-memory store)
PROJECTS_STORE=typeorm
# Set to enable the BullMQ notifications queue (docker compose --profile queue up -d)
# REDIS_URL=redis://localhost:6379
# Comma-separated origins allowed by CORS; unset = no cross-origin access
# CORS_ORIGIN=http://localhost:5173这些变量的加载和校验是 J7 的主题;Compose profile 和变量汇总在 Docker Compose 与 .env。Spring Boot 3.1 及以上可以在应用启动时自动拉起 Compose 文件(spring-boot-docker-compose 模块);这里需要你自己启动。
GET /health
今天的交付物是一个返回 { status: 'ok' } 的接口,供负载均衡器、编排器和 Docker HEALTHCHECK 使用。最简版本:
// TaskFlow 之外的示例:J1 版本,J11 会换成 Terminus
import { Controller, Get, Module } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok' };
}
}
@Module({ controllers: [HealthController] })
export class HealthModule {}把 HealthModule 加进 AppModule 的 imports(或者先 nest g module health 再 nest g controller health 生成),然后:
curl http://localhost:3000/health{ "status": "ok" }TaskFlow 的最终版本(J11)
到了 J11,控制器改用 @nestjs/terminus:/health/live(liveness,不依赖任何外部服务)和 /health 或 /health/ready(readiness:数据库、内存、磁盘)。响应保留 status: "ok",并附上每个指标的详情。
// /actuator/health/liveness and /readiness. VERSION_NEUTRAL keeps them at
// /health/... instead of /v1/health/... for load balancers and Kubernetes.
@ApiTags('health')
@Public()
@SkipThrottle()
@Controller({ path: 'health', version: VERSION_NEUTRAL })
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
private readonly memory: MemoryHealthIndicator,
private readonly disk: DiskHealthIndicator,
) {}
/** Liveness: the process answers. No dependency, or a DB outage restarts every pod. */
@Get('live')
@HealthCheck()
live() {
return this.health.check([]);
}
/** Readiness: ready to receive traffic. */
@Get(['', 'ready'])
@HealthCheck()
ready() {
return this.health.check([
() => this.db.pingCheck('database', { timeout: 1_500 }),
() => this.memory.checkHeap('memory_heap', 512 * 1024 * 1024),
() => this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
]);
}
}启用 /v1 版本后,VERSION_NEUTRAL 让 URL 保持为 /health,@Public() 让它免于 JWT 校验,@SkipThrottle() 让它不受限流。完整说明见 J11。
git 仓库、README 与 LEARNINGS
nest new 已经执行了 git init 并写好了 .gitignore。它忽略 .env 和 .env.*.local 文件,但不忽略 .env.development 和 .env.test,而计划并不把这两个文件纳入版本管理。加上 .env*,再加上 !.env.example (取反规则必须写在后面)。TaskFlow 在 examples/ 下有自己的 .gitignore,不依赖本站仓库的那一份:
node_modules/
dist/
coverage/
*.log
*.tsbuildinfo
.env*
!.env.example然后给仓库补上:
README.md:怎么启动(Compose、.env.development、npm run start:dev)以及有哪些脚本。可以参考 TaskFlow 的 README。LEARNINGS.md:每天收尾时写的日志(和 Spring 的不同、遇到的陷阱、尚未解决的问题)。TaskFlow 里有它的模板; 方法见学习计划。
git add .
git commit -m "feat: TaskFlow 骨架、Docker Compose 和 /health"
git tag jour-01练习:/block 对比 /ping
计划里的这个练习保留在 TaskFlow 的 /demo 下:
// 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 };
}
}启动应用(docker compose up -d,准备好 .env.development,npm run start:dev),然后开两个终端:
# 终端 1:占用 JavaScript 线程 5 秒
curl http://localhost:3000/demo/block
# 终端 2,紧接着执行:测量 /ping 的响应时间
curl -w '\n%{time_total} s\n' http://localhost:3000/demo/ping把 /demo/block 换成 /demo/sleep 再试一次。在 TaskFlow 上测得的结果:
| 期间 | /demo/ping 的响应时间 | 原因 |
|---|---|---|
/demo/block | 4.7 s | while 循环占住了唯一的线程;/ping 要等循环结束(即它发出之后循环还剩下的时间) |
/demo/sleep | ~1 ms | await sleep(5_000) 把控制权交还事件循环,事件循环立刻处理 /ping |
如果是 Spring MVC,两种情况下 /ping 都会立即响应,由线程池里的另一个线程处理。在这里,唯一的保护就是永远 不要阻塞线程:I/O 用 await,计算用 worker_threads 或队列。
控制器里还有几个细节,后面会讲:@Public() 免除 JWT 校验(J8),@SkipThrottle() 免除限流(J11),VERSION_NEUTRAL 让这些 URL 不带 /v1 前缀。AppModule 只在非生产环境加载这个模块,方法是 ConditionalModule.registerWhen(DemoModule, (env) => env.NODE_ENV !== 'production')。
场景速查
- 一个慢请求拖慢了所有请求 → 在处理函数里找同步代码:循环、
*SyncAPI、大的JSON.parse(事件循环、练习)。 - 要等两个互不依赖的调用 → 用
await Promise.all([...]),不要连写两个await(async/await)。 - 错误不见了,或者写入发生在响应之后 → 少了一个
await;跑一下 lint(no-floating-promises) (async/await)。 Cannot use import statement outside a module或require is not defined→ 检查package.json的type字段和编译器的输出格式(模块)。- 新建一个 Nest 服务 → 如果用 Jest 测试,
nest new时选 CommonJS(生成项目)。 - 添加模块、控制器或 service →
nest g mo|co|s <名称>,它会自动声明到正确的模块里(CLI)。 - 需要本地 PostgreSQL 和一个查看数据的工具 →
docker compose up -d,Adminer 在 8081 端口,服务器填db(Compose)。 - 负载均衡器或 Docker 需要知道 API 是否在响应 → 一个不参与版本的
/health接口 (health、J11)。
陷阱
阻塞事件循环
处理函数里的任何同步计算都会阻塞所有请求:CPU 循环、fs.readFileSync、crypto.pbkdf2Sync、序列化一个超大 对象。使用 API 的异步版本,把重计算挪到 worker_threads 或队列里(J9)。
没有被等待的 Promise
this.repository.save(dto); 没有 await:响应在写入前就发出,可能出现的错误没人看到,而未处理的被拒绝 Promise 会让进程退出(Node 15+)。让 no-floating-promises 规则保持 error 级别,有意不等待的地方用 void 标出。
docker compose down -v 会清空数据库
-v 选项会连卷一起删除,也就是 pgdata 和所有开发数据。只用 docker compose down 则会保留它们。
array.forEach(async (item) => { await save(item); })什么都不会等:forEach忽略返回的 Promise。串行用for (const item of array) { await save(item); },并发用await Promise.all(array.map((item) => save(item)))。setTimeout(fn, 0)并不是“立即执行”:回调要等同步代码和微任务都跑完,任何阻塞的循环都会让它相应推迟。- 生成的
.gitignore忽略了.env,却没有忽略.env.development和.env.test:在第一次提交包含密钥的文件 之前,加上.env*和!.env.example。 - 在 Adminer 里,服务器
localhost指的是 Adminer 容器自己:要填db。 nest new默认推荐 ESM 配 Vitest。如果按计划用 Jest,就选 CommonJS;否则相对导入都得带.js扩展名, Jest 的示例也不能直接照搬。
练习
- [ ]
npm i -g @nestjs/cli,然后nest new taskflow(CommonJS、npm)。 - [ ] 阅读
main.ts、app.module.ts、app.controller.ts、app.service.ts,记下每个文件在 Spring 里的对应物 (SpringApplication.run↔NestFactory.create)。 - [ ] 编写
docker-compose.yml(PostgreSQL + Adminer),docker compose up -d,在 Adminer 里连上数据库。 - [ ] 暴露返回
{ status: 'ok' }的GET /health,用curl验证。 - [ ] 完善 git 仓库:
.gitignore(含.env*和!.env.example)、README.md、LEARNINGS.md,第一次提交,git tag jour-01。 - [ ] 理解练习:一个同步循环 5 秒的
/block接口和一个/ping接口。观察/ping被阻塞,再把循环换成await sleep(5000),比较响应时间。 - [ ] 运行事件循环一节里的
setTimeout/Promise/nextTick脚本,先存成.cjs再存成.mjs,解释两次顺序为什么不同。 - [ ] 写一个用
Promise.all发出两个调用的函数,再写一个连续两个await的版本,比较耗时。 - [ ] 在
LEARNINGS.md里记下和 Spring MVC“每个请求一个线程”的模型相比,有哪些变化。