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

延伸阅读


相关推荐
JustHappy6 小时前
古法编程秘籍(二):什么是代码模块化?别背概念,把房间收拾明白就够了
前端·后端
小江的记录本6 小时前
【JVM虚拟机】堆内存分代模型:年轻代(Eden+Survivor)、老年代、元空间Metaspace(附《思维导图》+《面试高频考点清单》)
java·前端·jvm·后端·python·spring·面试
weixin_471383036 小时前
图片预解码缓存
前端·浏览器缓存·图片预解码
郑洁文8 小时前
基于网络爬虫的Web敏感信息泄露自动化检测工具
前端·爬虫·网络安全·自动化
郑洁文8 小时前
可视化Web渗透分析工具的设计与实现
前端
罗超驿9 小时前
18.Web API 实战:元素与表单属性的获取和修改
开发语言·前端·javascript
边界条件╝9 小时前
微前端进阶(四)
前端·状态模式
无风听海9 小时前
JSON Web Token(JWT)完全指南
java·前端·json
IT_陈寒9 小时前
Python闭包里藏的这个坑,差点让我加班到凌晨
前端·人工智能·后端
IT_陈寒9 小时前
Java注解空指针?这个坑我踩得莫名其妙
前端·人工智能·后端