NestJS 企业级实战:从零到微服务的全栈架构指南
这不是一篇"官方文档翻译"。这是一次复盘——我把搭了三次企业级 NestJS 项目踩过的坑、攒下的代码、半夜对着屏幕抽烟才想明白的道理全写下来了。
那天凌晨两点,我在公司群里发了一条消息:"先别上线,数据库字段类型改了,前端还没同步。"
消息发出去,我就知道今晚不用睡了。
事情的起因很简单:后端同事改了个接口——userId 从 number 变成了 string。他觉得没啥,就是个类型嘛。但前端的代码里,所有用到 userId 的地方都标注了 number。TypeScript 编译的时候不报错——build 的时候你拿到的是 JSON 数据,type annotation 根本不参与运行时校验。
上线两小时,用户开始报 bug。付款记录查不到,个人中心报错。找了半天才定位到类型不一致。
卧槽。
这种破事我遇到过太多次了。但不是你一个人的问题——这是整个前后端协作方式的问题。传统开发流程里,类型信息在 API 边界上丢失了。后端改了返回值,前端没人通知,bug 就埋下去了,等到生产环境才炸。
所以后来我押宝 TypeScript 全栈。不是追新技术——是被这种类型断层搞怕了。
这篇文章,就是我摸索出来的全套方案。从配置到部署,从单体到微服务。不翻译文档,每一个结论都是踩过坑的。你不需要一口气看完,挑你眼下最头疼的部分先看就行。
一、当你受够了类型断层
先讲一个概念。这不抽象——你下周就能用上。
tRPC 做的事很简单:把后端的类型定义自动推导到前端。你不用手动维护共享的 interface 文件,不用前后端两头改。以后端为准,前端自动跟上。
// 后端定义
const appRouter = router({
userById: publicProcedure.input(z.string()).query(({ input }) => {
return { id: input, name: '张三', email: 'zhangsan@example.com' }
}),
})
export type AppRouter = typeof appRouter
// 前端什么都不用写
const user = await trpc.userById.query('abc-123')
// user 自动推导为 { id: string; name: string; email: string }
你后端改了返回字段,前端编译直接报错。根本不会留到运行时。
但 tRPC 不是万能药。它解决的是 RPC 调用的类型推导问题:
- 你要对接外部系统(第三方 API、App 客户端),tRPC 的协议不通用
- 前端是 React Native / Flutter / Swift,tRPC 用不了
- 团队对抽象层有抵触,强行推只会增加矛盾
这时候 Zod 出场。它是一个运行时校验库,同时能推导出 TypeScript 类型:
import { z } from 'zod'
const CreateUserSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
age: z.number().min(0).max(150),
})
type CreateUserDto = z.infer<typeof CreateUserSchema>
const result = CreateUserSchema.safeParse(req.body)
if (!result.success) {
return { code: 400, errors: result.error.flatten() }
}
TypeScript 的类型在编译后就灰飞烟灭了。Zod 是你的最后一道防线——任何用户输入、外部 API 响应,都必须经过它。
NestJS 生态里 class-validator + class-transformer 也是干这个的。Zod 的好处在不需要装饰器类,一个 Schema 文件可以跨项目共享。
记住一条原则:编译时类型检查 + 运行时 Schema 校验,少一个都不行。 类型不是装饰品,是防弹衣。
二、NestJS 的"世界观"——先看懂这盘棋
你第一次打开 NestJS 文档,看到 Module、Controller、Provider、Decorator、Dependency Injection,脑子是懵的。正常。我当初也是。
后来我想明白了:NestJS 不是 Express 的升级版,它是一套建筑框架。Express 给你的是砖头,你怎么砌都行。NestJS 给你的是钢筋混凝土结构——有规则,但你按规则来,它能撑 100 层。
2.1 三种编程范式搅在一起
NestJS 里同时有三种编程范式的影子。
OOP(面向对象) —— 这是它的骨架。Module、Controller、Service 全是类。你可以继承、封装、多态。
export abstract class BaseCrudService<T> {
constructor(protected readonly repository: Repository<T>) {}
async findAll(): Promise<T[]> {
return this.repository.find()
}
abstract validateCreate(dto: unknown): T
}
FP(函数式) —— 管道和拦截器的组合模式。一个请求进来,经过一层层 Pipe,每个 Pipe 做一件事,组合起来。
FRP(函数响应式) —— RxJS 承载的异步流。后面会专门讲。你先知道 NestJS 里大量的 pipe() 调用不是装饰,是真的在组合异步操作就行。
你不必同时精通这三种。OOP 是必选项——理解类和依赖注入。FP 和 FRP 可以慢慢来。
2.2 AOP:切面编程——餐厅的记账系统
AOP 很多人觉得抽象。我讲个故事。
你去餐厅吃饭。点单、上菜、结账——这是"业务逻辑"。
但收银台有个规矩:每一笔消费,不管谁、不管吃什么,都要同步记到财务系统。这个"记账"跟吃饭没关系,但每桌客人都触发一遍。这就是横切关注点。
在你的后端系统里,这种横切关注点太多了:记日志、鉴权、脱敏、统一错误格式化。如果把这些代码写在每个 Controller 方法里,业务代码里 60% 都是这些——这他妈的还怎么维护?
NestJS 用四种机制解决:
| 机制 | 干什么的 | 食堂比喻 |
|---|---|---|
| Middleware | 请求还没进路由之前 | 进门检查健康码 |
| Guards | 决定你能不能进这个接口 | 保安查工牌 |
| Interceptors | 方法调用的前后 | 上菜前记台账 |
| Pipes | 参数校验和转换 | 后厨验菜 |
| Filters | 异常甩出来之后统一处理 | 投诉处理流程 |
AOP 一点都不玄。就是把"跟业务无关但每个请求都要做的事"从业务代码里抽出来。
2.3 依赖注入:为什么你的依赖都是"传"进来的
你写 Express 的时候:
app.get('/users', (req, res) => {
const db = new Database()
const userService = new UserService(db)
res.json(userService.findAll())
})
你自己 new,自己传,自己管生命周期。
NestJS 里变成了:
@Controller('users')
export class UserController {
constructor(
private readonly userService: UserService,
private readonly auditService: AuditService,
) {}
@Get()
findAll() {
return this.userService.findAll()
}
}
这个 userService 哪来的?没人 new 它啊。
答案是:NestJS 的 IoC 容器帮你 new 的,然后注入到你的构造函数里。
为什么这么搞?三个原因:
- 好测试——测试时注入假的
UserService,不用真的连数据库 - 解耦——Controller 只依赖 Service 的接口,不依赖具体实现
- 生命周期管理——你不管这个 Service 是单例还是请求作用域
刚开始不习惯。但用了一周之后,再回去写 new Database() 会全身不舒服。就像用惯了自来水,突然让你去井里打水。
2.4 装饰器:贴在代码上的便利贴
装饰器本质就是一个函数——在类/方法/属性定义时被调用。NestJS 用它在不侵入代码的前提下声明元数据:
function Injectable(): ClassDecorator {
return (target: any) => {
Reflect.defineMetadata('design:paramtypes', [UserRepository], target)
// 容器读到这个,就知道要注入 UserRepository
}
}
你常用的:@Module()、@Controller()、@Injectable()、@Get()、@Post()、@Param()、@Body()、@UseGuards()。
还能自己写:
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
return ctx.switchToHttp().getRequest().user
},
)
@Get('profile')
getProfile(@CurrentUser() user: User) {
return user
}
2.5 各层职责——铁律
AppModule
├── AuthModule
│ ├── AuthController → 路由层
│ ├── AuthService → 业务逻辑层
│ └── UserModule → 子模块
├── ConfigModule
├── DatabaseModule
└── CacheModule
- Controller:收请求、调 Service、返回响应。绝不写业务逻辑。永远不。
- Service:纯业务逻辑。被 Controller 调,也能被其他 Service 调。
- Module:组织代码单元,声明有哪些 Controller、Service、导入了什么、导出了什么。
我看到太多项目,Controller 里写了 200 行 SQL。这不是 NestJS 的问题,是没理解这个框架的设计意图。
三、让应用在不同环境里"活"过来
企业级项目不可能只有一套配置。开发连本地 DB,生产连 RDS。开发日志打控制台,生产打到 Elasticsearch。配置管理搞不好,生产事故迟早的。
3.1 环境变量的正确方式
pnpm add @nestjs/config
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [`.env.${process.env.NODE_ENV || 'development'}`, '.env'],
validationSchema: validationSchema,
}),
],
})
export class AppModule {}
isGlobal: true 很重要。不设的话,20 个模块你每个都得手动 imports: [ConfigModule],累死你。
环境文件优先级:数组前面的覆盖后面的。NODE_ENV=production 时先读 .env.production,再 .env 兜底。
3.2 用 Joi 做启动校验——变量不对就别跑
最怕的场景:部署到生产,跑了一小时才发现某个环境变量没配。应用不该默默带着 undefined 继续跑——应该在启动时就炸掉。
import * as Joi from 'joi'
const validationSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
PORT: Joi.number().default(3000),
DATABASE_HOST: Joi.string().required(),
DATABASE_PORT: Joi.number().default(5432),
DATABASE_USER: Joi.string().required(),
DATABASE_PASSWORD: Joi.string().required(),
DATABASE_NAME: Joi.string().required(),
REDIS_HOST: Joi.string().required(),
REDIS_PORT: Joi.number().default(6379),
JWT_SECRET: Joi.string().min(32).required(),
JWT_EXPIRES_IN: Joi.string().default('7d'),
})
DATABASE_PASSWORD 忘了配?应用直接报「"DATABASE_PASSWORD" is required」——炸在启动阶段,而不是跑着跑着数据库连不上。这才叫安全的失败。
四、日志:生产环境的黑匣子
项目上线后,你最依赖的就是日志。出 bug 看日志,性能慢看日志,被人攻击也看日志。
我最早用 console.log。开发时没问题。上了生产——日志里什么都有,但什么也找不到。没有结构化,搜一个用户的操作要翻几百行。
4.1 为什么推荐 Pino
| 方案 | 吞吐量 | 结构化 | 场景 |
|---|---|---|---|
| Pino | 极高 | JSON 原生 | 生产环境首选 |
| Winston | 中 | 需配置 | 需要复杂传输策略 |
| NestJS 内置 Logger | 低 | 弱 | Demo、本地调试 |
Pino 快的原因:异步的,JSON 序列化用优化 schema 而不是 JSON.stringify。QPS 一高,差异非常明显。
pnpm add nestjs-pino pino-http pino-pretty
LoggerModule.forRoot({
pinoHttp: {
transport:
process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty', options: { colorize: true } } : undefined,
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
autoLogging: false,
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
// 不记录 headers 和 body——避免密码进日志
}),
},
},
})
4.2 打日志的正确姿势
// 错误——拼字符串
this.logger.info(`用户 ${email} 创建成功`)
// 正确——上下文数据放在第一个对象参数
this.logger.info({ userId: user.id, email: dto.email }, '用户创建成功')
为什么?日志系统搜索结构化数据比搜字符串快一万倍。搜 email: "xxx" 是精确匹配,搜"用户 xxx 创建成功"是用正则抠字符串。日志量大你就知道了。
三条铁律:敏感信息挡在序列化器外、上下文数据放结构化对象、消息字符串只写人读的描述。
4.3 已绑定 Winston 的话
WinstonModule.forRoot({
transports: [
new transports.Console({
/* ... */
}),
new transports.DailyRotateFile({
filename: 'logs/error-%DATE%.log',
level: 'error',
maxFiles: '30d',
}),
],
})
Winston 不差,只是没 Pino 快。QPS 不超 1000,差别不大。选团队熟悉的。
五、过滤器、CORS、前缀——地基配置
这些配置每个接口都经过,一开始就搞对。
5.1 统一错误响应格式
前端最恨:同一个接口,成功是 { data: ... },失败是 { statusCode: 400, message: '...' }——两套格式,前端两套判断。
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<Response>()
const request = ctx.getRequest<Request>()
const status = exception.getStatus()
const exceptionResponse = exception.getResponse()
const message =
typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse as any).message || '未知错误'
response.status(status).json({
code: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
})
}
}
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse()
const request = ctx.getRequest()
this.logger.error({ err: exception, url: request.url, ip: request.ip }, '未捕获的异常')
response.status(500).json({
code: 500,
message: '服务器内部错误',
timestamp: new Date().toISOString(),
path: request.url,
})
}
}
给前端的永远同一个格式:{ code, message, timestamp, path, data? }。前端只判断 code,不用管其他的。
5.2 CORS 和前缀
app.enableCors({
origin:
process.env.NODE_ENV === 'production'
? ['https://yourdomain.com']
: ['http://localhost:3000', 'http://localhost:5173'],
credentials: true,
})
app.setGlobalPrefix('api', { exclude: ['health'] })
生产环境 CORS 必须白名单。别图省事写 origin: '*'——带了 credentials 直接报废。
5.3 API 版本控制
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: ['1'],
})
@Controller({ path: 'users', version: ['1', '2'] })
export class UserController {
@Get()
findAll() {}
@Version('2')
@Get('stats')
getStats() {}
}
/api/v1/users 老接口,/api/v2/users/stats 新接口。客户慢慢迁移,你别求所有人同时升级。
六、构建和测试——让 CI 别那么慢
6.1 SWC:Rust 写的编译加速器
NestJS 默认用 tsc。本地开发还行,CI/CD 慢成狗。SWC 能把编译时间降到 1/10。
pnpm add -D @swc/cli @swc/core
{
"compilerOptions": {
"builder": "swc",
"typeCheck": true,
"deleteOutDir": true
}
}
typeCheck: true 是重点——SWC 只管编译,不管类型检查。关了就等着类型错误静默通过上线炸。
6.2 Vitest:比 Jest 快一个量级
Jest 很好,但慢。Vitest 用 esbuild 做转换,原生 ESM,速度碾压。
pnpm add -D vitest @swc-node/register unplugin-swc
NestJS 测试的典型写法——TestingModule 创建隔离测试容器:
describe('UserService', () => {
let service: UserService
const mockRepository = {
find: vi.fn(),
findOne: vi.fn(),
save: vi.fn(),
}
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [UserService, { provide: getRepositoryToken(User), useValue: mockRepository }],
}).compile()
service = module.get<UserService>(UserService)
})
it('应该返回所有用户', async () => {
const mockUsers = [{ id: 1, name: '张三' }]
mockRepository.find.mockResolvedValue(mockUsers)
const result = await service.findAll()
expect(result).toEqual(mockUsers)
expect(mockRepository.find).toHaveBeenCalledTimes(1)
})
})
注入 Mock Repository,不依赖真实数据库。测试跑得快,你才会写得多。
七、缓存——餐馆不能没有冰箱
一个没有缓存的应用就像一间没有冰箱的餐馆。每次来客人都得现买食材、现洗、现切、现炒。流量一大,厨房(数据库)就崩了。
7.1 Redis 客户端选型
| 方案 | 优点 | 缺点 |
|---|---|---|
| ioredis | 原生 Promise、集群支持好 | 包稍大 |
| node-redis (v4+) | 官方维护 | API 不够直觉 |
| @nestjs-modules/ioredis | NestJS 原生集成 | 封装稍厚 |
推荐 ioredis——API 更贴近实际场景,集群配置简洁。
pnpm add @nestjs-modules/ioredis ioredis
RedisModule.forRoot({
type: 'single',
url: `redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
options: {
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 50, 2000)
},
},
})
7.2 两级缓存:内存 LRU + Redis
Redis 快,但毕竟要走网络。字典表、配置项这种高频但不怎么变的数据,内存里加一层 LRU,性能再提一个量级。
const memoryCache = createKeyv('cache-manager://store/memory', {
max: 1000,
ttl: 60000,
})
const redisCache = createKeyv({
store: new KeyvRedis({
url: `redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
}),
})
CacheModule.register({
stores: [memoryCache, redisCache], // 先查内存,未命中再查 Redis
isGlobal: true,
})
7.3 两条铁律
第一,永远在写操作后主动 invalidate 缓存。 别依赖 TTL 自然过期。
async updateUser(id: number, dto: UpdateUserDto) {
const result = await this.userRepo.update(id, dto)
await this.cacheManager.del(`user:${id}`)
return result
}
第二,留一个开关。 调试时能直接穿透缓存查数据库:
async findById(id: number, useCache = true): Promise<User | null> {
const cacheKey = `user:${id}`
if (useCache) {
const cached = await this.cacheManager.get<User>(cacheKey)
if (cached) return cached
}
const user = await this.userRepo.findOne({ where: { id } })
if (user) await this.cacheManager.set(cacheKey, user, 60000)
return user
}
这个 useCache 参数救过我很多次。
八、发邮件——别自建
注册验证、密码重置、通知推送——企业级应用基本都得发邮件。
我见过有团队专门搭了台服务器搞邮件。不到一个月,发出去的邮件基本全进垃圾箱。IP 没预热、没信誉、ESP 不给过。这不是业务该操心的事。
8.1 选型
| 服务商 | 优势 | 劣势 |
|---|---|---|
| AWS SES | 便宜、AWS 生态集成好 | 审核严格 |
| Mailgun | API 友好、送达率高 | 免费额度小 |
| SendCloud | 国内送达好 | 海外不行 |
| Nodemailer(自建) | 完全可控 | 维护成本高到离谱 |
大部分场景选 AWS SES + Nodemailer 传输层:
pnpm add @nestjs-modules/mailer nodemailer @aws-sdk/client-ses
MailerModule.forRoot({
transport: {
SES: new SES({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
}),
},
defaults: { from: '"系统通知" <noreply@yourdomain.com>' },
template: {
dir: path.join(__dirname, '..', 'templates', 'emails'),
adapter: new HandlebarsAdapter(),
options: { strict: true },
},
})
8.2 发邮件失败别拦用户
async sendWelcomeEmail(to: string, username: string) {
try {
await this.mailerService.sendMail({ to, subject: '欢迎注册', template: 'welcome', context: { username } })
this.logger.info({ to }, '欢迎邮件发送成功')
} catch (error) {
this.logger.error({ err: error, to }, '欢迎邮件发送失败')
// 不发邮件不代表注册失败——失败任务丢进队列重试
}
}
发邮件失败绝对不要阻止用户业务流程。 记个日志,放进重试队列。用户完成注册但报 500 错误,体验完全不一样。
九、数据库与 ORM——选错成本最高的一章
这是全文最他妈的重要的一章。ORM 选错了改不起,数据库架构搞错了没法回头。
9.1 ORM 选型
| ORM | 亮点 | 坑点 |
|---|---|---|
| TypeORM | NestJS 原生支持最好、装饰器风格 | 类型推导弱、部分 API 绕 |
| Prisma | 类型安全极好、迁移工具强 | 多一层抽象、生成代码不透明 |
| Mongoose | MongoDB 事实标准 | 仅适用于 MongoDB |
| MikroORM | 设计现代、类型推导好 | 社区比 TypeORM 小 |
建议:关系型数据库(PG/MySQL),新项目优先 Prisma。团队熟悉 NestJS 装饰器风格,TypeORM 也够用。MongoDB 直接用 Mongoose。
9.2 TypeORM 集成
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get<string>('DATABASE_HOST'),
port: config.get<number>('DATABASE_PORT'),
username: config.get<string>('DATABASE_USER'),
password: config.get<string>('DATABASE_PASSWORD'),
database: config.get<string>('DATABASE_NAME'),
entities: [User, Role, Permission],
synchronize: config.get('NODE_ENV') !== 'production',
logging: config.get('NODE_ENV') === 'development',
extra: { max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000 },
}),
})
synchronize: true 在生产环境是灾难。 它会自动修改表结构。一个配置失误直接丢字段、删数据。我劝过多少团队——有些听进去了,有些交完学费才懂。
9.3 多数据库——多租户
@Injectable()
export class MultiDatabaseService implements OnApplicationShutdown {
private dataSources = new Map<string, DataSource>()
async getDataSource(tenantId: string): Promise<DataSource> {
if (this.dataSources.has(tenantId)) {
const ds = this.dataSources.get(tenantId)!
if (ds.isInitialized) return ds
}
const tenantConfig = await this.loadTenantConfig(tenantId)
const dataSource = new DataSource({
type: 'postgres',
host: tenantConfig.host,
port: tenantConfig.port,
username: tenantConfig.username,
password: tenantConfig.password,
database: tenantConfig.database,
entities: [User, Order],
synchronize: false,
})
await dataSource.initialize()
this.dataSources.set(tenantId, dataSource)
return dataSource
}
async onApplicationShutdown() {
for (const [tenantId, ds] of this.dataSources) {
if (ds.isInitialized) await ds.destroy()
}
}
}
区分两个概念:
- ORM 连接池:TypeORM 内部管理到一个 DB 的多个连接(池化复用)
- DataSource Map:你手动管理的多 DB 路由(按租户分发)
不是一码事。一个池化,一个路由。
9.4 Prisma 集成
pnpm add prisma @prisma/client
npx prisma init
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
email String @unique
password String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect()
}
async onModuleDestroy() {
await this.$disconnect()
}
}
Prisma 的 Schema 文件就是你的数据库文档。改了 Schema,跑 prisma migrate dev,自动生成迁移文件。
9.5 连接池经验公式
PostgreSQL 默认最大 100 连接。5 个 Pod,每个 max: 20,刚好 100。每个 max: 50,就是 250——超出限制,新连接被拒绝。
公式:max = (数据库最大连接数 / 服务实例数) × 0.8。留 20% 给运维操作。
十、RxJS——把异步当集合处理
很多人抱怨 NestJS 里到处是 RxJS:"我 Promise 用得好好的,为什么要学这个?"
我一开始也这么想。后来遇到一个需求改变了我。
搜索框:输入要防抖、相同值不重复请求、新搜索来了取消旧的。传统写法:
let timer: NodeJS.Timeout
inputElement.addEventListener('input', (e) => {
clearTimeout(timer)
timer = setTimeout(() => search(e.target.value), 300)
})
三个操作,代码已经乱了。再加"取消上一次请求"、"去重"——没法看了。
RxJS:
fromEvent(inputElement, 'input')
.pipe(
map((e) => e.target.value),
debounceTime(300),
distinctUntilChanged(),
switchMap((keyword) => this.searchService.search(keyword)),
)
.subscribe((results) => this.displayResults(results))
声明式。防抖、去重、取消旧请求——三行管子搞定。把 RxJS 当成"异步事件的 Lodash"就行。
10.1 数据库断连重试
findByIdWithRetry(id: number) {
return from(this.userRepo.findOne({ where: { id } })).pipe(
retry({
count: 3,
delay: (_, retryCount) =>
new Promise((resolve) => setTimeout(resolve, Math.pow(2, retryCount - 1) * 1000)),
}),
catchError((error) => {
console.error('重试耗尽,查询失败', error)
return throwError(() => new Error('服务暂时不可用'))
}),
)
}
查 DB → 失败重试 3 次 → 每次等时翻倍(1s / 2s / 4s)→ 耗尽降级。不用 RxJS,你至少一个 for 循环加一堆 try-catch。
十一、用户认证——最容易出安全问题的地方
认证系统是门面。做不好,数据泄露、越权访问、Session 劫持——排队等你。
11.1 数据校验:三道防线
pnpm add class-validator class-transformer
export class RegisterDto {
@IsEmail({}, { message: '邮箱格式不正确' })
email: string
@IsString()
@MinLength(8, { message: '密码至少 8 位' })
@MaxLength(32)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, { message: '密码必须包含大小写字母和数字' })
password: string
@IsString()
@MinLength(1)
@MaxLength(20)
username: string
}
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
)
whitelist: true 是安全必备——自动剔除 DTO 没定义的字段。攻击者塞任意字段?直接过滤掉。
11.2 Passport + JWT
pnpm add @nestjs/passport passport @nestjs/jwt passport-jwt passport-local
pnpm add -D @types/passport-jwt @types/passport-local
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
})
}
async validate(payload: JwtPayload): Promise<User> {
const user = await this.userService.findById(payload.sub)
if (!user) throw new UnauthorizedException('用户不存在')
if (!user.isActive) throw new UnauthorizedException('用户已被禁用')
return user
}
}
11.3 Guard vs Middleware
中间件不知道 next() 后面是什么。守卫能拿到 ExecutionContext——知道要执行哪个 Controller 的哪个方法、上面有什么装饰器:
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super()
}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
])
if (isPublic) return true
return super.canActivate(context)
}
}
export const IS_PUBLIC_KEY = 'isPublic'
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)
@Public()
@Post('register')
async register(@Body() dto: RegisterDto) { }
// 全局注册
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }]
11.4 Argon2——密码哈希的唯一答案
pnpm add argon2
async hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
})
}
async verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password)
}
别再用 bcrypt 了。Argon2 是 2015 年密码哈希竞赛冠军,同时扛 GPU 和 ASIC 攻击,OWASP 首选。能配内存开销——暴力破解成本成倍增加。
11.5 接口脱敏——密码绝不出现在响应里
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number
@Column() @Exclude()
password: string
@Column() name: string
}
@UseInterceptors(ClassSerializerInterceptor)
@Get('profile')
getProfile(@CurrentUser() user: User) {
return user // password 自动排除
}
在 Entity 上加 @Exclude(),一劳永逸。这是代码里最不该省的三行。
十二、权限控制——能做什么比你是谁更重要
认证答"你是谁",授权答"你能做什么"。混在一起的后果:多角色、细粒度权限时,if-else 散落在几十个文件里。
12.1 RBAC——覆盖 80%
先确定角色 → 角色分配权限 → 用户分配角色。
@Entity()
export class Permission {
@PrimaryGeneratedColumn() id: number
@Column({ unique: true }) name: string // 'user:create', 'user:read'
}
@Entity()
export class Role {
@PrimaryGeneratedColumn() id: number
@Column({ unique: true }) name: string // 'admin', 'editor'
@ManyToMany(() => User, (user) => user.roles) users: User[]
}
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions)
@Injectable()
export class PermissionGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
])
if (!requiredPermissions) return true
const { user } = context.switchToHttp().getRequest()
const userPermissions = await this.authService.getUserPermissionNames(user.id)
return requiredPermissions.some((perm) => userPermissions.includes(perm))
}
}
@RequirePermissions('user:create', 'user:read')
@Post()
createUser(@Body() dto: CreateUserDto) { }
// 注意顺序:JWT 在前,权限在后
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
]
12.2 CASL——解决剩下 20%
RBAC 管不了"只能编辑自己的文章"、"部门领导只看本部门报表"。CASL 条件能力上场:
pnpm add @casl/ability
export function defineAbilityFor(user: User) {
const { can, build } = new AbilityBuilder(AppAbility)
if (user.roles?.some((r) => r.name === 'admin')) can('manage', 'all')
if (user.roles?.some((r) => r.name === 'editor')) {
can(['create', 'read', 'update'], 'Article')
can('delete', 'Article', { authorId: user.id }) // 只能删自己的
}
if (user.roles?.some((r) => r.name === 'viewer')) {
can('read', 'Article', { status: 'published' }) // 只能看已发布
}
return build()
}
// 使用
const ability = defineAbilityFor(currentUser)
if (!ability.can('update', subject('Article', { authorId: 5 }))) {
throw new ForbiddenException('你没有权限编辑此文章')
}
CASL 的精髓:条件能力 { authorId: user.id } ——纯 RBAC 做不到。RBAC 和 CASL 不是替代关系,是互补。
十三、定时任务和消息队列——别在请求里做重活
13.1 定时任务
pnpm add @nestjs/schedule
@Cron('0 3 * * *', { name: 'cleanExpiredTokens', timeZone: 'Asia/Shanghai' })
handleTokenCleanup() {
this.logger.log('开始清理过期 Token')
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
handleDailyReport() {
this.logger.log('生成每日报表')
}
13.2 Bull 消息队列
定时任务适合简单周期工作。重活——发邮件、处理图片、生成报表——走队列:
pnpm add @nestjs/bull bull
BullModule.forRoot({
redis: { host: process.env.REDIS_HOST, port: parseInt(process.env.REDIS_PORT || '6379'), password: process.env.REDIS_PASSWORD },
})
BullModule.registerQueue({ name: 'email' }, { name: 'thumbnail' })
// 消费者
@Processor('email')
export class EmailProcessor {
@Process('welcome')
async handleWelcomeEmail(job: Job<{ to: string; username: string }>) {
await this.mailService.sendWelcomeEmail(job.data.to, job.data.username)
}
@OnQueueFailed()
onFailed(job: Job, error: Error) {
console.error(`邮件任务失败: ${job.id}, ${error.message}`)
}
}
// 生产者
async register(dto: RegisterDto) {
const user = await this.createUser(dto)
await this.emailQueue.add('welcome', { to: user.email, username: user.username }, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: true,
})
return user
}
Bull 解决三件事:解耦(注册和发邮件分开)、削峰(大促排队)、重试(失败自动重试)。
十四、微服务——别一上来就拆
前面都是单体。当你项目大到单体扛不住——团队冲突、DB 扛不住、部署互相阻塞——才考虑微服务。
但微服务不是万能药。拆错了比单体还难维护。
14.1 先说清楚——分布式 vs 微服务
- 分布式:系统拆成多个节点,通过网络通信。这是"怎么部署"的问题
- 微服务:分布式的一种实现——按业务边界拆分,独立部署、独立数据库。这是"怎么拆分"的问题
关键在业务边界,不在部署形式。
14.2 怎么拆——三个维度
| 维度 | 思路 | 例子 |
|---|---|---|
| 按业务 | 业务领域 | 用户服务、内容服务、订单服务 |
| 按功能 | 横向共通能力 | 审计、通知、文件 |
| 按资源 | 访问特征和负载 | 搜索(高频)、报表(批量) |
14.3 微服务通信
gRPC(推荐)——HTTP/2 + Protocol Buffers + 强类型:
// proto/user.proto
service UserService {
rpc FindOne (UserById) returns (User) {}
}
// 服务端
@GrpcMethod('UserService', 'FindOne')
async findOne(data: { id: number }): Promise<User> {
return this.userService.findById(data.id)
}
// 客户端
@Client({
transport: Transport.GRPC,
options: { package: 'user', protoPath: join(__dirname, '../proto/user.proto'), url: 'localhost:50051' },
})
private client: ClientGrpc
Proto 文件必须在服务间共享——抽成独立 npm 包或 git submodule。到处拷贝版本不一致,早晚吃大亏。
Redis Pub/Sub——事件驱动,解耦更好:
this.eventBus.emit('user.registered', { userId: user.id, email: user.email })
@EventPattern('user.registered')
async handleWelcomeEmail(data: { userId: number; email: string }) {
await this.mailService.sendWelcome(data.email)
}
14.4 超时与降级
this.userServiceClient.send('user.findOne', { id: 5 }).pipe(
timeout(3000),
retry({ count: 2, delay: (_, retryCount) => timer(retryCount * 1000) }),
catchError((error) => {
if (error instanceof TimeoutError) {
return of({ id: 5, name: '未知用户', _fallback: true })
}
throw error
}),
)
微服务调用:超时、重试、降级——三者缺一不可。下游挂了下游的事,上游不能跟着死。
14.5 服务治理
| 功能 | 工具 |
|---|---|
| 服务注册与发现 | Consul / Etcd / Nacos |
| API 网关 | Kong / Traefik / Nginx |
| 链路追踪 | SkyWalking / Jaeger |
| 日志采集 | ELK / Loki |
| 监控告警 | Prometheus + Grafana |
微服务不是把代码拆了就完。治理基础设施跟上了,才叫微服务。
14.6 Monorepo 工程目录
project/
├── apps/
│ ├── api-gateway/
│ ├── user-service/
│ └── content-service/
├── libs/
│ ├── common/ # 共享过滤器、守卫、装饰器、DTO
│ └── proto/ # gRPC proto 定义
├── docker/
│ └── docker-compose.yml
├── nest-cli.json
└── package.json
nest generate app user-service 就在 apps 下建一个子应用。
14.7 本地开发——Multipass + K3s
你不能每次调试都往集群部署。本地搭轻量级 K8s:
brew install multipass
multipass launch --name k3s-master --cpus 2 --memory 4G --disk 20G
multipass exec k3s-master -- bash -c "curl -sfL https://get.k3s.io | sh -"
# k8s/user-service.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
spec:
replicas: 3
template:
spec:
containers:
- name: user-service
image: your-registry/user-service:latest
livenessProbe:
httpGet:
path: /health
port: 3001
readinessProbe:
httpGet:
path: /health/ready
port: 3001
livenessProbe 和 readinessProbe 必须配。一个管活没活,一个管能不能接流量。
十五、你下次做决策时问自己这些问题
我不给你总结。总结没用。我给你一套问题——每次做决策时问自己:
关于架构:
- 这个服务真的需要拆吗?一个人开发就先别拆
- 拆完调用延迟加了多少?数据一致性怎么保证?
- 团队有精力维护 Consul、K8s、链路追踪这些基础设施吗?
关于数据库:
synchronize关了吗?(这不是问答题——答案永远是"关了")- 连接池大小算过吗?
实例数 × max > 数据库上限? - 缓存失效策略:更新后立刻清还是等 TTL?
关于安全:
- 密码存储用 Argon2 了吗?
- 接口响应里有密码字段吗?不放心的全局挂 SerializeInterceptor
- whitelist 开了吗?没开的现在去开
关于可靠性:
- 微服务调用设超时了吗?
- 发邮件失败会不会阻止用户操作?
- 日志存没存敏感信息?
最后一句话——我踩了三年坑之后最想说的:
不要一上来就搞微服务。 先写单体。NestJS 最大的好处不是让你写微服务,是你写成单体后,拆微服务只需把 Service 包成微服务端、调用方改成 ClientProxy——业务代码几乎不动。
优先级:
- 配置 + 日志 + 异常处理(地基)
- 数据库 + 缓存 + 认证授权(业务核心)
- 消息队列 + 微服务(架构扩展)
每搞定一层,验证在生产环境跑稳了,再进下一层。
现在,去看看你的代码里 synchronize: true 是不是还开着。如果是,先去改掉。
读者来信
暂无来信,期待你的分享。