NestJS 生产级开发教程

面向 中级 → 高级 工程师的生产级开发手册。每章附 原理章节 (💡)和 踩坑提醒(⚠️)。

本文档由 11 个子主题拼接而成,可一口气读完,也可按下面目录跳读。


总目录

  1. [00 - 架构总览与技术选型](#00 - 架构总览与技术选型 "#00---%E6%9E%B6%E6%9E%84%E6%80%BB%E8%A7%88%E4%B8%8E%E6%8A%80%E6%9C%AF%E9%80%89%E5%9E%8B")
  2. [01 - 脚手架与工程化](#01 - 脚手架与工程化 "#01---%E8%84%9A%E6%89%8B%E6%9E%B6%E4%B8%8E%E5%B7%A5%E7%A8%8B%E5%8C%96")
  3. [02 - NestJS 核心:模块 / DI / 生命周期 / 管道 / 守卫 / 拦截器](#02 - NestJS 核心:模块 / DI / 生命周期 / 管道 / 守卫 / 拦截器 "#02---nestjs-%E6%A0%B8%E5%BF%83%E6%A8%A1%E5%9D%97-di-%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F-%E7%AE%A1%E9%81%93-%E5%AE%88%E5%8D%AB-%E6%8B%A6%E6%88%AA%E5%99%A8")
  4. [03 - 数据层:Prisma + PostgreSQL + Redis](#03 - 数据层:Prisma + PostgreSQL + Redis "#03---%E6%95%B0%E6%8D%AE%E5%B1%82prisma-postgresql-redis")
  5. [04 - 认证授权:JWT + Refresh + RBAC/CASL + 安全加固](#04 - 认证授权:JWT + Refresh + RBAC/CASL + 安全加固 "#04---%E8%AE%A4%E8%AF%81%E6%8E%88%E6%9D%83jwt-refresh-rbaccasl-%E5%AE%89%E5%85%A8%E5%8A%A0%E5%9B%BA")
  6. [05 - API 设计:DTO / 校验 / 版本 / 错误模型 / 幂等 / 限流](#05 - API 设计:DTO / 校验 / 版本 / 错误模型 / 幂等 / 限流 "#05---api-%E8%AE%BE%E8%AE%A1dto-%E6%A0%A1%E9%AA%8C-%E7%89%88%E6%9C%AC-%E9%94%99%E8%AF%AF%E6%A8%A1%E5%9E%8B-%E5%B9%82%E7%AD%89-%E9%99%90%E6%B5%81")
  7. [06 - 异步与可靠性:BullMQ / 事件 / Outbox / Saga](#06 - 异步与可靠性:BullMQ / 事件 / Outbox / Saga "#06---%E5%BC%82%E6%AD%A5%E4%B8%8E%E5%8F%AF%E9%9D%A0%E6%80%A7bullmq-%E4%BA%8B%E4%BB%B6-outbox-saga")
  8. [07 - 测试金字塔:单元 / 集成 / E2E / 契约](#07 - 测试金字塔:单元 / 集成 / E2E / 契约 "#07---%E6%B5%8B%E8%AF%95%E9%87%91%E5%AD%97%E5%A1%94%E5%8D%95%E5%85%83-%E9%9B%86%E6%88%90-e2e-%E5%A5%91%E7%BA%A6")
  9. [08 - 可观测性:Pino + OpenTelemetry + Prometheus + Sentry](#08 - 可观测性:Pino + OpenTelemetry + Prometheus + Sentry "#08---%E5%8F%AF%E8%A7%82%E6%B5%8B%E6%80%A7pino-opentelemetry-prometheus-sentry")
  10. [09 - DevOps:Docker / GitHub Actions / Kubernetes / 灰度发布](#09 - DevOps:Docker / GitHub Actions / Kubernetes / 灰度发布 "#09---devopsdocker-github-actions-kubernetes-%E7%81%B0%E5%BA%A6%E5%8F%91%E5%B8%83")
  11. [10 - 安全加固与上线 Checklist](#10 - 安全加固与上线 Checklist "#10---%E5%AE%89%E5%85%A8%E5%8A%A0%E5%9B%BA%E4%B8%8E%E4%B8%8A%E7%BA%BF-checklist")

附录(高级进阶)

  1. [A1 - 复杂业务建模(DDD 实战)](#A1 - 复杂业务建模(DDD 实战) "#a1---%E5%A4%8D%E6%9D%82%E4%B8%9A%E5%8A%A1%E5%BB%BA%E6%A8%A1ddd-%E5%AE%9E%E6%88%98")
  2. [A2 - 性能调优诊断手艺](#A2 - 性能调优诊断手艺 "#a2---%E6%80%A7%E8%83%BD%E8%B0%83%E4%BC%98%E8%AF%8A%E6%96%AD%E6%89%8B%E8%89%BA")
  3. [A3 - 经典事故 10 例](#A3 - 经典事故 10 例 "#a3---%E7%BB%8F%E5%85%B8%E4%BA%8B%E6%95%85-10-%E4%BE%8B")
  4. [A4 - 架构演进与大规模](#A4 - 架构演进与大规模 "#a4---%E6%9E%B6%E6%9E%84%E6%BC%94%E8%BF%9B%E4%B8%8E%E5%A4%A7%E8%A7%84%E6%A8%A1")
  5. [A5 - 工程师软实力](#A5 - 工程师软实力 "#a5---%E5%B7%A5%E7%A8%8B%E5%B8%88%E8%BD%AF%E5%AE%9E%E5%8A%9B")

00 - 架构总览与技术选型

目标:在写第一行代码之前,搞清楚我们要造什么、为什么这么造、有哪些 不可逆决策

1. 服务全景图

scss 复制代码
                          ┌──────────────────────┐
   客户端 (Web/移动)  ───►│   Ingress / Gateway  │
                          │   (Nginx / ALB)      │
                          └──────────┬───────────┘
                                     │  HTTPS
                          ┌──────────▼───────────┐
                          │   NestJS API         │
                          │   (Fastify adapter)  │  ← 业务核心
                          └────┬────────┬────────┘
                               │        │
                ┌──────────────▼──┐  ┌──▼─────────────┐
                │  PostgreSQL 16  │  │  Redis 7       │
                │  - 主库 + 只读  │  │  - 缓存        │
                │    副本         │  │  - BullMQ 队列 │
                └─────────────────┘  │  - 分布式锁    │
                                     └────────────────┘
                          ┌──────────────────────┐
                          │   BullMQ Worker      │  ← 独立部署
                          │   (与 API 同代码库)  │
                          └──────────────────────┘
                          ┌──────────────────────┐
                          │  S3 兼容对象存储     │
                          └──────────────────────┘
                          ┌──────────────────────┐
                          │  可观测性平面         │
                          │  Otel Collector →    │
                          │    Tempo / Loki /     │
                          │    Prometheus / Sentry│
                          └──────────────────────┘

关键边界

  1. NestJS API 是唯一可信源。所有写操作必须经它,客户端不直接连数据库。
  2. PostgreSQL 是事实之源 。Redis 是加速层,Redis 中的数据 任何时候都可被丢弃且不影响正确性
  3. API 与 Worker 进程分离,共用代码库但启动入口不同。Worker 不绑 HTTP 端口,可独立扩缩容。

💡 原理:为什么 API 与 Worker 必须分进程?

  1. 资源画像不同:API 多内存少 CPU,Worker 多 CPU/IO。2) 故障隔离:Worker 崩溃不影响 API。3) 扩缩独立:队列堆积时只加 Worker 即可。4) Worker 优雅退出需要更长 grace period,与 API 不同。

2. 不可逆决策清单

下面这些决定如果做错,改起来代价极大,务必现在就想清楚:

决策 推荐 不可逆原因
数据库类型 PostgreSQL 数据迁移成本是几周到几月
主键策略 ULID / UUIDv7(不要 auto-increment) 改主键 = 改所有外键 + 所有 API
时间存储 timestamptz 存 UTC 时区混乱后排查极痛苦
多租户隔离方案 行级(tenant_id + RLS) 改成 schema/库级要重写权限层
软删除 vs 硬删除 审计表 + 硬删除(默认) 软删除会污染所有查询
货币存储 numeric(18,4) 或最小单位整数(分) float 误差就回不来了
API 风格 REST + 资源化 URL 后期可叠加 GraphQL / tRPC
错误响应模型 RFC 7807 application/problem+json 客户端代码会绑死你的错误格式
Node 运行时 Node 20 LTS 影响所有底层 API

⚠️ 踩坑:auto-increment 主键的代价 见过一个团队上线 8 个月后发现要分库,所有表都是自增 ID,做分片要重写所有外键关联和 API。ULID/UUIDv7 自带时间序,索引友好,且全局唯一,几乎没有任何缺点。
💡 原理:为什么是 UUIDv7 而不是 v4? v4 完全随机 → 插入时主键在 B-tree 上随机落点 → 频繁页分裂、写放大严重。v7 高位是时间戳,顺序近似单调递增,行为接近自增 ID 的写性能,同时保留全局唯一。Postgres 16 已可用 pg_uuidv7 扩展;或在应用层用 uuidv7 npm 包生成。

3. 分层与依赖方向

我们采用 轻量分层(不是 DDD,但吸收了 DDD 的"依赖向内"思想):

scss 复制代码
   ┌──────────────────────────────────────────────┐
   │  Controllers / Resolvers / Listeners         │  ← I/O 适配层
   ├──────────────────────────────────────────────┤
   │  Application Services (用例编排, 事务边界)  │  ← 业务用例
   ├──────────────────────────────────────────────┤
   │  Domain  (实体, 值对象, 领域服务)            │  ← 纯业务规则
   ├──────────────────────────────────────────────┤
   │  Infrastructure (Prisma, Redis, S3, Email)   │  ← 副作用实现
   └──────────────────────────────────────────────┘

依赖方向:向下/向内。Domain 层不能 import Prisma;Service 层不能 import HTTP 相关代码。

💡 原理:为什么不是 Clean Architecture 全套? 全套 Clean(Entities/Use Cases/Interface Adapters/Frameworks)对中小项目过度工程。你会写出一堆 mapper 和 interface,只为了"理论上可换 ORM"。真实世界你 5 年都不会换 ORM。本教程保留依赖方向纪律,但允许 Service 层直接用 Prisma 类型 ------ 实用主义。

4. 多租户策略(以行级隔离为基线)

所有租户共用一套库一套 schema ,每张业务表带 tenant_id,用 PostgreSQL Row-Level Security 强制隔离。

sql 复制代码
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

应用层在每个事务开始时:

typescript 复制代码
await prisma.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;

💡 原理:RLS 是"最后一道防线" 你的代码层一定会写 where: { tenantId },但人会犯错。RLS 在数据库层兜底:就算应用代码漏写了租户过滤,数据库也不会返回别人的数据。代价是每个连接要 set_config,对连接池(尤其 PgBouncer transaction 模式)有要求 ------ 03 章会详细处理。
⚠️ 不要在第一天就上 schema 级或 DB 级隔离。等单租户数据真的撑爆共享库时(通常是单租户 > 100GB 或有合规要求时)再切。提前隔离 = 提前承担运维复杂度。

5. 部署拓扑(生产 baseline)

ini 复制代码
┌────────────────────────────────────────────────────────────┐
│ K8s 集群(任一云厂托管)                                    │
│                                                            │
│   ┌─────────────┐    ┌─────────────┐    ┌──────────────┐  │
│   │ NestJS API  │    │ BullMQ      │    │ Cron / 一次性│  │
│   │ Deployment  │    │ Worker      │    │ Job          │  │
│   │ replicas=3+ │    │ Deployment  │    │              │  │
│   └─────────────┘    │ replicas=2+ │    └──────────────┘  │
│         │            └─────────────┘                       │
│         └──────────────┬──────────────────┐                │
│                        │                  │                │
│              ┌─────────▼──────────┐                        │
│              │  Service / Ingress │                        │
│              └────────────────────┘                        │
└────────────────────────────────────────────────────────────┘
        │                                       │
        ▼                                       ▼
┌──────────────────┐                  ┌──────────────────────┐
│ RDS / CloudSQL   │                  │ ElastiCache / Upstash│
│  Postgres 主+RO  │                  │  Redis cluster       │
└──────────────────┘                  └──────────────────────┘

要点:

  • Worker 至少 2 副本,任何"只能起一份"的任务用分布式锁(Redis SETNX with TTL)解决
  • Deployment 用 rolling update ,maxUnavailable: 0maxSurge: 1,配合 readiness probe 实现零停机
  • 滚动 + 蓝绿 + 灰度 三档:常规改动滚动;Schema 不兼容用蓝绿;高风险特性用灰度。09 章细讲

6. 性能预算(从设计阶段定)

指标 目标
API p50 / p95 / p99 延迟 50ms / 200ms / 500ms
单接口 DB 查询数 ≤ 3(超过就要警惕 N+1)
单接口外部调用 0(写)/ ≤ 2 (读, 必须并行)
内存基线(API 容器) < 400 MB / 副本
CPU 利用率(峰值期) < 70%(留 30% 余量)

💡 原理:为什么 p99 也要定? p50 好看不代表用户体验好。你的 1% 慢请求往往是最有钱的客户(数据量大、关联多)。给 p99 定预算 = 强制你处理长尾。

7. 安全基线(在第 10 章细讲,这里先列)

  • 所有出站默认 HTTPS;内网服务间通信用 mTLS 或 service mesh
  • Secret 一律从环境变量或 secret manager 读,永远不进 git
  • 输入校验在 API 边界(Zod / class-validator) 必做;不要相信前端
  • 所有写操作要求认证;读操作显式标注公开
  • 依赖每周扫一次(pnpm audit + GitHub Dependabot + Snyk)
  • 数据库连接用专用账号,绝不用 superuser

8. 你将得到什么

完整跟完这套教程后,你将有一份生产级 NestJS 模板,具备:

  • ✅ 类型安全:Zod schema + 严格 TS,出错即 fail-fast
  • ✅ 可观测:每个请求都有 trace,日志、metrics、错误关联到 trace
  • ✅ 可演进:加新模块/新表/新队列任务都有标准流程
  • ✅ 可上线:CI 跑过即可部署,失败可秒级回滚
  • ✅ 可调试:本地能复现 90% 的生产问题(剩下 10% 用 trace + log 排查)

延伸阅读


01 - 脚手架与工程化

目标:从零搭出一个 可直接进入业务开发 的 NestJS 仓库,跑通 lint、type-check、test、本地 docker-compose、API 启动。

1. 单仓 vs Monorepo

单仓(本教程默认) Monorepo(多 app)
复杂度
共享代码 不需要 用 pnpm workspaces
部署/CI 简单 需要 turbo / nx 切片
适用 只一个后端服务 服务 + Worker + 共享包 + 多服务

本教程 :即使是单 NestJS,我们仍用 pnpm workspaces 来组织 apps/api(主服务)和 apps/worker(独立部署的 BullMQ 消费者)。两者共用业务代码,但运行入口不同。

💡 pnpm 为什么是必选 npm/yarn 的 hoisting 会把所有依赖提升到顶层 node_modules,导致"声明里没写但能 import"的幽灵依赖。pnpm 的隔离式 store 杜绝幽灵依赖,且磁盘空间省一个数量级。

2. 目录结构

bash 复制代码
my-api/
├── apps/
│   ├── api/                  # NestJS HTTP 服务
│   └── worker/               # BullMQ worker(共享 apps/api 的业务代码)
├── packages/
│   ├── config/               # tsconfig / eslint / prettier 基础配置
│   └── shared/               # Zod schemas、错误码、常量
├── infra/
│   ├── docker/               # Dockerfile、docker-compose
│   ├── k8s/                  # Helm / kustomize
│   └── scripts/              # 运维脚本
├── .github/workflows/
├── .editorconfig
├── .gitignore
├── .nvmrc
├── .npmrc
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.base.json
└── README.md

apps/worker 通常 import apps/api/src/modules/... 的业务代码并启动一个 NestFactory.createApplicationContext,不绑 HTTP 端口。详见 06 章。

⚠️ 不要把 infra 塞到 packages/packages/* 是 npm 包语义,而 K8s 配置不是 npm 包。

3. 初始化

3.1 锁定运行时

bash 复制代码
echo "20.11.1" > .nvmrc
echo "auto-install-peers=true
shamefully-hoist=false
strict-peer-dependencies=true
node-linker=isolated" > .npmrc

package.json:

json 复制代码
{
  "name": "my-api",
  "private": true,
  "engines": {
    "node": ">=20.10",
    "pnpm": ">=8.15"
  },
  "packageManager": "pnpm@8.15.4",
  "scripts": {
    "build": "pnpm -r --filter './apps/**' build",
    "lint": "pnpm -r lint",
    "typecheck": "pnpm -r typecheck",
    "test": "pnpm -r test",
    "dev:deps": "docker compose -f infra/docker/docker-compose.dev.yml up -d",
    "dev:deps:down": "docker compose -f infra/docker/docker-compose.dev.yml down",
    "dev:api": "pnpm --filter @my-app/api dev",
    "dev:worker": "pnpm --filter @my-app/worker dev"
  },
  "devDependencies": {
    "typescript": "^5.4.3",
    "@types/node": "^20.11.30",
    "prettier": "^3.2.5"
  }
}

pnpm-workspace.yaml:

yaml 复制代码
packages:
  - "apps/*"
  - "packages/*"

💡 packageManager 字段的作用 配合 Corepack 可以让任何人 clone 后自动用正确的 pnpm 版本,不需要全局安装。CI 里也只要 corepack enable 一行。

4. TS / ESLint / Prettier 基线

4.1 共享 TS 配置

packages/config/typescript/base.json:

json 复制代码
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "ESNext",
    "moduleResolution": "Bundler",

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "useUnknownInCatchVariables": true,
    "exactOptionalPropertyTypes": true,

    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "resolveJsonModule": true
  }
}

💡 noUncheckedIndexedAccess 是高级工程师的护身符 默认 arr[0] 类型是 T,但运行时可能是 undefined。开启后类型变 T | undefined,强制处理。线上 50% 的"undefined is not a function"都是这条规则能挡住的

NestJS 专用 packages/config/typescript/nest.json:

json 复制代码
{
  "extends": "./base.json",
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "Node",
    "target": "ES2022",
    "outDir": "dist",
    "rootDir": "src",
    "declaration": false,
    "sourceMap": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
  }
}

⚠️ NestJS 必须用 CommonJS + emitDecoratorMetadata NestJS 的 DI 依赖 reflect-metadata 在编译期写入参数类型元数据,只在 experimentalDecorators + emitDecoratorMetadata 下工作。Stage-3 装饰器目前不兼容 Nest;ESM 出口要看 Nest 11+。当前 Nest 10:老老实实用 CJS

4.2 ESLint(Flat Config)

eslint.config.mjs(根):

javascript 复制代码
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
import importPlugin from "eslint-plugin-import";

export default tseslint.config(
  ...tseslint.configs.strictTypeChecked,
  ...tseslint.configs.stylisticTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    plugins: { import: importPlugin },
    rules: {
      "@typescript-eslint/consistent-type-imports": "error",
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/no-misused-promises": "error",
      "@typescript-eslint/require-await": "error",
      "import/order": ["error", {
        groups: ["builtin", "external", "internal", "parent", "sibling"],
        "newlines-between": "always",
        alphabetize: { order: "asc" }
      }]
    },
  },
  {
    ignores: ["**/dist/**", "**/node_modules/**"]
  },
  eslintConfigPrettier
);

💡 no-floating-promises 是必开 没 await 的 Promise 拒绝时是 unhandled rejection,Node 18+ 默认终止进程。生产事故里有相当一部分是"忘了 await"。这条规则在编译期就能挡住。

4.3 Prettier + EditorConfig

.prettierrc.json:

json 复制代码
{
  "semi": true,
  "singleQuote": false,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2
}

.editorconfig:

ini 复制代码
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

5. 共享 schema:packages/shared

Zod schema、错误码、常量。

packages/shared/package.json:

json 复制代码
{
  "name": "@my-app/shared",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.ts",
    "./schemas": "./src/schemas/index.ts",
    "./errors": "./src/errors/index.ts"
  },
  "dependencies": {
    "zod": "^3.22.4"
  }
}

示例:

typescript 复制代码
// packages/shared/src/schemas/user.ts
import { z } from "zod";

export const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(80),
  password: z.string().min(12).max(200),
});

export type CreateUserInput = z.infer<typeof CreateUserSchema>;

💡 为什么独立 shared 包? 即便只有后端,把 schema 抽出 packages/shared 也有价值:1) 单测可独立运行,不需启动 Nest;2) 未来给客户端 SDK 复用;3) Worker 与 API 共用 schema 时不会重复。

6. NestJS 应用初始化

bash 复制代码
pnpm dlx @nestjs/cli new apps/api --skip-git --skip-install --strict

apps/api/package.json:

json 复制代码
{
  "name": "@my-app/api",
  "private": true,
  "scripts": {
    "dev": "nest start --watch",
    "build": "nest build",
    "start:prod": "node dist/main.js",
    "lint": "eslint . --max-warnings=0",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:e2e": "vitest run -c vitest.e2e.config.ts"
  },
  "dependencies": {
    "@nestjs/common": "^10.3.0",
    "@nestjs/core": "^10.3.0",
    "@nestjs/platform-fastify": "^10.3.0",
    "@nestjs/config": "^3.2.0",
    "@my-app/shared": "workspace:*",
    "reflect-metadata": "^0.2.0",
    "rxjs": "^7.8.0"
  }
}

替换默认 Express 为 Fastify(apps/api/src/main.ts):

typescript 复制代码
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
import { Logger } from "@nestjs/common";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter({
      trustProxy: true,
      bodyLimit: 1_048_576,
    }),
    { bufferLogs: true },
  );

  app.enableShutdownHooks();

  const port = Number(process.env.PORT ?? 3001);
  await app.listen(port, "0.0.0.0");
  Logger.log(`API listening on :${port}`, "Bootstrap");
}

void bootstrap();

💡 为什么 Fastify 而不是 Express Fastify 吞吐通常比 Express 高 2-3x(JSON schema 序列化、更少中间件开销)。NestJS 抽象层让你切换适配器几乎只改一行。唯一代价 :某些社区中间件(Express 生态)需要找 Fastify 替代或用 @fastify/express。新项目直接 Fastify。
⚠️ enableShutdownHooks() 必开 K8s 滚动更新时会发 SIGTERM,Nest 收到后才会触发 OnModuleDestroy / OnApplicationShutdown。不开会导致正在处理的请求被强杀。

7. Worker 应用

apps/worker/package.json:

json 复制代码
{
  "name": "@my-app/worker",
  "private": true,
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "build": "tsc -p tsconfig.json",
    "start:prod": "node dist/main.js"
  },
  "dependencies": {
    "@nestjs/common": "^10.3.0",
    "@nestjs/core": "^10.3.0",
    "@nestjs/bullmq": "^10.0.0",
    "@my-app/api": "workspace:*"
  }
}

apps/worker/src/main.ts 见 06 章。要点:与 API 共用业务模块,但不绑 HTTP 端口

8. 本地开发依赖

infra/docker/docker-compose.dev.yml:

yaml 复制代码
services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app_dev
    ports: ["5432:5432"]
    volumes:
      - pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app_dev"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    ports: ["6379:6379"]
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  mailhog:
    image: mailhog/mailhog
    ports: ["1025:1025", "8025:8025"]

volumes:
  pg_data:
  redis_data:

⚠️ 不要把 pg_data 卷的清理脚本放在常用快捷键下 。见过同事 npm run reset 一键删除本地两个月的种子数据。给清理命令起个不顺手的名字。

9. 环境变量管理

  • .env.example 入 git,列所有需要的变量名,值用占位符
  • .env 各自维护,绝对不入 git
  • @nestjs/config + Zod 校验
typescript 复制代码
// apps/api/src/config/env.ts
import { z } from "zod";

export const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3001),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_ACCESS_SECRET: z.string().min(32),
  JWT_REFRESH_SECRET: z.string().min(32),
  LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
});

export type Env = z.infer<typeof EnvSchema>;

export const validateEnv = (config: Record<string, unknown>): Env => {
  const parsed = EnvSchema.safeParse(config);
  if (!parsed.success) {
    console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors);
    process.exit(1);
  }
  return parsed.data;
};

AppModule:

typescript 复制代码
ConfigModule.forRoot({
  isGlobal: true,
  validate: validateEnv,
  cache: true,
})

💡 为什么启动就校验环境变量? 让进程在配置错误时 fail fast,而不是跑了半小时被某个请求触发后才 crash。K8s 看到容器起不来就会停止 rollout,把坏配置挡在生产之外。
⚠️ secret 长度校验 :JWT secret 太短就被弱化加密。我们硬性要求 ≥ 32 字符 ------ 强制开发别用 secret 当 secret。

10. Git hooks(husky + lint-staged)

bash 复制代码
pnpm add -Dw husky lint-staged
pnpm exec husky init

.husky/pre-commit:

bash 复制代码
pnpm exec lint-staged

package.json:

json 复制代码
"lint-staged": {
  "*.{ts,js}": ["eslint --fix --max-warnings=0", "prettier --write"],
  "*.{json,md,yml,yaml}": ["prettier --write"]
}

⚠️ 不要在 pre-commit 跑全量 typecheck 那是 CI 的事。pre-commit 必须 < 10s,否则同事会 --no-verify 绕过。Lint 只跑改动文件,typecheck 留给 CI 和编辑器。

11. 验证脚手架

bash 复制代码
pnpm install
pnpm dev:deps                     # postgres/redis/mailhog
pnpm dev:api                      # http://localhost:3001
pnpm typecheck
pnpm lint
pnpm test

应该看到 NestJS 默认 GET / 返回 "Hello World",数据库/Redis 可连通。

12. 提交前检查清单

  • .env.example 完整
  • pnpm install 是 deterministic 的(pnpm-lock.yaml 入 git)
  • package.json 不直接依赖业务包,只放 dev tooling
  • tsconfig.base.json 严格模式全开
  • Dockerfile 占位待补(09 章详写)

13. CI 最小骨架(09 章详写)

.github/workflows/ci.yml:

yaml 复制代码
name: CI
on:
  push: { branches: [main] }
  pull_request:
jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v4
        with: { node-version-file: ".nvmrc" }
      - run: corepack enable
      - uses: actions/cache@v4
        with:
          path: ~/.local/share/pnpm/store
          key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test

延伸阅读


02 - NestJS 核心:模块 / DI / 生命周期 / 管道 / 守卫 / 拦截器

目标:讲透 NestJS 的"七件套",每件都给出生产用法原理。读完你应该能闭眼说出请求经过哪些组件、谁能短路谁、出错时哪里能 catch。

1. 请求生命周期(必须背下来)

scss 复制代码
HTTP Request
   │
   ▼
┌─────────────────────────────────────────────────────┐
│ 1. Middleware (全局/模块级,Express/Fastify 中间件)│  ← 几乎不用
└──────────────────────┬──────────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────────┐
│ 2. Guard  (CanActivate, 鉴权/角色)                  │  ← 决定能否进入
└──────────────────────┬──────────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────────┐
│ 3. Interceptor.before (logging, timing, transform) │  ← 包裹
└──────────────────────┬──────────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────────┐
│ 4. Pipe  (transform & validate)                     │  ← 改/校验入参
└──────────────────────┬──────────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────────┐
│ 5. Controller Handler  →  Service                   │  ← 真正业务
└──────────────────────┬──────────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────────┐
│ 6. Interceptor.after  (map response, cache)         │  ← 包裹
└──────────────────────┬──────────────────────────────┘
                       ▼
┌─────────────────────────────────────────────────────┐
│ 7. Exception Filter  (异常 → 响应)                  │  ← 兜底
└──────────────────────┬──────────────────────────────┘
                       ▼
                  HTTP Response

关键时序记忆 :Guard 在 Interceptor 之前 ;Pipe 在 Interceptor.before 之后;异常无论从哪一步抛出都进入 Filter。

💡 为什么 Guard 在 Interceptor 前面? 设计意图:鉴权先决,日志/监控类拦截器不应该处理一个最终会被拒的请求(它甚至不该进入到日志)。但反过来 ------ 你想给"鉴权失败"也打日志怎么办?答:在 Exception Filter 里打。
⚠️ 认知陷阱 :Pipe 比 Interceptor.before 执行。所以"在拦截器里读 DTO 转换后的数据"行不通,要在 controller 拿。

2. 模块系统

2.1 三种模块的写法

typescript 复制代码
// 1. 静态模块(最常见)
@Module({
  imports: [PrismaModule],
  controllers: [UsersController],
  providers: [UsersService, UserRepository],
  exports: [UsersService],
})
export class UsersModule {}

// 2. 动态模块(配置/复用)
@Module({})
export class StorageModule {
  static forRoot(options: StorageOptions): DynamicModule {
    return {
      module: StorageModule,
      providers: [
        { provide: STORAGE_OPTIONS, useValue: options },
        StorageService,
      ],
      exports: [StorageService],
      global: options.global ?? false,
    };
  }
}

// 3. 异步动态模块(配置依赖其他服务,例如 ConfigService)
@Module({})
export class StorageModule {
  static forRootAsync(options: AsyncOptions): DynamicModule {
    return {
      module: StorageModule,
      imports: options.imports ?? [],
      providers: [
        {
          provide: STORAGE_OPTIONS,
          inject: options.inject ?? [],
          useFactory: options.useFactory,
        },
        StorageService,
      ],
      exports: [StorageService],
    };
  }
}

💡 global: true 慎用 全局模块不需要再 import 即可注入,看起来很方便,但破坏了"模块边界即依赖关系"这一约束。只让真正全局的(Logger / Config)是 global,业务模块永远走显式 import。

2.2 模块组织建议

bash 复制代码
src/
├── main.ts
├── app.module.ts
├── common/                      # 跨模块共用的工具:guards, interceptors, pipes, decorators
│   ├── decorators/
│   ├── filters/
│   ├── guards/
│   ├── interceptors/
│   └── pipes/
├── config/                      # ConfigModule + 环境校验
├── infra/                       # 基础设施模块:Prisma, Redis, Queue, Mailer
│   ├── prisma/
│   ├── redis/
│   └── queue/
└── modules/                     # 业务模块
    ├── auth/
    ├── users/
    ├── billing/
    └── ...

⚠️ 不要按"层"分目录 (controllers/services/dtos/)。一个功能改起来要跳 5 个目录。按 功能(feature)分目录,内部再按层。

3. 依赖注入(DI)

3.1 五种 provider 写法

typescript 复制代码
// useClass ------ 最常见
{ provide: UsersService, useClass: UsersService }
// 等价简写:在 providers 数组里直接写 UsersService

// useValue ------ 常量/mock
{ provide: APP_CONFIG, useValue: { foo: "bar" } }

// useFactory ------ 异步初始化、依赖其他服务
{
  provide: DATABASE_CONNECTION,
  inject: [ConfigService],
  useFactory: async (config: ConfigService) => {
    return await createConnection(config.get("DATABASE_URL"));
  },
}

// useExisting ------ 起别名
{ provide: ILogger, useExisting: Logger }

// useClass + scope
{ provide: HttpClient, useClass: HttpClient, scope: Scope.REQUEST }

3.2 三种 Scope

Scope 行为 用途
DEFAULT (Singleton) 全应用一份 99% 的服务,无状态
REQUEST 每请求新建 需要请求上下文(租户 ID、追踪 ID)
TRANSIENT 每次注入新建 几乎不用

⚠️ Scope.REQUEST 是性能毒药 一旦一个 provider 是 REQUEST,所有注入它的下游 全部变 REQUEST,整条链每请求都要重新实例化。生产环境 RPS 高时这是性能杀手。
💡 更好的方式:AsyncLocalStorage 用 Node 内置的 AsyncLocalStorage 维护请求上下文,服务保持 Singleton,从 ALS 里读上下文。后面给完整代码。

3.3 AsyncLocalStorage 注入请求上下文(替代 Scope.REQUEST)

typescript 复制代码
// src/common/context/request-context.ts
import { AsyncLocalStorage } from "node:async_hooks";

export interface RequestContext {
  requestId: string;
  userId?: string;
  tenantId?: string;
  traceId?: string;
}

export const requestContext = new AsyncLocalStorage<RequestContext>();

export const getContext = (): RequestContext => {
  const ctx = requestContext.getStore();
  if (!ctx) {
    throw new Error("RequestContext accessed outside HTTP request");
  }
  return ctx;
};

注入器(放在最早的拦截器或中间件):

typescript 复制代码
// src/common/context/context.middleware.ts
import { Injectable, NestMiddleware } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { FastifyReply, FastifyRequest } from "fastify";
import { requestContext } from "./request-context";

@Injectable()
export class ContextMiddleware implements NestMiddleware {
  use(req: FastifyRequest["raw"], res: FastifyReply["raw"], next: () => void) {
    const requestId = (req.headers["x-request-id"] as string) || randomUUID();
    res.setHeader("x-request-id", requestId);
    requestContext.run({ requestId }, next);
  }
}

任何 Singleton 服务里读取:

typescript 复制代码
import { getContext } from "@/common/context/request-context";

@Injectable()
export class AuditService {
  log(action: string) {
    const { userId, tenantId, requestId } = getContext();
    // ...
  }
}

💡 原理:为什么 ALS 不会跨请求"串话"? Node 用 async_hooks 跟踪异步链,在 als.run(store, fn) 内部启动的所有异步任务(Promise / setTimeout)都自动继承 store。请求结束 → 异步链结束 → store 被 GC。性能开销在 Node 16+ 已经可以忽略(< 1% CPU)。

4. 守卫(Guard)------ 鉴权与授权

4.1 基础 Guard

typescript 复制代码
// src/common/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly jwtService: JwtService,
  ) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    // 1. 检查是否标注了 @Public()
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      ctx.getHandler(),
      ctx.getClass(),
    ]);
    if (isPublic) return true;

    // 2. 从请求头取 token
    const req = ctx.switchToHttp().getRequest<FastifyRequest>();
    const auth = req.headers.authorization;
    if (!auth?.startsWith("Bearer ")) throw new UnauthorizedException();
    const token = auth.slice(7);

    // 3. 校验
    try {
      const payload = await this.jwtService.verifyAsync(token);
      // 4. 注入上下文给后续 handler 使用
      (req as any).user = payload;
      const store = requestContext.getStore();
      if (store) { store.userId = payload.sub; store.tenantId = payload.tid; }
      return true;
    } catch {
      throw new UnauthorizedException("Invalid token");
    }
  }
}

@Public() 装饰器:

typescript 复制代码
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

注册为全局:

typescript 复制代码
// app.module.ts
providers: [
  { provide: APP_GUARD, useClass: JwtAuthGuard },
]

💡 "全局守卫 + 白名单装饰器" 比 "局部加守卫" 更安全 默认全部要鉴权,显式标注 @Public() 才放行。漏写 = 拒绝 ,符合 fail-safe 原则。如果反过来"默认放行,显式 @Auth()",漏写就出安全事故。

4.2 角色/权限 Guard(RBAC 雏形,04 章扩展为 CASL)

typescript 复制代码
export const ROLES_KEY = "roles";
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
  canActivate(ctx: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
      ctx.getHandler(), ctx.getClass(),
    ]);
    if (!required?.length) return true;
    const { user } = ctx.switchToHttp().getRequest();
    return required.some((r) => user?.roles?.includes(r));
  }
}

使用:

typescript 复制代码
@Roles("admin")
@Delete(":id")
remove(@Param("id") id: string) { /* ... */ }

⚠️ 角色字符串硬编码是技术债 立刻整理成 enum / const,放到 @my-app/shared。否则三个月后到处都是 "admin" "administrator" "ADMIN" 各种拼写。

5. 管道(Pipe)------ 校验与转换

5.1 Zod Pipe(本教程的标准方式)

typescript 复制代码
// src/common/pipes/zod-validation.pipe.ts
import { BadRequestException, PipeTransform } from "@nestjs/common";
import { ZodSchema } from "zod";

export class ZodValidationPipe<T> implements PipeTransform {
  constructor(private readonly schema: ZodSchema<T>) {}
  transform(value: unknown): T {
    const result = this.schema.safeParse(value);
    if (!result.success) {
      throw new BadRequestException({
        type: "https://errors.example.com/validation",
        title: "Validation failed",
        status: 400,
        errors: result.error.flatten().fieldErrors,
      });
    }
    return result.data;
  }
}

控制器:

typescript 复制代码
import { CreateUserSchema, CreateUserInput } from "@my-app/shared/schemas/user";

@Post()
create(@Body(new ZodValidationPipe(CreateUserSchema)) dto: CreateUserInput) {
  return this.users.create(dto);
}

💡 为什么 Zod 而不是 class-validator?

  1. 同一份 schema 前后端共用,见 01 章。2) Zod 推导出 infer 类型,不需要写 DTO class,少一层维护。3) class-validator 依赖装饰器和 emitDecoratorMetadata,与 Stage-3 装饰器/ESM 未来不友好。
    ⚠️ whitelist + forbidNonWhitelisted 在 Zod pipe 下要自己实现 Zod 默认会 .strip() 多余字段(不抛错)。想"多余字段直接拒绝"用 schema.strict()

5.2 内置 Pipe 也别忘了

ParseIntPipeParseUUIDPipeParseBoolPipeDefaultValuePipe,处理路径/查询参数很方便:

typescript 复制代码
@Get(":id")
findOne(@Param("id", new ParseUUIDPipe({ version: "4" })) id: string) {}

6. 拦截器(Interceptor)

拦截器的本质是 环绕通知(around advice):你拿到一个 RxJS Observable,可以在订阅前/后做事,也可以替换返回值。

6.1 日志 + 计时(常用)

typescript 复制代码
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger("HTTP");

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest<FastifyRequest>();
    const start = Date.now();
    return next.handle().pipe(
      tap({
        next: () => {
          const ms = Date.now() - start;
          this.logger.log({
            method: req.method,
            url: req.url,
            status: ctx.switchToHttp().getResponse().statusCode,
            duration_ms: ms,
            requestId: getContext().requestId,
          });
        },
        error: (err) => {
          this.logger.error({
            method: req.method,
            url: req.url,
            err: err.message,
            duration_ms: Date.now() - start,
          });
        },
      }),
    );
  }
}

6.2 响应包装(谨慎用)

经常见到把所有响应包成 { code, data, message } 的拦截器。

⚠️ 建议不要做这一层

  1. 状态码已经表达成功/失败,再包一层 code 是冗余。2) 缓存/CDN 失去 HTTP 语义。3) 错误用 RFC 7807 的 application/problem+json 是标准,不该自创格式。

如果必须做(例如对接老前端):

typescript 复制代码
@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(_: ExecutionContext, next: CallHandler): Observable<{ data: unknown }> {
    return next.handle().pipe(map((data) => ({ data })));
  }
}

6.3 缓存拦截器

NestJS 自带 CacheInterceptor,但只覆盖最简单的场景(整个响应作为 key,无失效逻辑)。生产用法 03 章细讲(Redis + 显式 key + 显式失效)。

7. 异常过滤器(Exception Filter)

7.1 全局 Filter:统一错误响应模型(RFC 7807)

typescript 复制代码
// src/common/filters/all-exceptions.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from "@nestjs/common";
import { FastifyReply, FastifyRequest } from "fastify";
import { getContext } from "@/common/context/request-context";

interface ProblemDetails {
  type: string;
  title: string;
  status: number;
  detail?: string;
  instance: string;
  requestId: string;
  errors?: Record<string, string[]>;
}

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<FastifyReply>();
    const req = ctx.getRequest<FastifyRequest>();
    const requestId = getContext().requestId;

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let body: ProblemDetails = {
      type: "https://errors.example.com/internal",
      title: "Internal Server Error",
      status,
      instance: req.url,
      requestId,
    };

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const resp = exception.getResponse();
      body = {
        ...body,
        status,
        title: typeof resp === "string" ? resp : (resp as any).message ?? exception.message,
        ...(typeof resp === "object" ? resp : {}),
      };
    } else if (exception instanceof Error) {
      this.logger.error({
        err: exception,
        stack: exception.stack,
        requestId,
        url: req.url,
      });
    }

    // 5xx 用 error 级,4xx 用 warn 级
    if (status >= 500) {
      this.logger.error({ err: exception, requestId, url: req.url });
    } else {
      this.logger.warn({ status, title: body.title, requestId, url: req.url });
    }

    res.status(status).type("application/problem+json").send(body);
  }
}

注册:

typescript 复制代码
// app.module.ts
providers: [
  { provide: APP_FILTER, useClass: AllExceptionsFilter },
]

💡 为什么不直接 throw new Error("foo") 让客户端看? 内部错误细节(stack、SQL 语句、内部 ID)泄露 = 信息披露漏洞。生产环境必须有"5xx → 通用文案 + 内部 log 详细"的脱敏。
⚠️ 不要在 Filter 里抛新异常 会进入 Fastify 默认错误处理,你失去格式控制。Filter 内部用 try/catch 保住自己。

7.2 业务错误的分类

把异常分两类,定下规范:

  1. 预期异常 (用户输入错、权限不足、资源不存在)→ 抛 NestJS 内置的 BadRequestException / ForbiddenException / NotFoundException 等。Filter 输出 4xx。
  2. 意外异常(DB 连不上、第三方挂、bug)→ 让它原样向上抛。Filter 兜底成 500。

不要为每个错误码定义一个 class 。过度工程。需要错误码区分时,放在 body.code 字段:

typescript 复制代码
throw new BadRequestException({
  type: "https://errors.example.com/user/email-taken",
  title: "Email already in use",
  code: "USER_EMAIL_TAKEN",
});

8. 元数据与装饰器

8.1 自定义参数装饰器

typescript 复制代码
// 取当前用户
export const CurrentUser = createParamDecorator(
  (_: unknown, ctx: ExecutionContext) => {
    const req = ctx.switchToHttp().getRequest();
    return req.user as JwtPayload;
  },
);

@Get("me")
me(@CurrentUser() user: JwtPayload) { return user; }

8.2 组合装饰器(简化重复)

typescript 复制代码
export function Auth(...roles: string[]) {
  return applyDecorators(
    UseGuards(JwtAuthGuard, RolesGuard),
    Roles(...roles),
    ApiBearerAuth(), // Swagger 标注
  );
}

@Auth("admin")
@Delete(":id")
remove() {}

💡 装饰器组合是降噪利器 ,每个 controller 顶部不再堆 5-10 行装饰器。但不要嵌套太深 ------ 抽象一层就够,两层就难调试。

9. 模块依赖的常见坑

9.1 循环依赖

typescript 复制代码
// A 依赖 B,B 依赖 A
@Module({ imports: [forwardRef(() => BModule)], ... })
class AModule {}

@Module({ imports: [forwardRef(() => AModule)], ... })
class BModule {}

// 在 service 里:
constructor(@Inject(forwardRef(() => BService)) private b: BService) {}

⚠️ forwardRef 是创可贴,不是解药 循环依赖通常意味着领域边界设计错了。先尝试:1) 抽出共享接口/事件;2) 引入第三方"中介"模块;3) 反转依赖。实在不行才用 forwardRef。

