Max
搜索
返回故事会

NestJS 企业级实战:从零到微服务的全栈架构指南

36 分钟阅读0Max ZhangBackend
NestJSTypeScript微服务全栈

这不是一篇"官方文档翻译"。这是一次复盘——我把搭了三次企业级 NestJS 项目踩过的坑、攒下的代码、半夜对着屏幕抽烟才想明白的道理全写下来了。

那天凌晨两点,我在公司群里发了一条消息:"先别上线,数据库字段类型改了,前端还没同步。"

消息发出去,我就知道今晚不用睡了。

事情的起因很简单:后端同事改了个接口——userIdnumber 变成了 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 的,然后注入到你的构造函数里。

为什么这么搞?三个原因:

  1. 好测试——测试时注入假的 UserService,不用真的连数据库
  2. 解耦——Controller 只依赖 Service 的接口,不依赖具体实现
  3. 生命周期管理——你不管这个 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 内置 LoggerDemo、本地调试

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/ioredisNestJS 原生集成封装稍厚

推荐 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 生态集成好审核严格
MailgunAPI 友好、送达率高免费额度小
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亮点坑点
TypeORMNestJS 原生支持最好、装饰器风格类型推导弱、部分 API 绕
Prisma类型安全极好、迁移工具强多一层抽象、生成代码不透明
MongooseMongoDB 事实标准仅适用于 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()
    }
  }
}

区分两个概念:

  1. ORM 连接池:TypeORM 内部管理到一个 DB 的多个连接(池化复用)
  2. 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

livenessProbereadinessProbe 必须配。一个管活没活,一个管能不能接流量。


十五、你下次做决策时问自己这些问题

我不给你总结。总结没用。我给你一套问题——每次做决策时问自己:

关于架构

  1. 这个服务真的需要拆吗?一个人开发就先别拆
  2. 拆完调用延迟加了多少?数据一致性怎么保证?
  3. 团队有精力维护 Consul、K8s、链路追踪这些基础设施吗?

关于数据库

  1. synchronize 关了吗?(这不是问答题——答案永远是"关了")
  2. 连接池大小算过吗?实例数 × max > 数据库上限
  3. 缓存失效策略:更新后立刻清还是等 TTL?

关于安全

  1. 密码存储用 Argon2 了吗?
  2. 接口响应里有密码字段吗?不放心的全局挂 SerializeInterceptor
  3. whitelist 开了吗?没开的现在去开

关于可靠性

  1. 微服务调用设超时了吗?
  2. 发邮件失败会不会阻止用户操作?
  3. 日志存没存敏感信息?

最后一句话——我踩了三年坑之后最想说的:

不要一上来就搞微服务。 先写单体。NestJS 最大的好处不是让你写微服务,是你写成单体后,拆微服务只需把 Service 包成微服务端、调用方改成 ClientProxy——业务代码几乎不动。

优先级:

  1. 配置 + 日志 + 异常处理(地基)
  2. 数据库 + 缓存 + 认证授权(业务核心)
  3. 消息队列 + 微服务(架构扩展)

每搞定一层,验证在生产环境跑稳了,再进下一层。

现在,去看看你的代码里 synchronize: true 是不是还开着。如果是,先去改掉。

读者来信

0/1000

暂无来信,期待你的分享。