面向 中级 → 高级 工程师的生产级开发手册。每章附 原理章节 (💡)和 踩坑提醒(⚠️)。
本文档由 11 个子主题拼接而成,可一口气读完,也可按下面目录跳读。
总目录
- [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")
- [01 - 脚手架与工程化](#01 - 脚手架与工程化 "#01---%E8%84%9A%E6%89%8B%E6%9E%B6%E4%B8%8E%E5%B7%A5%E7%A8%8B%E5%8C%96")
- [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")
- [03 - 数据层:Prisma + PostgreSQL + Redis](#03 - 数据层:Prisma + PostgreSQL + Redis "#03---%E6%95%B0%E6%8D%AE%E5%B1%82prisma-postgresql-redis")
- [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")
- [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")
- [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")
- [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")
- [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")
- [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")
- [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")
附录(高级进阶)
- [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")
- [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")
- [A3 - 经典事故 10 例](#A3 - 经典事故 10 例 "#a3---%E7%BB%8F%E5%85%B8%E4%BA%8B%E6%95%85-10-%E4%BE%8B")
- [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")
- [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│
└──────────────────────┘
关键边界
- NestJS API 是唯一可信源。所有写操作必须经它,客户端不直接连数据库。
- PostgreSQL 是事实之源 。Redis 是加速层,Redis 中的数据 任何时候都可被丢弃且不影响正确性。
- API 与 Worker 进程分离,共用代码库但启动入口不同。Worker 不绑 HTTP 端口,可独立扩缩容。
💡 原理:为什么 API 与 Worker 必须分进程?
- 资源画像不同: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扩展;或在应用层用uuidv7npm 包生成。
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: 0、maxSurge: 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 排查)
延伸阅读
- The Twelve-Factor App --- 这套教程基本符合 12factor
- Designing Data-Intensive Applications --- 数据层决策的圣经
- Refactoring Guru - Architectural Patterns
- Postgres RLS 官方文档:Row Security Policies
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?
- 同一份 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 也别忘了
ParseIntPipe、ParseUUIDPipe、ParseBoolPipe、DefaultValuePipe,处理路径/查询参数很方便:
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 } 的拦截器。
⚠️ 建议不要做这一层
- 状态码已经表达成功/失败,再包一层
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 业务错误的分类
把异常分两类,定下规范:
- 预期异常 (用户输入错、权限不足、资源不存在)→ 抛 NestJS 内置的
BadRequestException/ForbiddenException/NotFoundException等。Filter 输出 4xx。 - 意外异常(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 优雅退出完整链路
- 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 |
延伸阅读
- NestJS 官方文档 Fundamentals
- Node async_hooks 与 AsyncLocalStorage
- RFC 7807 Problem Details for HTTP APIs