Skip to content

J1 · Node 与 Nest 基础 ​

第 1 天 · 11 月 9 日周一 · 交付物:项目骨架、Docker Compose、第一个接口

写 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 ​

SpringNestJS / Node要点
SpringApplication.run(App.class, args)NestFactory.create(AppModule),再 app.listen(port)分两步:先构建容器,再监听端口
@SpringBootApplication 类AppModule(@Module)模块图的根,没有 component scan
Tomcat 线程池(每个请求一个线程)一个 JavaScript 线程加事件循环一段同步计算会阻塞所有请求
CompletableFuture、MonoPromise、async/awaitawait 挂起的是函数,不是线程
Thread.sleep(5000)await sleep(5000)(node:timers/promises)等待期间线程空出来,可以服务其他请求
ExecutorService.invokeAll()Promise.all()并发要用 Promise 来思考,而不是线程
@Async没有对应物:async 不会切换线程async 函数在遇到第一个 await 之前一直在同一个线程上运行
Spring Initializrnest new生成项目、安装依赖、初始化 git
Maven / Gradle、pom.xmlnpm、package.json + package-lock.jsonnpm ci 严格按 lockfile 安装
mvn spring-boot:run + devtoolsnpm run start:dev(nest start --watch)每次修改都重新编译并重启
/actuator/healthGET /health(J11 改用 Terminus)不参与版本:没有 /v1 前缀

原理 ​

事件循环、微任务与宏任务 ​

Node 在单个线程上执行你的 JavaScript 代码。I/O(socket、SQL 查询、文件)交给操作系统或 libuv(Node 的 C 库)处理。某个操作完成后,它的回调被放进队列。事件循环就是这样一个循环:从队列里逐个取出回调,在这唯一的 线程上执行。

每一轮循环会依次经过几个阶段,每个阶段有自己的队列:

阶段在这里执行什么
timers已到期的 setTimeout 和 setInterval 回调
pending callbacks推迟到下一轮的某些系统错误回调
idle, prepareNode 内部使用
pollI/O 回调(socket 可读、收到 PostgreSQL 的响应);没有别的事可做时,循环就停在这里等待
checksetImmediate 的回调
close callbacks关闭事件,比如 socket.on('close')

这些回调都是宏任务(macrotask)。每执行完一个宏任务,Node 都会把微任务(microtask)队列彻底清空: 先是 process.nextTick,再是 Promise 的后续(.then、await 之后的代码)。所以微任务总是先于下一个定时器 或下一个 I/O 执行。

ts
// 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 请求不行,定时器也不行。

ts
// 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。几个请求同时发出,等待期间线程是空闲的。
ts
// 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:

ts
// 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 保留了这份配置:

json
{
  "$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.exportsimport、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,会生成:

text
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 里已经没有它们了,取而代之的是按领域划分的模块:

ts
// 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 的入口:

ts
// 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();
  1. NestFactory.create(AppModule) 从根模块出发,遍历它的 imports,解析依赖,在 HTTP 适配器(Express 5) 之上实例化 provider 和控制器。它和 SpringApplication.run 一样会构建容器;但和后者不同,它不监听端口。
  2. setupApp(app) 和 setupSwagger(app) 应用那些不属于 provider 的 HTTP 设置:logger、helmet、CORS、 版本(J2)、接口文档(J11)。 bufferLogs: true 会先缓存启动日志,等 pino logger 接上后再输出(J7)。
  3. app.listen(port) 在应用尚未初始化时先完成初始化(注册路由,执行 onModuleInit 和 onApplicationBootstrap 钩子,见 J7),然后监听端口。端口来自 校验过的配置;生成的版本读取的是 process.env.PORT ?? 3000。
  4. void bootstrap(); 显式地启动这个 Promise 而不等待它。CommonJS 不支持模块顶层 await,所以才要包一个函数。

对于没有 HTTP 服务器的脚本(批处理、worker),NestFactory.createApplicationContext(AppModule) 只构建容器, 不带 HTTP 适配器,相当于非 Web 的 Spring Boot 应用。

骨架之外,TaskFlow 按领域组织代码(projects/、tasks/、users/ 等),另有横切目录(common/、config/、 database/):见目录结构和命名约定。

Nest CLI ​

CLI 负责生成项目和各个组件,并把它们声明到正确的模块里:

bash
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 案例 ​

生成项目 ​

bash
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:

yaml
# 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
adminerPostgreSQL 的 Web 管理界面http://localhost:8081
redisBullMQ 队列,profile queue(J9)localhost:6379
app容器化的 API,profile app(J11)localhost:3000
bash
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 访问同一个数据库。

这些凭据必须和环境变量文件里的一致。复制纳入版本管理的模板:

bash
cp .env.example .env.development
dotenv
# 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 使用。最简版本:

ts
// 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 生成),然后:

bash
curl http://localhost:3000/health
json
{ "status": "ok" }
TaskFlow 的最终版本(J11)

到了 J11,控制器改用 @nestjs/terminus:/health/live(liveness,不依赖任何外部服务)和 /health 或 /health/ready(readiness:数据库、内存、磁盘)。响应保留 status: "ok",并附上每个指标的详情。

ts
// /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,不依赖本站仓库的那一份:

text
node_modules/
dist/
coverage/
*.log
*.tsbuildinfo
.env*
!.env.example

然后给仓库补上:

  • README.md:怎么启动(Compose、.env.development、npm run start:dev)以及有哪些脚本。可以参考 TaskFlow 的 README。
  • LEARNINGS.md:每天收尾时写的日志(和 Spring 的不同、遇到的陷阱、尚未解决的问题)。TaskFlow 里有它的模板; 方法见学习计划。
bash
git add .
git commit -m "feat: TaskFlow 骨架、Docker Compose 和 /health"
git tag jour-01

练习:/block 对比 /ping ​

计划里的这个练习保留在 TaskFlow 的 /demo 下:

ts
// 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),然后开两个终端:

bash
# 终端 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/block4.7 swhile 循环占住了唯一的线程;/ping 要等循环结束(即它发出之后循环还剩下的时间)
/demo/sleep~1 msawait 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')。

场景速查 ​

  • 一个慢请求拖慢了所有请求 → 在处理函数里找同步代码:循环、*Sync API、大的 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“每个请求一个线程”的模型相比,有哪些变化。

延伸阅读 ​