9.2 Provider 在哪个模块

原则:Provider 在它被定义的模块里实例化一次,跨模块用就 export + import

  • 同一个 provider 在两个模块都列 providers → 两个实例。
  • A export,B import → 共享同一个实例。

⚠️ PrismaService 必须是单例 多实例 = 多个 connection pool = 数据库连接爆炸。所以 PrismaModule 在根 AppModule 导入一次,且 @Global() 或显式被需要的模块导入。

10. 生命周期钩子

钩子 时机 用途
OnModuleInit 模块依赖图建好后 初始化连接(但通常 Prisma 用 lazy 更稳)
OnApplicationBootstrap 所有模块都 init 完 注册外部回调、跨模块协调
OnModuleDestroy 收到 SIGTERM 后 停止接受新工作
OnApplicationShutdown 关闭流程末段 关 DB / Redis / 队列连接
BeforeApplicationShutdown 介于 destroy 和 shutdown 之间 等正在处理的请求结束

实操优雅退出:

typescript 复制代码
@Injectable()
export class QueueService implements OnApplicationShutdown {
  constructor(@InjectQueue("emails") private q: Queue) {}
  async onApplicationShutdown(signal?: string) {
    Logger.log(`Closing queue due to ${signal}`);
    await this.q.close(); // 等当前任务完成
  }
}

main.ts 需要 app.enableShutdownHooks()(01 章已加)。

💡 K8s 优雅退出完整链路

  1. K8s 发 SIGTERM 给容器。2) Nest 触发 BeforeApplicationShutdown(此时 readiness probe 应返回 not ready,停止接流量)。3) 等待 terminationGracePeriodSeconds(默认 30s)。4) 处理完存量请求,触发 OnApplicationShutdown 关连接。5) 进程退出。任何步骤超时,K8s 发 SIGKILL,强杀

11. App 启动模板(整合本章)

src/app.module.ts:

typescript 复制代码
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";

import { validateEnv } from "./config/env";
import { ContextMiddleware } from "./common/context/context.middleware";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
import { RolesGuard } from "./common/guards/roles.guard";
import { LoggingInterceptor } from "./common/interceptors/logging.interceptor";
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter";

import { PrismaModule } from "./infra/prisma/prisma.module";
import { RedisModule } from "./infra/redis/redis.module";
import { AuthModule } from "./modules/auth/auth.module";
import { UsersModule } from "./modules/users/users.module";

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, validate: validateEnv, cache: true }),
    PrismaModule,
    RedisModule,
    AuthModule,
    UsersModule,
  ],
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },
    { provide: APP_GUARD, useClass: RolesGuard },
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
    { provide: APP_FILTER, useClass: AllExceptionsFilter },
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(ContextMiddleware).forRoutes("*");
  }
}

💡 顺序敏感 :APP_GUARD 数组中的多个 Guard 按声明顺序执行;APP_INTERCEPTOR 按声明顺序"洋葱"包裹。第一个声明的拦截器最外层。

12. 速记卡

你想做 选哪个
校验/转换入参 Pipe
决定能不能进入 Guard
包裹响应 / 日志 / 计时 / 缓存 Interceptor
处理异常 → 响应 Exception Filter
取请求上下文 自定义参数装饰器 + ALS
跨模块共享服务 export + import,或 @Global()(慎)
进程级初始化/清理 OnModuleInit / OnApplicationShutdown

延伸阅读


相关推荐
十有八七1 小时前
Hermes Agent 自进化实现:从源码到架构的深度拆解
前端·人工智能
前端毕业班1 小时前
uni-app onShareAppMessage hook 原理分析
前端·javascript
gogoing1 小时前
React 分包加载优化
前端·react.js
gogoing1 小时前
Babel 配置与工具
前端·javascript
亲亲小宝宝鸭1 小时前
重新install,项目就跑不起来了?!
前端·npm
Mike117.1 小时前
GBase 8a 物化视图依赖和 DDL 风险排查记录
java·服务器·前端
蜡台1 小时前
Vue3 Hook 与 Store 状态管理:深度解析与选型指南
前端·javascript·vue.js
無名路人2 小时前
小程序点餐页吸顶滚动
前端·微信小程序·ai编程
小小小前端啊2 小时前
前端手写代码大全
前端