面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 06月5日 20:04

NestJS 拦截器和异常过滤器:响应转换、日志、缓存和统一错误处理

拦截器和异常过滤器是 NestJS 请求处理管道中的两个关键环节。拦截器在请求成功时介入,做响应转换、日志记录、缓存等;异常过滤器在请求出错时介入,统一错误格式。两者配合,就能控制 API 返回给客户端的所有内容。先搞清执行顺序NestJS 处理一个请求的完整流程:请求 → Middleware → Guard → Interceptor(前) → Controller → Interceptor(后) → 响应 ↓ (异常) Exception Filter → 错误响应拦截器包住了 Controller——请求进来时先执行拦截器的"前"逻辑,Controller 处理完后执行"后"逻辑。如果 Controller 抛异常,跳过"后"逻辑,直接进异常过滤器。拦截器:请求前后的横切逻辑响应格式统一后端 API 最常见的需求:所有响应都包成统一格式 { code, data, message }:// interceptors/transform.interceptor.tsimport { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';import { map } from 'rxjs/operators';export interface ApiResponse<T> { code: number; data: T; message: string;}@Injectable()export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> { intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> { return next.handle().pipe( map(data => ({ code: 0, data, message: 'success', })), ); }}next.handle() 返回的是 Controller 的原始响应 Observable,用 map 操作符在"后"阶段转换格式。请求耗时日志@Injectable()export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger('HTTP'); intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const request = context.switchToHttp().getRequest(); const { method, url } = request; const now = Date.now(); this.logger.log(`${method} ${url} - Started`); return next.handle().pipe( tap(() => { this.logger.log(`${method} ${url} - ${Date.now() - now}ms`); }), ); }}tap 操作符只观察不修改——适合做日志、指标采集等副作用操作。缓存拦截器@Injectable()export class CacheInterceptor implements NestInterceptor { private cache = new Map<string, { data: any; expire: number }>(); intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const request = context.switchToHttp().getRequest(); const key = `${request.method}:${request.url}`; const cached = this.cache.get(key); if (cached && cached.expire > Date.now()) { return of(cached.data); // 命中缓存,直接返回,不进 Controller } return next.handle().pipe( tap(data => { this.cache.set(key, { data, expire: Date.now() + 60000 }); // 缓存 1 分钟 }), ); }}这个拦截器的特别之处:of(cached.data) 直接返回数据,next.handle() 根本不会执行——Controller 被完全跳过了。这就是拦截器的强大之处:它可以选择不调用 Controller。超时控制import { timeout, TimeoutError } from 'rxjs/operators';import { throwError } from 'rxjs';@Injectable()export class TimeoutInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { return next.handle().pipe( timeout(5000), // 5 秒超时 catchError(err => { if (err instanceof TimeoutError) { return throwError(() => new RequestTimeoutException('请求超时')); } return throwError(() => err); }), ); }}绑定拦截器// 方法级别@UseInterceptors(LoggingInterceptor)@Get('users')getUsers() {}// Controller 级别@UseInterceptors(TransformInterceptor)@Controller('users')export class UserController {}// 全局app.useGlobalInterceptors(new TransformInterceptor());全局绑定时用 useGlobalInterceptors 简单直接,但如果拦截器需要依赖注入,必须用 Module 方式注册:// 在任意 Module 中providers: [ { provide: APP_INTERCEPTOR, useClass: TransformInterceptor, },]异常过滤器:统一错误处理基本异常过滤器NestJS 内置了默认的异常处理,但返回格式不够友好。自定义过滤器可以统一错误格式:// filters/http-exception.filter.tsimport { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';import { Response } from 'express';@Catch(HttpException)export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const status = exception.getStatus(); const exceptionResponse = exception.getResponse(); response.status(status).json({ code: status, message: typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse as any).message || exception.message, data: null, timestamp: new Date().toISOString(), }); }}捕获所有异常@Catch() 不带参数捕获一切异常——包括非 HttpException 的错误(如数据库异常、第三方库异常):@Catch()export class AllExceptionsFilter implements ExceptionFilter { private readonly logger = new Logger('ExceptionFilter'); catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception instanceof HttpException ? exception.getStatus() : 500; // 非业务异常记录完整堆栈 if (!(exception instanceof HttpException)) { this.logger.error(exception instanceof Error ? exception.stack : String(exception)); } response.status(status).json({ code: status, message: status === 500 ? '服务器内部错误' : (exception as any).message, data: null, path: request.url, timestamp: new Date().toISOString(), }); }}500 错误不要把内部细节暴露给客户端——统一返回"服务器内部错误",详细信息只写日志。业务异常的优雅处理定义业务异常码,让过滤器按类型处理:// 自定义业务异常export class BusinessException extends HttpException { private readonly errorCode: string; constructor(errorCode: string, message: string, statusCode = 400) { super(message, statusCode); this.errorCode = errorCode; } getErrorCode() { return this.errorCode; }}// 在 Service 中使用async transfer(fromId: number, toId: number, amount: number) { if (amount <= 0) { throw new BusinessException('INVALID_AMOUNT', '转账金额必须大于0'); } const from = await this.findOne(fromId); if (from.balance < amount) { throw new BusinessException('INSUFFICIENT_BALANCE', '余额不足'); } // ... 执行转账}// 过滤器中区分处理@Catch(BusinessException)export class BusinessExceptionFilter implements ExceptionFilter { catch(exception: BusinessException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); response.status(exception.getStatus()).json({ code: exception.getErrorCode(), // 'INSUFFICIENT_BALANCE' message: exception.message, // '余额不足' data: null, }); }}绑定过滤器// 方法级别@UseFilters(HttpExceptionFilter)@Get('users')getUsers() {}// 全局app.useGlobalFilters(new AllExceptionsFilter());// 全局(支持依赖注入)providers: [ { provide: APP_FILTER, useClass: AllExceptionsFilter, },]拦截器 vs 异常过滤器的边界| 场景 | 用哪个 | 原因 ||------|--------|------|| 响应格式统一 | 拦截器 | 处理正常响应 || 日志记录 | 拦截器 | 记录请求和响应 || 缓存 | 拦截器 | 控制是否调用 Controller || 超时控制 | 拦截器 | 用 RxJS timeout 操作符 || 错误格式统一 | 异常过滤器 | 处理异常响应 || 错误日志 | 异常过滤器 | 记录异常堆栈 || 错误码映射 | 异常过滤器 | 把技术异常翻译成业务错误码 |一个常见错误:在拦截器里用 catchError 处理异常。虽然技术上可行,但违反了职责分离——拦截器负责"正常路径",过滤器负责"异常路径"。混在一起会让代码难以维护。全局注册的最佳实践在 main.ts 中统一注册全局拦截器和过滤器:// main.tsconst app = await NestFactory.create(AppModule);app.useGlobalInterceptors( new TransformInterceptor(), new LoggingInterceptor(),);app.useGlobalFilters( new AllExceptionsFilter(), new BusinessExceptionFilter(),);await app.listen(3000);需要注意注册顺序:过滤器按注册顺序反向执行(后注册的先执行),拦截器按注册顺序正向执行。所以 AllExceptionsFilter 放最后——它兜底处理所有未捕获的异常。
服务端阅读 06月5日 20:02

NestJS 集成 GraphQL:代码优先模式实战指南

NestJS 对 GraphQL 的支持是所有 Node.js 框架里做得最顺的——代码优先模式下,你只写 TypeScript 类,Schema 自动生成,不用手写 .graphql 文件也不用维护两套类型定义。这篇讲清楚两种集成方式的选择、核心配置、以及 Resolver/ObjectType/InputType 的写法。代码优先 vs Schema 优先NestJS 支持两种 GraphQL 集成方式,90% 的项目应该选代码优先:| | 代码优先(推荐) | Schema 优先 ||---|---|---|| 工作流 | 写 TypeScript → 自动生成 Schema | 写 Schema → 自动生成 TypeScript 类型 || 类型安全 | 天然安全,TypeScript 是单一事实来源 | 需要生成类型定义,可能和手写代码不同步 || 维护成本 | 低——只维护一份代码 | 高——Schema 和代码要同步 || 适合场景 | 纯 TypeScript 项目 | 多语言共享 Schema、已有 Schema 的项目 |安装和基本配置npm install @nestjs/graphql @nestjs/apollo graphql @apollo/server代码优先配置// app.module.tsimport { GraphQLModule } from '@nestjs/graphql';import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';import { join } from 'path';@Module({ imports: [ GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, autoSchemaFile: join(process.cwd(), 'src/schema.gql'), sortSchema: true, // 生成的 Schema 按字母排序,减少 git diff 噪音 playground: true, // 开发环境启用 GraphQL Playground }), ],})export class AppModule {}autoSchemaFile 是代码优先模式的关键——NestJS 会根据你的 TypeScript 装饰器自动生成 GraphQL Schema 文件。sortSchema: true 让生成的 Schema 内容顺序稳定,避免每次重新生成都产生无意义的 diff。Schema 优先配置GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, typePaths: ['./**/*.graphql'], // 指向你的 .graphql 文件 definitions: { path: join(process.cwd(), 'src/graphql.ts'), // 生成的 TypeScript 类型 outputAs: 'class', },})定义 ObjectType(对应数据库模型)ObjectType 是 GraphQL 的返回类型,相当于 REST 里的响应 DTO:// models/user.model.tsimport { ObjectType, Field, Int } from '@nestjs/graphql';@ObjectType()export class User { @Field(() => Int) id: number; @Field() email: string; @Field({ nullable: true }) nickname?: string; @Field(() => Int) age: number; @Field() createdAt: Date;}注意:TypeScript 的 number 对应 GraphQL 的 Int 和 Float 两种类型,必须显式指定 @Field(() => Int) 或 @Field(() => Float),否则 NestJS 不知道该映射到哪个。nullable: true 标记可空字段。默认所有字段都是非空的——这比 REST 的 JSON 响应更严格,客户端能明确知道哪些字段一定有值。定义 InputType(对应请求参数)InputType 是 GraphQL 的输入类型,相当于 REST 的请求 DTO:// dto/create-user.input.tsimport { InputType, Field } from '@nestjs/graphql';@InputType()export class CreateUserInput { @Field() email: string; @Field({ nullable: true }) nickname?: string; @Field(() => Int) age: number;}ObjectType 和 InputType 的区别:ObjectType 用于返回数据,InputType 用于接收参数。GraphQL 规范要求输入和输出类型必须分开定义——虽然字段可能一样,但不能复用同一个类。部分更新用 Patch@InputType()export class UpdateUserInput { @Field(() => Int) id: number; @Field({ nullable: true }) email?: string; @Field({ nullable: true }) nickname?: string; @Field(() => Int, { nullable: true }) age?: number;}所有字段都设为 nullable: true,客户端只需传要更新的字段。在 Service 层判断哪些字段有值就更新哪些。写 Resolver(对应 Controller)Resolver 是 GraphQL 的"控制器",处理查询和变更请求:// resolvers/user.resolver.tsimport { Resolver, Query, Mutation, Args } from '@nestjs/graphql';@Resolver(() => User)export class UserResolver { constructor(private readonly userService: UserService) {} @Query(() => [User]) async users(): Promise<User[]> { return this.userService.findAll(); } @Query(() => User, { nullable: true }) async user(@Args('id', { type: () => Int }) id: number): Promise<User | null> { return this.userService.findOne(id); } @Mutation(() => User) async createUser( @Args('input') input: CreateUserInput, ): Promise<User> { return this.userService.create(input); } @Mutation(() => User) async updateUser( @Args('input') input: UpdateUserInput, ): Promise<User> { return this.userService.update(input); } @Mutation(() => Boolean) async deleteUser(@Args('id', { type: () => Int }) id: number): Promise<boolean> { return this.userService.remove(id); }}@Query 用于读取数据(对应 REST GET)@Mutation 用于修改数据(对应 REST POST/PUT/DELETE)@Args 提取参数,和 Controller 里的 @Param/@Body 类似{ nullable: true } 表示查询可能返回 null(比如按 ID 查不到)关联查询(关系型数据)用户和文章是一对多关系,GraphQL 的优势是客户端可以按需获取关联数据:// models/post.model.ts@ObjectType()export class Post { @Field(() => Int) id: number; @Field() title: string; @Field(() => Int) authorId: number; @Field(() => User) author: User; // 关联字段}// resolvers/post.resolver.ts@Resolver(() => Post)export class PostResolver { @ResolveField('author', () => User) async getAuthor(@Parent() post: Post): Promise<User> { return this.userService.findOne(post.authorId); }}@ResolveField 是懒加载——只有客户端请求了 author 字段时才会执行。如果客户端只查 title,就不会触发额外的数据库查询。N+1 问题关联查询的陷阱是 N+1:查 10 篇文章的作者,会执行 1 次查文章 + 10 次查作者 = 11 次查询。解决方案是用 DataLoader 批量加载:import * as DataLoader from 'dataloader';// 在 Module 中注册 DataLoaderproviders: [ { provide: 'USER_LOADER', useFactory: (userService: UserService) => { return new DataLoader(async (ids: number[]) => { const users = await userService.findByIds(ids); return ids.map(id => users.find(u => u.id === id)); }); }, inject: [UserService], },]// 在 Resolver 中使用@ResolveField('author', () => User)async getAuthor( @Parent() post: Post, @Inject('USER_LOADER') userLoader: DataLoader<number, User>,) { return userLoader.load(post.authorId);}DataLoader 会把同一批次的所有 load() 调用合并成一次 findByIds() 查询,11 次变 2 次。认证和授权在 Resolver 层面守卫@UseGuards(GqlAuthGuard)@Mutation(() => Post)async createPost( @Args('input') input: CreatePostInput, @CurrentUser() user: User,): Promise<Post> { return this.postService.create(user.id, input);}GqlAuthGuard 需要从 GraphQL 上下文中提取用户信息,和 REST 的 AuthGuard 略有不同:@Injectable()export class GqlAuthGuard extends AuthGuard('jwt') { getRequest(context: ExecutionContext) { const ctx = GqlExecutionContext.create(context); return ctx.getContext().req; // GraphQL 请求对象 }}分页查询GraphQL 常用的游标分页(Relay 风格):@ObjectType()class PaginatedUsers { @Field(() => [User]) items: User[]; @Field(() => Int) totalCount: number; @Field() hasNextPage: boolean; @Field(() => String, { nullable: true }) cursor?: string;}@Query(() => PaginatedUsers)async users( @Args('limit', { type: () => Int, defaultValue: 10 }) limit: number, @Args('cursor', { type: () => String, { nullable: true } }) cursor?: string,): Promise<PaginatedUsers> { return this.userService.findPaginated(limit, cursor);}客户端可以灵活选择每页数量和起始位置,不像 REST 分页那么死板。调试技巧在 Playground 里测试启动应用后访问 http://localhost:3000/graphql,可以直接写查询测试。比用 Postman 方便得多——左侧写查询,右侧看结果,还有自动补全。查看生成的 Schema代码优先模式下,autoSchemaFile 指向的文件就是生成的 Schema。如果查询报类型错误,先看看生成的 Schema 是否符合预期——可能是装饰器写错了。常见报错"Cannot determine a GraphQL type for User":忘了加 @ObjectType() 装饰器"Query root type must be provided":没有任何 @Query 装饰器,Schema 是空的Circular dependency:两个 ObjectType 互相引用,用 () => import('./other.model').then(m => m.OtherType) 延迟引用解决快速配置清单| 检查项 | 配置 ||--------|------|| 选择模式 | 代码优先(autoSchemaFile) || Driver | ApolloDriver || 数字类型 | 显式指定 () => Int 或 () => Float || 可空字段 | { nullable: true } || 关联查询 | @ResolveField + DataLoader 防 N+1 || 认证 | GqlAuthGuard 从 GqlExecutionContext 取 req || Schema 排序 | sortSchema: true 减少 diff 噪音 |
服务端阅读 06月4日 15:45

NestJS控制器和路由:装饰器、参数获取、响应处理和常见坑

NestJS 的控制器用装饰器声明路由,不用手动写 app.get('/users/:id', ...)——装饰器既是文档又是路由注册。这篇文章把控制器的声明、路由参数获取、响应处理、以及常见的坑都过一遍。基本路由声明@Controller('users') // 路由前缀 /usersexport class UsersController { constructor(private readonly usersService: UsersService) {} @Get() // GET /users findAll() { return this.usersService.findAll(); } @Get(':id') // GET /users/:id findOne(@Param('id') id: string) { return this.usersService.findOne(id); } @Post() // POST /users create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); } @Put(':id') // PUT /users/:id update(@Param('id') id: string, @Body() dto: UpdateUserDto) { return this.usersService.update(id, dto); } @Delete(':id') // DELETE /users/:id remove(@Param('id') id: string) { return this.usersService.remove(id); }}NestJS 自动把返回值序列化为 JSON,状态码默认 200(POST 是 201)。不需要手动 res.json()。路由参数的获取方式| 装饰器 | 来源 | 示例 ||--------|------|------|| @Param('id') | 路径参数 | /users/42 → "42" || @Query('page') | 查询参数 | ?page=2 → "2" || @Body() | 请求体 | {"name": "Tom"} → { name: "Tom" } || @Headers('auth') | 请求头 | Authorization: Bearer ... || @Ip() | 客户端 IP | || @Session() | Express session | |路径参数@Get(':id')findOne(@Param('id') id: string) { // 拿单个参数 return this.usersService.findOne(id);}@Get(':category/:id')findByCategory( @Param('category') category: string, @Param('id') id: string, // 多个路径参数) {}查询参数@Get()findAll( @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, @Query('sort') sort?: string,) { return this.usersService.findAll({ page, limit, sort });}多个管道按参数位置从左到右执行:DefaultValuePipe 先设默认值,ParseIntPipe 再转数字。请求体 + DTO@Post()create(@Body() dto: CreateUserDto) { return this.usersService.create(dto);}DTO 配合 ValidationPipe 使用,验证逻辑在 DTO 类上声明,控制器保持干净。响应处理修改状态码@Post()@HttpCode(200) // POST 默认 201,改成 200create(@Body() dto: CreateUserDto) {}@Delete(':id')@HttpCode(204) // 删除成功返回 204 No Contentremove(@Param('id') id: string) {}设置响应头@Get()@Header('Cache-Control', 'max-age=3600')findAll() {}重定向@Get('docs')@Redirect('https://docs.nestjs.com', 302)redirectToDocs() {}动态重定向(返回值覆盖装饰器):@Get('docs')@Redirect('https://docs.nestjs.com')redirectToDocs(@Query('version') version?: string) { if (version === 'v7') { return { url: 'https://v7.docs.nestjs.com' }; }}流式响应大文件下载、SSE 等场景需要流式返回:import { Observable } from 'rxjs';@Get('stream')streamData(): Observable<MessageEvent> { return interval(1000).pipe( map(() => ({ data: `Time: ${new Date().toISOString()}` })), );}返回 Observable 或 Stream 时,NestJS 自动处理背压和清理。手动操作 Response需要完全控制响应时(如设置 cookie、自定义流),注入 @Res():import { Response } from 'express';@Get('download')download(@Res() res: Response) { res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', 'attachment; filename=report.pdf'); fs.createReadStream('report.pdf').pipe(res);}注意:一旦注入 @Res(),NestJS 不再自动序列化返回值——你必须自己调 res.json() 或 res.send()。如果只想设 cookie 但仍然用自动序列化,用 @Res({ passthrough: true }):@Post('login')login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) { const token = this.authService.login(dto); res.cookie('jwt', token, { httpOnly: true }); // 设 cookie return { message: '登录成功' }; // 返回值照常自动序列化}异步路由NestJS 天然支持 async/await,返回 Promise 就行:@Get()async findAll(): Promise<User[]> { return this.usersService.findAll(); // service 返回 Promise}也可以返回 RxJS Observable:@Get()findAll(): Observable<User[]> { return from(this.usersService.findAll());}路由版本控制API 版本升级时,同一接口需要同时支持 v1 和 v2:// main.ts 启用版本控制app.enableVersioning({ type: VersioningType.URI });@Controller('users')export class UsersController { @Get({ version: '1' }) // GET /v1/users findAllV1() { return this.usersService.findAllV1(); } @Get({ version: '2' }) // GET /v2/users findAllV2() { return this.usersService.findAllV2(); }}也可以用枚举或数组支持多个版本:version: ['1', '2']。请求生命周期一个请求进入控制器前后的完整链路:请求 → 中间件 → 守卫 → 拦截器(before) → 管道 → 控制器方法 → 拦截器(after) → 异常过滤器 → 响应控制器方法抛出的异常会被异常过滤器捕获。如果没有自定义过滤器,NestJS 内置的异常过滤器返回标准 JSON 错误:{ "statusCode": 404, "message": "User not found"}常见坑路由顺序:NestJS 按声明顺序匹配路由。@Get(':id') 在 @Get('profile') 前面的话,/users/profile 会被 :id 匹配,id 值变成 "profile"。把具体路由放在参数路由前面:@Controller('users')export class UsersController { @Get('profile') // ✅ 具体路由在前 getProfile() {} @Get(':id') // 参数路由在后 findOne(@Param('id') id: string) {}}返回 undefined:控制器方法返回 undefined 时,NestJS 返回空响应体和 200 状态码。如果你期望 204,要显式 @HttpCode(204)。@Body() 拿不到数据:需要全局启用 ValidationPipe 或确保 app.useBodyParser() 没被禁用。
服务端阅读 06月4日 15:43

NestJS提供者详解:四种注册方式、循环依赖和作用域选择

NestJS 的提供者(Provider)就是"可以被注入的东西"——@Injectable() 装饰的类,通过依赖注入(DI)容器管理生命周期,在控制器或其他服务里通过构造函数参数自动获得实例。服务是最常见的提供者,但提供者不只有服务:配置对象、数据库连接、工厂函数都可以是提供者。最常用的提供者:服务(Service)服务封装业务逻辑,控制器只负责接收请求和返回响应:// users.service.tsimport { Injectable } from '@nestjs/common';@Injectable()export class UsersService { private users = []; create(name: string, email: string) { const user = { id: Date.now(), name, email }; this.users.push(user); return user; } findAll() { return this.users; }}// users.controller.ts@Controller('users')export class UsersController { constructor(private readonly usersService: UsersService) {} // 自动注入 @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto.name, dto.email); } @Get() findAll() { return this.usersService.findAll(); }}在模块里注册:@Module({ controllers: [UsersController], providers: [UsersService], // 注册服务,DI 容器会自动创建实例})export class UsersModule {}提供者的四种注册方式useClass:默认方式providers: [UsersService]// 等价于:providers: [{ provide: UsersService, useClass: UsersService }]最常用,DI 容器自动 new 一个实例。useValue:提供常量或外部对象providers: [ { provide: 'API_KEY', useValue: process.env.API_KEY, // 直接给一个值 },]注入时用 @Inject() 指定令牌:constructor(@Inject('API_KEY') private apiKey: string) {}适合配置值、环境变量、第三方 SDK 实例等不需要 DI 创建的东西。useFactory:动态创建,可以注入依赖providers: [ { provide: 'DATABASE_CONNECTION', useFactory: (configService: ConfigService) => { return createConnection({ host: configService.get('DB_HOST'), port: configService.get('DB_PORT'), }); }, inject: [ConfigService], // 声明 useFactory 需要哪些依赖 },]useFactory 的参数由 inject 数组提供,DI 容器先解析 inject 里的依赖,再传给工厂函数。适合需要异步初始化、依赖其他服务的场景。useExisting:别名providers: [ UsersService, { provide: 'IUsersService', // 接口令牌 useExisting: UsersService, // 指向已有的提供者 },]接口在 TypeScript 编译后不存在,不能用 provide: IUsersService,用字符串或 Symbol 令牌 + useExisting 是标准做法。依赖注入令牌DI 容器通过令牌(token)匹配依赖。令牌可以是类、字符串或 Symbol:// 类令牌(最常见)constructor(private usersService: UsersService) {}// 字符串令牌constructor(@Inject('API_KEY') private apiKey: string) {}// Symbol 令牌(避免命名冲突)export const DATABASE_CONNECTION = Symbol('DATABASE_CONNECTION');constructor(@Inject(DATABASE_CONNECTION) private db: Connection) {}类令牌最好用——类型安全,不需要 @Inject() 装饰器。字符串和 Symbol 令牌用在没有对应类的场景。循环依赖两个服务互相依赖会报错:Circular dependency detected。// service-a 依赖 service-b,service-b 又依赖 service-a@Injectable()export class ServiceA { constructor(private serviceB: ServiceB) {} // ❌ 循环依赖}解决方案:用 forwardRef 延迟解析:@Injectable()export class ServiceA { constructor( @Inject(forwardRef(() => ServiceB)) private serviceB: ServiceB, ) {}}@Injectable()export class ServiceB { constructor( @Inject(forwardRef(() => ServiceA)) private serviceA: ServiceA, ) {}}模块里也要加 forwardRef:@Module({ imports: [forwardRef(() => ServiceBModule)],})export class ServiceAModule {}但 forwardRef 只是绕过了报错,说明设计有问题——更好的做法是提取公共逻辑到第三个服务,或者通过事件解耦。提供者作用域默认情况下所有提供者都是单例(Singleton)——整个应用共享一个实例。NestJS 支持三种作用域:| 作用域 | 生命周期 | 适用场景 ||--------|----------|----------|| DEFAULT(单例) | 应用启动时创建,共享 | 几乎所有服务 || REQUEST | 每个请求创建一个实例 | 请求上下文数据(如当前用户) || TRANSIENT | 每次注入都创建新实例 | 无状态的临时对象 |@Injectable({ scope: Scope.REQUEST })export class RequestContextService { private userId: string; setUserId(id: string) { this.userId = id; } getUserId() { return this.userId; }}注意:REQUEST 作用域的服务,注入它的控制器也必须是 REQUEST 作用域。而且 REQUEST 作用域会显著影响性能——每个请求都创建新实例,数据库连接等资源不能共享。大多数场景用 DEFAULT + 在请求对象上挂数据就够了。模块间共享提供者默认情况下,模块的提供者对外不可见。要让其他模块用你的服务,必须 export:// users.module.ts@Module({ controllers: [UsersController], providers: [UsersService], exports: [UsersService], // 暴露给其他模块})export class UsersModule {}其他模块 import 后就能注入 UsersService:// posts.module.ts@Module({ imports: [UsersModule], // import 整个模块 providers: [PostsService],})export class PostsModule {}// posts.service.ts@Injectable()export class PostsService { constructor(private usersService: UsersService) {} // 可以用了}关键规则:import 模块,注入 export 的服务。不能直接 import 服务,也不能注入没 export 的服务。可选注入某些依赖不是必须的,找不到时不报错:import { Optional } from '@nestjs/common';@Injectable()export class MyService { constructor(@Optional() private cacheService?: CacheService) {} getData() { return this.cacheService?.get('key') ?? this.fetchFromDB(); }}有 CacheService 就用缓存,没有就直接查数据库。适合功能增强型依赖。
服务端阅读 06月4日 15:42

NestJS中间件和守卫有什么区别?各自适用场景和RBAC实现

NestJS 里中间件和守卫都能拦截请求,很多人搞不清该用哪个。一句话区分:中间件不知道下一站是谁,守卫知道。中间件只能看到原始的 HTTP 请求/响应,守卫能拿到 ExecutionContext,知道当前请求要调用哪个控制器、哪个方法。这个区别决定了各自的职责:中间件做通用预处理(日志、CORS),守卫做权限判断(认证、授权)。核心区别对比| | 中间件(Middleware) | 守卫(Guard) ||---|---|---|| 能看到什么 | req、res、next | ExecutionContext(含控制器、方法元信息) || 能否访问 DI 容器 | 不能(函数式中间件) | 可以(@Injectable()) || 作用范围 | 模块级或全局 | 方法级、控制器级、全局 || 能否用装饰器元数据 | 不能 | 能(Reflector + SetMetadata) || 执行时机 | 最早(路由匹配之前) | 守卫之后,管道之前 || 典型用途 | 日志、CORS、请求转换 | 认证、授权、角色检查 || 返回值 | 无(调 next() 放行) | boolean / Promise<boolean> |中间件:看不到终点站的通用处理中间件直接来自 Express 的概念,签名是 (req, res, next) => void:import { Injectable, NestMiddleware } from '@nestjs/common';import { Request, Response, NextFunction } from 'express';@Injectable()export class LoggerMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { console.log(`${req.method} ${req.url}`); next(); // 放行,必须调,否则请求卡住 }}在模块里注册(中间件不能装饰器注册):@Module({})export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer .apply(LoggerMiddleware) .forRoutes('users') // 只对 /users 路由生效 .exclude({ path: 'users', method: RequestMethod.GET }) // 排除 GET }}中间件能做什么日志:记录请求方法、路径、耗时CORS:跨域配置请求转换:解析 body、压缩响应限流:简单的 IP 级别限流中间件不能做什么权限判断:中间件拿不到当前要调用的控制器方法,不知道这个接口需要什么角色读取装饰器元数据:Reflector 在中间件里不可用精细路由控制:只能在模块级别通过路径匹配,不能按方法粒度守卫:知道要去哪,所以能判断能不能去守卫实现 CanActivate 接口,返回 true 放行、false 拒绝(返回 403):import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';@Injectable()export class AuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); return !!request.user; // 有 user 就放行 }}使用:@Controller('users')@UseGuards(AuthGuard) // 整个控制器都要认证export class UsersController { @Get() findAll() { /* ... */ } @Post() @UseGuards(AdminGuard) // 这个方法额外要管理员权限 create() { /* ... */ }}基于角色的权限控制(RBAC)守卫真正的威力是配合 SetMetadata + Reflector 实现声明式权限:// 自定义装饰器import { SetMetadata } from '@nestjs/common';export const Roles = (...roles: string[]) => SetMetadata('roles', roles);// 守卫里读取元数据import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';import { Reflector } from '@nestjs/core';@Injectable()export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { // 拿到方法或控制器上 @Roles() 标注的角色 const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [ context.getHandler(), // 方法级别的元数据 context.getClass(), // 控制器级别的元数据 ]); if (!requiredRoles) return true; // 没标注角色,放行 const request = context.switchToHttp().getRequest(); return requiredRoles.some(role => request.user?.roles?.includes(role)); }}控制器上使用:@Controller('admin')@UseGuards(AuthGuard, RolesGuard)@Roles('admin') // 整个控制器需要 admin 角色export class AdminController { @Get('dashboard') dashboard() { /* ... */ } @Get('users') @Roles('admin', 'superadmin') // 这个方法需要 admin 或 superadmin listUsers() { /* ... */ }}这是中间件做不到的——中间件拿不到 @Roles('admin') 这个元数据,也不知道当前请求匹配的是哪个方法。守卫里注入服务守卫是 @Injectable() 的,可以注入数据库、缓存等服务:@Injectable()export class AuthGuard implements CanActivate { constructor(private jwtService: JwtService) {} async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest(); const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) return false; try { request.user = await this.jwtService.verifyAsync(token); return true; } catch { return false; } }}执行顺序一个请求经过的完整链路:客户端请求 → 中间件 → 守卫 → 拦截器(before) → 管道 → 控制器 → 拦截器(after) → 过滤器中间件最先执行,适合做不依赖业务逻辑的通用处理。守卫在中间件之后,能用中间件预处理的结果(如解析出的 token)做权限判断。权限不通过直接返回 403,不会走到管道和控制器。什么时候用哪个| 场景 | 用什么 | 原因 ||------|--------|------|| 请求日志 | 中间件 | 不需要知道目标方法 || CORS 配置 | 中间件 | 通用 HTTP 头处理 || 请求限流 | 中间件 | 按 IP/路由限流,不涉及业务 || JWT 验证 | 守卫 | 需要注入 JwtService,需要设置 request.user || 角色权限 | 守卫 | 需要读取 @Roles() 元数据 || API Key 验证 | 守卫 | 需要查询数据库验证 key || 请求体转换 | 中间件 | 纯数据处理,不涉及权限 || 多租户隔离 | 守卫 | 需要根据路由决定查询哪个租户的数据 |判断口诀:只看 HTTP 不看业务 → 中间件;要看路由决定权限 → 守卫。
服务端阅读 06月4日 15:41

NestJS管道和验证:class-validator配置、自定义Pipe和常见坑

NestJS 的管道(Pipe)就两件事:转换和验证。转换是把字符串参数变成数字、把日期字符串变成 Date 对象;验证是检查请求体里的字段是否合法,不合法就拒绝请求。听起来简单,但管道是 NestJS 请求生命周期里的关键一环——守卫之后、控制器之前,数据必须过管道这一关。管道的两种用途转换:把输入数据转成目标类型(如 ParseIntPipe 把路由参数 "42" 变成数字 42)验证:检查输入数据是否合法,不合法抛异常(如 class-validator 检查 email 格式)一个管道可以只做转换、只做验证,或者两者都做。NestJS 内置的管道偏向转换,实际项目里的验证管道通常结合 class-validator。内置管道ParseIntPipe:路由参数转数字@Get(':id')findOne(@Param('id', ParseIntPipe) id: number) { // 如果 id 不是数字,自动返回 400 Bad Request return this.usersService.findOne(id);}不加 ParseIntPipe,id 是字符串 "42",你的 service 里拿到的类型和声明不一致。加了之后,不合法的值直接被拦截,控制器方法不会被调用。ParseUUIDPipe@Get(':id')findOne(@Param('id', new ParseUUIDPipe()) id: string) { return this.usersService.findOne(id);}验证 UUID 格式。非法 UUID 返回 400,不需要自己写正则。ParseArrayPipe@Get()findAll(@Query('ids', new ParseArrayPipe({ items: String, separator: ',' })) ids: string[]) { // ?ids=a,b,c → ['a', 'b', 'c'] return this.usersService.findByIds(ids);}DefaultValuePipe@Get()findAll( @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,) { return this.usersService.findAll({ page, limit });}DefaultValuePipe 放在 ParseIntPipe 前面——先设默认值,再转数字。管道按参数顺序从左到右执行。用 class-validator 做请求体验证这是实际项目里用得最多的验证方式。安装依赖npm install class-validator class-transformer定义 DTOimport { IsString, IsEmail, IsInt, Min, IsOptional, IsEnum } from 'class-validator';export class CreateUserDto { @IsString() name: string; @IsEmail() email: string; @IsInt() @Min(0) age: number; @IsEnum(['admin', 'user']) @IsOptional() role?: string;}启用全局 ValidationPipe// main.tsimport { ValidationPipe } from '@nestjs/common';async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes( new ValidationPipe({ whitelist: true, // 自动剥离 DTO 里没定义的字段 forbidNonWhitelisted: true, // 有多余字段时返回 400 而不是静默忽略 transform: true, // 自动把普通对象转成 DTO 类实例(class-transformer 生效) transformOptions: { enableImplicitConversion: true, // 自动类型转换(字符串 → 数字等) }, }), ); await app.listen(3000);}三个选项都很重要:whitelist: true — 防止客户端传入多余字段(如 isAdmin: true),只保留 DTO 中定义的属性forbidNonWhitelisted: true — 配合 whitelist,有多余字段直接报错,而不是静默丢弃transform: true — 让 class-transformer 的 @Type()、@Exclude() 等装饰器生效,否则 DTO 上的装饰器不会被执行嵌套对象验证如果 DTO 里有对象类型的属性,必须加 @ValidateNested() 和 @Type():import { ValidateNested } from 'class-validator';import { Type } from 'class-transformer';class AddressDto { @IsString() city: string; @IsString() street: string;}export class CreateUserDto { @IsString() name: string; @ValidateNested() @Type(() => AddressDto) // 必须指定类型,否则 class-transformer 不知道怎么实例化 address: AddressDto;}不加 @Type() 的话,address 仍然是一个普通 JS 对象,@ValidateNested() 无法对其中的属性做验证。自定义错误消息export class CreateUserDto { @IsString({ message: '用户名必须是字符串' }) name: string; @IsEmail({}, { message: '邮箱格式不正确' }) email: string;}或者统一格式:new ValidationPipe({ exceptionFactory: (errors) => { const messages = errors.map(err => ({ field: err.property, constraints: Object.values(err.constraints || {}), })); return new BadRequestException({ statusCode: 400, message: '输入验证失败', errors: messages }); },})自定义管道内置管道不够用时,写自己的:import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';@Injectable()export class ParseDatePipe implements PipeTransform<string, Date> { transform(value: string, metadata: ArgumentMetadata): Date { const date = new Date(value); if (isNaN(date.getTime())) { throw new BadRequestException(`"${value}" 不是有效的日期`); } return date; }}使用:@Get(':date')findByDate(@Param('date', ParseDatePipe) date: Date) { return this.recordsService.findByDate(date);}管道的作用范围管道可以用在四个层级:| 层级 | 写法 | 影响范围 ||------|------|----------|| 参数 | @Param('id', ParseIntPipe) | 只验证这一个参数 || 方法 | @UsePipes(new ValidationPipe()) | 这个路由方法的所有参数 || 控制器 | @Controller() @UsePipes(...) | 这个控制器所有方法 || 全局 | app.useGlobalPipes(...) | 整个应用 |全局管道有两种注册方式:// 方式一:main.ts 里直接用(无法注入依赖)app.useGlobalPipes(new ValidationPipe());// 方式二:模块里用 token 注册(可以注入依赖)import { APP_PIPE } from '@nestjs/core';@Module({ providers: [{ provide: APP_PIPE, useClass: ValidationPipe }],})export class AppModule {}如果你的管道需要注入其他服务(如数据库查询),必须用方式二,方式一拿不到依赖注入容器。请求生命周期中的位置NestJS 处理一个请求的顺序:中间件 → 守卫 → 拦截器(before) → 管道 → 控制器 → 拦截器(after) → 过滤器管道在守卫之后、控制器之前。这意味着守卫可以先判断权限,没权限直接拒绝,不会走到管道的验证逻辑。管道验证失败抛出的异常,会被异常过滤器捕获。
服务端阅读 06月2日 01:30

NestJS 是什么?和 Express 有什么区别?核心概念和应用场景

NestJS 是一个 Node.js 后端框架,底层用 Express(或 Fastify)做 HTTP 处理,上层加了模块化架构、依赖注入、装饰器语法。你可以把 NestJS 理解为 Node.js 版的 Spring Boot——同样的分层架构、同样的开箱即用。NestJS vs ExpressExpress 是一个极简的 HTTP 路由库,给你一个 app.get() 然后自由发挥。项目小的时候很爽,项目大了没有约束——路由、中间件、数据库连接、业务逻辑全混在一起,没人知道代码应该放哪。NestJS 解决的是"团队协作时的代码组织"问题:Module 划分功能边界(用户模块、订单模块、支付模块互不干扰)Controller 处理 HTTP 请求,只做参数校验和路由转发Service 处理业务逻辑,可以被多个 Controller 复用依赖注入自动管理实例创建和依赖关系,不用手动 new 和传参Express 5000 行代码以上的项目,不靠团队约定基本没法维护。NestJS 通过架构约束让你写出的代码天然就是分层的。核心概念Module(模块):组织代码的边界。每个功能模块有自己的 Controller、Service、Provider。Module 之间通过 imports 和 exports 通信,类似 JavaScript 的模块化但更严格——不 export 的东西外部不可见。Controller(控制器):处理 HTTP 请求。用装饰器定义路由:@Controller('users')export class UsersController { @Get() findAll() { return this.usersService.findAll(); } @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); }}Service(服务):业务逻辑层。Controller 调 Service,Service 调数据库和外部 API。Service 用 @Injectable() 装饰,可以被依赖注入。DTO(数据传输对象):定义请求/响应的数据结构,配合 class-validator 做参数校验:export class CreateUserDto { @IsEmail() email: string; @MinLength(8) password: string;}NestJS 自动根据 DTO 校验请求参数,校验失败返回 400。适合什么项目中大型后端 API:用户系统、订单系统、管理后台——多模块、需要权限控制微服务:NestJS 内置多种 Transport(Redis、RabbitMQ、Kafka、gRPC)GraphQL API:官方 @nestjs/graphql 模块,代码优先(Code-First)定义 Schema实时应用:内置 WebSocket/Socket.IO 支持不适合:简单的静态站点、只有一个接口的轻量服务——用 Express 或 Fastify 就够了,NestJS 的架构开销不值得。快速开始npm i -g @nestjs/clinest new my-projectcd my-projectnpm run start:dev生成的项目结构:src/├── app.module.ts # 根模块├── app.controller.ts # 根控制器├── app.service.ts # 根服务└── main.ts # 入口文件添加模块:nest g module users、nest g controller users、nest g service users——CLI 自动创建文件并注册到 Module 里。
服务端阅读 06月2日 01:29

NestJS 依赖注入是怎么工作的?Module、Provider 和注入机制详解

NestJS 的依赖注入(DI)是从 Angular 借鉴的核心机制。你不需要手动创建实例和传递依赖——在 Provider 里声明,在构造函数里接收,Nest 容器自动装配。Module 是组织 Provider 的边界,控制哪些可以对外暴露、哪些只在内部使用。依赖注入基本原理没有 DI 的写法:手动创建依赖,耦合度高。// 没有 DIconst repo = new UserRepository();const service = new UserService(repo);const controller = new UserController(service);有 DI 的写法:只声明需要什么,Nest 自动注入。@Controller('users')export class UsersController { constructor(private usersService: UsersService) {} // 自动注入}Nest 的 IoC 容器在启动时扫描所有 Module,根据构造函数的参数类型自动创建和注入实例。private 关键字同时声明和赋值——TypeScript 的参数属性简写。Module:组织代码的边界每个 Module 是一个独立的 DI 容器。Module 里注册的 Provider 默认只在 Module 内部可用。@Module({ controllers: [UsersController], providers: [UsersService], exports: [UsersService], // 对外暴露})export class UsersModule {}providers:注册到本 Module 的服务,本 Module 内可注入exports:声明哪些 Provider 可以被其他 Module 使用imports:导入其他 Module 暴露的 Provider@Module({ imports: [UsersModule], // 导入后可以注入 UsersService providers: [PostsService],})export class PostsModule {}如果不 exports: [UsersService],PostsModule 里注入 UsersService 会报错——Nest 找不到这个 Provider。Provider 的三种注册方式1. 类名注册(最常见)providers: [UsersService]// 等价于providers: [{ provide: UsersService, useClass: UsersService }]2. 值注册(Mock 或配置对象)providers: [{ provide: 'CONFIG', useValue: { dbHost: 'localhost', port: 5432 },}]3. 工厂注册(动态创建,依赖其他 Provider)providers: [{ provide: 'DATABASE_CONNECTION', useFactory: (configService: ConfigService) => { return createConnection(configService.get('db')); }, inject: [ConfigService], // 工厂的依赖}]注入方式构造函数注入(推荐):constructor(private usersService: UsersService) {}属性注入(可选依赖):@Inject('CONFIG')config: ConfigType<typeof config>;用 @Inject() 指定 token——当 Provider 不是用类名注册时(字符串 token、Symbol token),必须显式指定。作用域默认情况下所有 Provider 是 Singleton(单例)——整个应用共享一个实例。这是最高效的模式,也是 99% 场景的正确选择。其他作用域:| 作用域 | 生命周期 | 适用场景 ||--------|----------|----------|| DEFAULT(Singleton) | 应用启动时创建,共享 | 几乎所有情况 || REQUEST | 每个请求创建新实例 | 需要请求上下文(如租户隔离) || TRANSIENT | 每次注入都创建新实例 | 极少用 |@Injectable({ scope: Scope.REQUEST })export class RequestLogger {}不要随意改作用域——REQUEST scope 会显著增加内存和 GC 压力,因为每个请求都要创建和销毁实例。循环依赖A 依赖 B,B 依赖 A——Nest 无法确定先创建谁。解决方式:用 forwardRef 延迟解析。@Module({ imports: [forwardRef(() => ModuleB)],})export class ModuleA {}constructor( @Inject(forwardRef(() => ServiceB)) private serviceB: ServiceB,) {}但循环依赖通常意味着设计有问题——考虑抽取共享逻辑到第三个 Module 里。
服务端阅读 06月2日 01:27

NestJS 怎么写测试?单元测试、E2E 测试和 Mock 实战

NestJS 内置 Jest 支持,开箱即用。测试分两层:单元测试(测 Service/Controller 的逻辑)和 E2E 测试(测整个请求链路)。关键是学会 Mock 依赖——单元测试不应该依赖数据库或外部服务。单元测试:测 Service// users/users.service.spec.tsdescribe('UsersService', () => { let service: UsersService; let repo: Repository<User>; beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ UsersService, { provide: getRepositoryToken(User), useValue: { findOne: jest.fn(), create: jest.fn(), save: jest.fn(), }, }, ], }).compile(); service = module.get(UsersService); repo = module.get(getRepositoryToken(User)); }); it('findByEmail 应该返回用户', async () => { const mockUser = { id: '1', email: 'test@test.com' }; jest.spyOn(repo, 'findOne').mockResolvedValue(mockUser as User); const result = await service.findByEmail('test@test.com'); expect(result).toEqual(mockUser); expect(repo.findOne).toHaveBeenCalledWith({ where: { email: 'test@test.com' } }); });});Test.createTestingModule 创建一个精简的 Nest 容器,只注册需要测试的 Provider。useValue 用 Jest mock 替换真实的 Repository——测试不碰数据库,跑得快而且稳定。测 ControllerController 的测试重点是"路由是否正确调用 Service":describe('UsersController', () => { let controller: UsersController; let service: UsersService; beforeEach(async () => { const module = await Test.createTestingModule({ controllers: [UsersController], providers: [ { provide: UsersService, useValue: { findOne: jest.fn() } }, ], }).compile(); controller = module.get(UsersController); service = module.get(UsersService); }); it('getUser 应该调用 service.findOne', async () => { jest.spyOn(service, 'findOne').mockResolvedValue({ id: '1', name: 'Test' } as User); await controller.getUser('1'); expect(service.findOne).toHaveBeenCalledWith('1'); });});Controller 测试不需要 HTTP 服务器——Nest 的测试工具直接调用方法,省去了网络层开销。E2E 测试:测完整请求E2E 测试启动完整的 HTTP 服务器,发真实请求,验证整个链路:// test/app.e2e-spec.tsdescribe('App (e2e)', () => { let app: INestApplication; beforeAll(async () => { const moduleFixture = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); }); it('GET /users/:id', () => { return request(app.getHttpServer()) .get('/users/1') .expect(200) .expect({ id: '1', name: 'Test' }); }); afterAll(async () => { await app.close(); });});E2E 测试需要数据库。用 testcontainers 启动 Docker 容器里的 Postgres,测完自动销毁:import { PostgreSqlContainer } from '@testcontainers/postgresql';const container = await new PostgreSqlContainer().start();process.env.DB_HOST = container.getHost();process.env.DB_PORT = container.getPort().toString();Mock 外部服务如果 Service 调用第三方 API(如支付、邮件),测试时不应该真的调:providers: [ PaymentService, { provide: 'PAYMENT_CLIENT', useValue: { charge: jest.fn().mockResolvedValue({ success: true }) }, },],用自定义 Provider 替换外部服务客户端。测试只验证你的代码逻辑是否正确,不验证第三方服务是否正常。测试覆盖率npm run test:cov目标:Service 层覆盖率 > 80%,Controller 层 > 70%。不要追求 100%——getter/setter、简单委托方法不值得写测试。重点测试业务逻辑分支和边界条件。测试最佳实践1. 测试文件和源文件同目录:users.service.spec.ts 放在 users.service.ts 旁边,比放在单独的 __tests__/ 目录更好找。2. beforeAll vs beforeEach:beforeAll 创建的容器所有测试共享(快),beforeEach 每个测试前重新创建(隔离)。单元测试用 beforeEach 保证隔离,E2E 测试用 beforeAll 加速。3. 别测试框架本身:不需要测 @Get() 装饰器能不能工作,NestJS 自己有测试。测你的业务逻辑——Service 返回的数据对不对,Controller 有没有调对 Service 方法。
服务端阅读 06月2日 01:26

NestJS 怎么做实时通信?WebSocket Gateway 和 Socket.IO 集成

NestJS 的 WebSocket 支持基于 Socket.IO,用装饰器风格的 Gateway 替代传统的事件监听写法。和 HTTP Controller 几乎一样的开发体验,底层自动处理连接、重连、房间管理。基本 Gatewaynpm install @nestjs/websockets @nestjs/platform-socket.io socket.io// chat/chat.gateway.ts@Gateway({ cors: { origin: '*' } })export class ChatGateway { @SubscribeMessage('send_message') handleMessage(@MessageBody() data: string, @ConnectedSocket() client: Socket) { // 广播给所有连接的客户端 this.server.emit('new_message', data); } @WebSocketServer() server: Server;}Gateway 就是 WebSocket 版的 Controller。@SubscribeMessage 等价于 @Post,@MessageBody 等价于 @Body,@ConnectedSocket 可以拿到底层 Socket 实例。房间(Rooms)Socket.IO 的房间机制让消息只发给特定用户群:@Gateway()export class RoomGateway { @WebSocketServer() server: Server; @SubscribeMessage('join_room') handleJoinRoom(@MessageBody() room: string, @ConnectedSocket() client: Socket) { client.join(room); this.server.to(room).emit('user_joined', client.id); } @SubscribeMessage('room_message') handleRoomMessage( @MessageBody() data: { room: string; message: string }, @ConnectedSocket() client: Socket, ) { this.server.to(data.room).emit('new_message', { from: client.id, message: data.message, }); }}client.join(room) 加入房间,this.server.to(room).emit() 只发给该房间的成员。认证:验证 WebSocket 连接WebSocket 连接不能用 HTTP Guard,要在握手阶段验证 Token:@Gateway()export class ChatGateway implements OnGatewayConnection { handleConnection(client: Socket) { const token = client.handshake.auth.token; try { const payload = this.jwtService.verify(token); client.data.user = payload; // 存到 socket 上供后续使用 } catch { client.disconnect(); // Token 无效直接断开 } }}客户端连接时带上 Token:const socket = io('http://localhost:3000', { auth: { token: 'your-jwt-token' }});配合 HTTP ControllerWebSocket 和 HTTP 可以共享 Service 层:// messages/messages.module.ts@Module({ imports: [TypeOrmModule.forFeature([Message])], providers: [MessagesService, MessagesGateway], controllers: [MessagesController],})export class MessagesModule {}// messages/messages.controller.ts - HTTP 接口拿历史消息@Get('history/:roomId')getHistory(@Param('roomId') roomId: string) { return this.messagesService.getHistory(roomId);}// messages/messages.gateway.ts - WebSocket 推新消息@SubscribeMessage('send_message')async handleSendMessage(@MessageBody() data: { roomId: string; content: string }, @ConnectedSocket() client: Socket) { const message = await this.messagesService.create({ content: data.content, roomId: data.roomId, userId: client.data.user.id, }); this.server.to(data.roomId).emit('new_message', message);}HTTP 负责历史数据的 CRUD,WebSocket 负责实时推送。两者共享同一个 Service 和数据库。常见问题连接频繁断开重连:通常是 Nginx 代理超时。加 proxy_read_timeout 3600s 和 proxy_send_timeout 3600s,否则 Nginx 60 秒没数据就断开长连接。内存泄漏:每个 Socket 连接占用约 10-50KB 内存。1 万个连接约 500MB。确保断开的客户端被正确清理——Socket.IO 默认有心跳检测,但最好加 pingTimeout 和 pingInterval 调优。集群模式:单机多进程时,Socket.IO 的房间信息不跨进程共享。需要用 Redis Adapter:import { RedisIoAdapter } from '@nestjs/platform-socket.io';const redisIoAdapter = new RedisIoAdapter(app);await redisIoAdapter.connectToRedis('redis://localhost:6379');app.useWebSocketAdapter(redisIoAdapter);Redis Adapter 让所有进程通过 Redis 共享房间和消息状态。
服务端阅读 06月2日 01:25

NestJS 微服务怎么设计?Transport 层、消息模式和架构选型

NestJS 的微服务支持不是"把单体拆成微服务"的完整方案,而是提供了跨服务通信的 Transport 层。你可以用同样的 Controller/Service 写法,底层换成 Redis/RabbitMQ/Kafka/gRPC 通信,应用代码几乎不用改。微服务模式 vs 单体NestJS 应用默认是 HTTP 单体。改成微服务只需要换一个传输层:// main.ts - HTTP 单体const app = await NestFactory.create(AppModule);await app.listen(3000);// main.ts - 微服务const app = await NestFactory.createMicroservice(AppModule, { transport: Transport.REDIS, options: { url: 'redis://localhost:6379' },});await app.listen();微服务模式下,应用不再监听 HTTP 端口,而是通过消息队列接收请求。通信模式请求/响应(Request/Response)和 HTTP 一样——发请求等响应。适合需要立即拿到结果的场景。// 服务端@MessagePattern('get_user')getUser(@Payload() id: string) { return this.usersService.findOne(id);}// 客户端@Injectable()export class AppService { constructor(@Inject('USER_SERVICE') private client: ClientProxy) {} getUser(id: string) { return this.client.send('get_user', id); }}send 返回 Observable,可以用 .toPromise() 或 firstValueFrom() 转成 Promise。事件驱动(Event-driven)发出去不等响应——"fire and forget"。适合通知类场景(发邮件、写日志、更新缓存)。// 发布者this.client.emit('user_created', { userId: user.id });// 订阅者@EventPattern('user_created')handleUserCreated(@Payload() data: { userId: string }) { // 发送欢迎邮件、更新统计等}emit 不返回结果,订阅者可以有多个(广播模式)。请求/响应模式只会有一个服务响应。Transport 选型| Transport | 适用场景 | 特点 ||-----------|----------|------|| TCP | 开发/测试 | 默认,零依赖 || Redis | 简单生产环境 | Pub/Sub 模式,需要 Redis || RabbitMQ | 企业级 | 消息确认、重试、路由 || Kafka | 高吞吐 | 日志流、事件溯源 || gRPC | 高性能 RPC | 强类型、Protobuf |开发阶段用 TCP,不需要装任何中间件。生产环境看需求:简单场景用 Redis,复杂路由和消息确认用 RabbitMQ,日志和流处理用 Kafka。混合模式:HTTP + 微服务大部分现实应用需要一个 HTTP 入口 + 内部微服务通信。NestJS 支持混合模式:// main.tsconst app = await NestFactory.create(AppModule);const microservice = app.connectMicroservice({ transport: Transport.REDIS, options: { url: 'redis://localhost:6379' },});await app.startAllMicroservices();await app.listen(3000);这样应用同时监听 HTTP(给前端用)和 Redis 消息(给其他微服务用)。API Gateway 模式通常是这种——外部请求走 HTTP,内部服务间走消息队列。什么时候该用微服务不要因为"微服务是趋势"就拆。微服务引入的复杂度(部署、调试、数据一致性)对小团队是灾难。适合微服务的信号:团队超过 10 人,需要独立部署不同模块某个模块有独立的伸缩需求(比如报表生成吃 CPU,需要单独扩容)不同模块的技术栈差异大(一个用 Node,一个用 Python)不适合微服务的信号:3-5 人团队模块间数据高度耦合没有自动化部署和监控基础设施大多数项目从模块化单体(Modular Monolith)开始更安全——NestJS 的 Module 本身就是天然的模块边界,等真正需要时再拆微服务,代码几乎不用改,只需要换 Transport。
服务端阅读 06月2日 01:25

NestJS 怎么连数据库?TypeORM 和 Prisma 集成实战

NestJS 集成数据库的主流方案有两个:TypeORM(官方推荐,装饰器风格)和 Prisma(类型安全更好,迁移体验好)。选哪个看团队偏好——TypeORM 和 NestJS 风格统一,Prisma 的类型推导更强。TypeORM 集成npm install @nestjs/typeorm typeorm pg配置连接// app.module.tsimport { TypeOrmModule } from '@nestjs/typeorm';@Module({ imports: [ TypeOrmModule.forRoot({ type: 'postgres', host: process.env.DB_HOST, port: 5432, username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, autoLoadEntities: true, synchronize: false, // 生产环境必须 false }), ],})export class AppModule {}autoLoadEntities 自动加载注册了 TypeOrmModule.forFeature() 的实体,不用手动列。synchronize: false 防止生产环境自动改表结构(丢数据风险)。定义实体// users/user.entity.ts@Entity()export class User { @PrimaryGeneratedColumn('uuid') id: string; @Column({ unique: true }) email: string; @Column() password: string; @CreateDateColumn() createdAt: Date;}在模块中注册// users/users.module.ts@Module({ imports: [TypeOrmModule.forFeature([User])], providers: [UsersService], controllers: [UsersController],})export class UsersModule {}使用 Repository// users/users.service.ts@Injectable()export class UsersService { constructor( @InjectRepository(User) private userRepo: Repository<User>, ) {} async findByEmail(email: string) { return this.userRepo.findOne({ where: { email } }); } async create(data: Partial<User>) { const user = this.userRepo.create(data); return this.userRepo.save(user); }}Prisma 集成npm install prisma @prisma/clientnpx prisma init定义 Schema// prisma/schema.prismamodel User { id String @id @default(uuid()) email String @unique password String createdAt DateTime @default(now()) posts Post[]}model Post { id String @id @default(uuid()) title String content String? authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now())}生成迁移并应用npx prisma migrate dev --name init在 NestJS 中使用// prisma/prisma.service.ts@Injectable()export class PrismaService extends PrismaClient implements OnModuleInit { async onModuleInit() { await this.$connect(); }}// users/users.service.ts@Injectable()export class UsersService { constructor(private prisma: PrismaService) {} async findByEmail(email: string) { return this.prisma.user.findUnique({ where: { email } }); } async create(data: { email: string; password: string }) { return this.prisma.user.create({ data }); }}TypeORM vs Prisma 怎么选| 维度 | TypeORM | Prisma ||------|---------|--------|| 查询方式 | 装饰器 + Repository | 链式 API(自动补全强) || 类型安全 | 需要手动标注 | 自动推导,几乎不需要类型标注 || 迁移工具 | 内置但体验一般 | 最好,migrate dev 自动生成 SQL || 关联查询 | 容易写出 N+1 | include 语法清晰 || 社区 | NestJS 官方推荐 | 增长快,2024-2025 很多人从 TypeORM 迁过来 |新项目推荐 Prisma——类型安全和迁移体验的优势在项目做大后会越来越明显。已有 TypeORM 的项目不必迁移,两者都能正常工作。数据库迁移生产环境绝对不能依赖 synchronize: true(自动同步 schema 到数据库),这会静默删列、改类型,导致数据丢失。TypeORM 迁移:npx typeorm migration:generate -n AddUserTablenpx typeorm migration:runPrisma 迁移:npx prisma migrate dev --name add_user_table # 开发npx prisma migrate deploy # 生产migrate dev 生成迁移文件并应用,migrate deploy 只应用已有文件,适合 CI/CD。
服务端阅读 06月2日 01:23

NestJS 怎么做认证和授权?JWT、Guards 和 RBAC 实战

NestJS 的认证授权用 Guards(守卫)+ 策略模式实现。认证(Authentication)验证"你是谁",授权(Authorization)验证"你能做什么"。JWT 是最常用的认证方案,RBAC 是最常用的授权模型。JWT 认证安装依赖:npm install @nestjs/jwt @nestjs/passport passport passport-jwt配置 JWT 策略// auth/jwt.strategy.ts@Injectable()export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: process.env.JWT_SECRET, }); } async validate(payload: { sub: string; email: string }) { return { id: payload.sub, email: payload.email }; }}validate 的返回值会挂到 req.user 上。Passport 自动验证 JWT 签名和过期时间,你只需要解析 payload。登录签发 Token// auth/auth.service.ts@Injectable()export class AuthService { constructor(private jwtService: JwtService) {} async login(email: string, password: string) { const user = await this.validateUser(email, password); const payload = { sub: user.id, email: user.email }; return { access_token: this.jwtService.sign(payload), }; }}保护路由@UseGuards(AuthGuard('jwt'))@Get('profile')getProfile(@Request() req) { return req.user;}没带 Token 或 Token 过期的请求返回 401。RBAC 角色授权认证只解决"你是谁",授权解决"你能做什么"。// auth/roles.decorator.tsexport const Roles = (...roles: string[]) => SetMetadata('roles', roles);// auth/roles.guard.ts@Injectable()export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler()); if (!requiredRoles) return true; const { user } = context.switchToHttp().getRequest(); return requiredRoles.some(role => user.roles?.includes(role)); }}使用:@UseGuards(AuthGuard('jwt'), RolesGuard)@Roles('admin')@Delete('users/:id')removeUser() { return '仅管理员可操作';}先过 AuthGuard 验证身份(拿到 user),再过 RolesGuard 验证角色。顺序不能反——没有 user 对象就没法检查角色。密码安全永远不要明文存密码。用 bcrypt 哈希:import * as bcrypt from 'bcrypt';// 注册时哈希const hash = await bcrypt.hash(password, 10);// 登录时验证const isValid = await bcrypt.compare(inputPassword, hash);10 是 salt rounds,越大越安全但越慢。10 是当前推荐值,每增加 1 耗时翻倍。常见安全措施1. 限流防暴力破解:用 @nestjs/throttler 限制登录接口的请求频率。ThrottlerModule.forRoot([{ ttl: 60000, limit: 5 }]),60 秒内同一 IP 最多 5 次登录请求。2. CORS 配置:只允许可信域名访问。app.enableCors({ origin: ['https://your-app.com'] });3. Helmet 设置安全头:npm i helmet,app.use(helmet())。自动加上 X-Content-Type-Options、X-Frame-Options 等安全响应头。4. 输入验证:用 class-validator 的 DTO 验证所有入参,防止注入攻击。export class CreateUserDto { @IsEmail() email: string; @MinLength(8) password: string;}Token 刷新JWT 一旦签发无法撤销(这是无状态的设计)。如果用户改了密码或被踢下线,旧的 Token 仍然有效直到过期。两种解决方式:短过期 + Refresh Token:accesstoken 15 分钟过期,refreshtoken 7 天过期。refresh_token 存数据库可以主动撤销Token 黑名单:过期前把 Token 加入 Redis 黑名单,每次请求检查黑名单。牺牲了无状态的优点但更安全
服务端阅读 05月27日 23:03

NestJS 性能优化有哪些方法?

为什么 NestJS 应用需要性能优化NestJS 默认基于 Express 构建,开箱即用时很多配置偏向开发便利而非运行效率。当并发量上来之后,数据库查询堆积、内存泄漏、序列化开销大等问题会集中暴露。优化的核心思路就三条:减少不必要的计算、减少不必要的等待、让该并行的并行起来。一、换掉 Express,用 Fastify 适配器这是投入产出比最高的一步。Fastify 的路由匹配和序列化性能显著优于 Express,NestJS 官方原生支持切换,代码改动极小:import { NestFactory } from '@nestjs/core';import { AppModule } from './app.module';import { FastifyAdapter } from '@nestjs/platform-fastify';async function bootstrap() { const app = await NestFactory.create(AppModule, new FastifyAdapter()); await app.listen(3000);}bootstrap();切换后主要注意点:Fastify 使用 fastify-static 而非 express.static,文件上传用 fastify-multipart 而非 multer,中间件写法也有差异。官方文档列出了完整的迁移清单,大部分项目半天内就能完成。基准测试中,同样的接口 Fastify 吞吐量通常是 Express 的 2-3 倍。如果你的项目还在用 Express 适配器,这是第一个该做的事。二、数据库层优化数据库往往是瓶颈所在,优化收益也最大。2.1 索引不能乱加,但该加的必须加索引加速查询但拖慢写入,需要根据实际查询模式来设计。一个常见错误是给低基数字段(如性别、状态枚举)建索引,这种索引几乎不会被优化器使用。正确做法是对高频 WHERE 条件、JOIN 字段、ORDER BY 字段建索引,并用 EXPLAIN 验证索引是否被命中:@Entity('orders')export class Order { @PrimaryGeneratedColumn() id: number; @Index() @Column() userId: number; // 高频 JOIN 和 WHERE 条件 @Index() @Column({ type: 'timestamp' }) createdAt: Date; // 高频排序字段 @Column() status: string; // 低基数,不建索引}2.2 消灭 N+1 查询N+1 是 ORM 项目中最常见的性能杀手。症状是查询 100 条记录却产生 101 条 SQL:// N+1 —— 100 个用户产生 101 次查询const users = await this.userRepo.find();for (const user of users) { user.orders = await this.orderRepo.find({ where: { userId: user.id } });}用 relations 或 QueryBuilder 的 leftJoinAndSelect 一次搞定:// 1 次查询搞定const users = await this.userRepo.find({ relations: ['orders'] });// 或者需要更精细控制时const users = await this.userRepo .createQueryBuilder('user') .leftJoinAndSelect('user.orders', 'order') .getMany();2.3 连接池调优默认连接池大小往往不够用或用不满。关键参数:TypeOrmModule.forRoot({ type: 'mysql', // ... extra: { connectionLimit: 50, // 活跃连接上限 waitForConnections: true, // 连接耗尽时排队等待 queueLimit: 0, // 排队不限人数 acquireTimeout: 30000, // 等待连接超时 30s }, poolSize: 50, // TypeORM 原生参数(PostgreSQL 用这个)})连接池大小有个经验公式:连接数 = (CPU 核心数 * 2) + 有效磁盘数。但实际要根据监控数据调整——如果活跃连接数长期接近上限就加,如果大部分时间空闲就减。2.4 分页必须做不分页的列表查询是定时炸弹,数据量增长后直接拖垮数据库:async findPage(page: number, limit: number) { const [data, total] = await this.repo.findAndCount({ skip: (page - 1) * limit, take: limit, order: { createdAt: 'DESC' }, }); return { data, total, page, totalPages: Math.ceil(total / limit) };}深度分页(page 很大时)性能差,可以考虑用游标分页(基于 ID 的 where id > lastId)替代 offset 分页。三、缓存策略3.1 Redis 缓存热点数据频繁查询且变更少的数据(用户信息、配置项、热门内容)必须缓存。NestJS 的 @nestjs/cache-manager 配合 cache-manager-redis-store 可以快速接入:import { CacheModule, CacheInterceptor } from '@nestjs/cache-manager';import { redisStore } from 'cache-manager-redis-store';@Module({ imports: [ CacheModule.register({ store: redisStore, host: process.env.REDIS_HOST, port: process.env.REDIS_PORT, ttl: 3600, // 默认过期时间 1 小时 }), ],})export class AppModule {}然后直接用拦截器缓存整个接口响应:@UseInterceptors(CacheInterceptor)@Get('hot-articles')getHotArticles() { return this.articleService.findHot();}注意:POST 请求默认不被缓存,缓存 key 基于 URL,带查询参数的接口要确保参数一致时 key 也一致。3.2 多级缓存单层 Redis 缓存的问题是每次都要走网络。如果某些数据读多写极少,可以在 Redis 前面加一层内存缓存:@Injectable()export class ConfigService { private localCache = new Map<string, { value: any; expires: number }>(); async getConfig(key: string) { // L1: 内存缓存 const local = this.localCache.get(key); if (local && local.expires > Date.now()) return local.value; // L2: Redis const redisVal = await this.cacheManager.get(key); if (redisVal) { this.localCache.set(key, { value: redisVal, expires: Date.now() + 60000 }); return redisVal; } // L3: 数据库 const dbVal = await this.configRepo.findOne({ where: { key } }); await this.cacheManager.set(key, dbVal, 3600000); this.localCache.set(key, { value: dbVal, expires: Date.now() + 60000 }); return dbVal; }}内存缓存 TTL 要比 Redis 短(比如 1 分钟 vs 1 小时),这样即使内存缓存没及时失效,最多延迟 1 分钟就能拿到新数据。四、异步与并发4.1 能并行就不要串行多个互不依赖的异步操作,串行等就是浪费:// 串行 —— 总耗时 = 查用户 + 查订单 + 查通知async getUserDashboard(userId: number) { const user = await this.userRepo.findOne({ where: { id: userId } }); const orders = await this.orderRepo.find({ where: { userId } }); const notifications = await this.notifRepo.find({ where: { userId } }); return { user, orders, notifications };}// 并行 —— 总耗时 = max(查用户, 查订单, 查通知)async getUserDashboard(userId: number) { const [user, orders, notifications] = await Promise.all([ this.userRepo.findOne({ where: { id: userId } }), this.orderRepo.find({ where: { userId } }), this.notifRepo.find({ where: { userId } }), ]); return { user, orders, notifications };}注意 Promise.all 有一个失败全部失败的特性。如果部分请求允许失败,用 Promise.allSettled 代替。4.2 耗时任务丢进队列发邮件、生成报表、数据同步这类不需要即时返回结果的操作,同步处理会阻塞请求。用 Bull 队列异步处理:import { BullModule } from '@nestjs/bull';import { Processor, Process } from '@nestjs/bull';// 注册队列@Module({ imports: [BullModule.registerQueue({ name: 'email' })],})export class EmailModule {}// 生产者@Injectable()export class EmailService { constructor(@InjectQueue('email') private emailQueue: Queue) {} async sendWelcome(userId: string) { await this.emailQueue.add('welcome', { userId }, { attempts: 3, // 失败重试 3 次 backoff: 5000, // 重试间隔 5 秒 removeOnComplete: true, }); }}// 消费者@Processor('email')export class EmailConsumer { @Process('welcome') async handleWelcome(job: Job<{ userId: string }>) { const user = await this.userService.findOne(job.data.userId); await this.mailerService.sendMail({ to: user.email, subject: '欢迎' }); }}4.3 CPU 密集型任务用 Worker Threads图片处理、加密计算、大数据序列化等 CPU 密集操作会阻塞 Node.js 的事件循环。NestJS 没有内置 Worker Threads 封装,但自己集成不难:import { Worker } from 'worker_threads';@Injectable()export class ImageService { async resizeImage(inputPath: string, width: number): Promise<Buffer> { return new Promise((resolve, reject) => { const worker = new Worker('./workers/image-resize.worker.js', { workerData: { inputPath, width }, }); worker.on('message', resolve); worker.on('error', reject); worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exited with code ${code}`)); }); }); }}关键点:Worker 有启动开销,不要为每个请求都创建新 Worker。用 Worker 池(如 piscina 库)来复用 Worker 实例。五、响应压缩启用 Gzip 或 Brotli 压缩可以大幅减少传输体积,尤其是 JSON API 响应,压缩率通常在 60%-80%:import compression from 'compression';async function bootstrap() { const app = await NestFactory.create(AppModule); app.use(compression({ threshold: 1024, // 小于 1KB 的响应不压缩,开销大于收益 level: 6, // 压缩级别 1-9,6 是性价比最优 })); await app.listen(3000);}如果用 Nginx 做反向代理,在 Nginx 层开启压缩更合适(gzip on; gzip_types application/json;),因为 Nginx 的压缩对 CPU 的开销更低。六、HTTP Keep-Alive 和连接复用Node.js 默认的 HTTP Agent 对同一域名只保持 5 个连接。当你的 NestJS 应用频繁请求第三方 API 时,连接复用能显著减少 TCP 握手开销:import { Agent } from 'http';const keepAliveAgent = new Agent({ keepAlive: true, maxSockets: 50, // 同一域名最大连接数 keepAliveMsecs: 30000, // Keep-Alive 探测间隔});// Axios 中使用const axiosInstance = Axios.create({ httpAgent: keepAliveAgent });服务端也要配置 Keep-Alive 超时:async function bootstrap() { const app = await NestFactory.create(AppModule); const server = app.getHttpServer(); server.keepAliveTimeout = 65000; // 65 秒 server.headersTimeout = 66000; // 必须大于 keepAliveTimeout await app.listen(3000);}headersTimeout 必须大于 keepAliveTimeout,否则 Nginx 等反向代理在 Keep-Alive 探测时会收到 408 错误。七、模块懒加载大型应用启动时加载所有模块会拖慢启动速度。NestJS 支持模块懒加载,只在首次请求时初始化:import { Module } from '@nestjs/common';import { LazyModuleLoader } from '@nestjs/core';@Module({ providers: [LazyModuleLoader],})export class AppModule { constructor(private lazyLoader: LazyModuleLoader) {} async onModuleInit() { // 按需加载后台管理模块,不影响主流程启动 const { AdminModule } = await import('./admin/admin.module'); const moduleRef = await this.lazyLoader.load(() => AdminModule); }}实际项目中,管理后台、报表导出、数据迁移等低频功能模块适合懒加载。核心业务模块还是建议预加载,避免首次请求延迟。八、序列化优化NestJS 默认用 class-transformer 做序列化,开启 excludeExtraneousValues 后每次序列化都要遍历所有属性,数据量大时开销不可忽视:// 全局开启严格序列化app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, // 自动剔除未装饰的属性 forbidNonWhitelisted: true, // 有未装饰属性直接报错 }),);如果某个接口返回数据量大且不需要复杂转换,可以绕过 class-transformer 直接返回 Plain Object:@Get('raw-list')async getRawList() { // 直接用 QueryBuilder 的 raw 模式,跳过实体转换 return this.repo .createQueryBuilder('item') .select(['item.id', 'item.name', 'item.price']) .getRawMany();}getRawMany() 返回的是纯对象而非实体实例,少了实例化和关系映射的开销,适合只读列表场景。九、内存泄漏防范Node.js 进程内存持续增长但不回落,大概率是泄漏了。常见原因:闭包持有大对象引用全局 Map/数组无限增长事件监听器重复注册定时器未清理用 heapdump 或 node --inspect + Chrome DevTools 可以抓堆快照对比定位:node --inspect dist/main.js# 然后打开 chrome://inspect,点击 Profile 抓堆快照代码层面的防御性写法:@Injectable()export class CacheService implements OnModuleDestroy { private cache = new Map<string, { value: any; expires: number }>(); private cleanupTimer: NodeJS.Timeout; constructor() { // 定期清理过期缓存 this.cleanupTimer = setInterval(() => this.evictExpired(), 60000); } set(key: string, value: any, ttlMs: number = 3600000) { this.cache.set(key, { value, expires: Date.now() + ttlMs }); } private evictExpired() { const now = Date.now(); for (const [key, entry] of this.cache) { if (entry.expires < now) this.cache.delete(key); } } onModuleDestroy() { clearInterval(this.cleanupTimer); // 模块销毁时清理定时器 this.cache.clear(); }}十、生产部署优化10.1 PM2 集群模式单实例无法利用多核 CPU。PM2 的 cluster 模式自动 fork 多个进程:// ecosystem.config.jsmodule.exports = { apps: [{ name: 'nestjs-app', script: './dist/main.js', instances: 'max', // 按 CPU 核心数创建实例 exec_mode: 'cluster', max_memory_restart: '1G', // 内存超限自动重启 env: { NODE_ENV: 'production', }, }],};注意:集群模式下内存缓存不共享,必须用 Redis 等外部缓存;定时任务会重复执行,需要用分布式锁或指定单实例执行。10.2 Nginx 反向代理生产环境不要让 Node.js 直接面对公网,用 Nginx 做反向代理可以处理 SSL 终止、静态文件、负载均衡:upstream nestjs_backend { least_conn; server 127.0.0.1:3000; server 127.0.0.1:3001; server 127.0.0.1:3002; keepalive 64;}server { listen 80; server_name api.example.com; location / { proxy_pass http://nestjs_backend; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }}keepalive 64 让 Nginx 与 Node.js 之间也保持连接池,避免每次请求都建新 TCP 连接。10.3 监控不可少没有监控的优化是盲目的。推荐方案:性能拦截器:记录每个接口的响应时间,找慢接口APM 工具:Elastic APM、New Relic 或 Datadog,自动追踪请求链路Node.js 运行指标:用 prom-client 暴露事件循环延迟、堆内存、活跃句柄数等指标import { NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';@Injectable()export class PerformanceInterceptor implements NestInterceptor { private readonly logger = new Logger('Performance'); intercept(context: ExecutionContext, next: CallHandler) { const start = Date.now(); const req = context.switchToHttp().getRequest(); return next.handle().pipe( tap(() => { const duration = Date.now() - start; if (duration > 500) { this.logger.warn(`${req.method} ${req.url} took ${duration}ms`); } }), ); }}响应超过 500ms 的请求自动告警,比全量打日志更实用。优化不是一次性的性能优化没有终点。上线后要持续关注监控数据,根据实际瓶颈选择优化方向。优先做收益高、改动小的事——比如切换 Fastify 适配器和加索引可能只要半天,效果却比花一周重构代码架构明显得多。
服务端阅读 05月27日 23:03

NestJS 部署到生产环境有哪些关键步骤?

从开发到生产:部署的全局视角把一个 NestJS 应用从本地跑通到稳定上线,中间要跨越的不仅仅是"能跑起来"这么简单。生产环境面对的是真实流量、不可控的依赖服务、随时可能出现的故障——部署方案的选择直接影响应用的可用性和团队迭代效率。这篇内容围绕 NestJS 应用的生产部署展开,从容器化打包、编排调度、CI/CD 自动化、环境配置管理、可观测性建设到弹性伸缩,把每个环节中值得关注的实践细节梳理清楚。Docker 容器化:构建可复制的运行环境容器化是现代部署的起点。把应用和它的依赖打包成一个不可变的镜像,消除了"我这能跑你那不行"的环境差异问题。对 NestJS 来说,多阶段构建是减少镜像体积的关键手段。多阶段 Dockerfile一个面向生产的 Dockerfile 应该把构建和运行分开:# 构建阶段:安装全部依赖,编译 TypeScriptFROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build# 运行阶段:只装生产依赖,复制编译产物FROM node:20-alpine AS runnerWORKDIR /appENV NODE_ENV=production# 非 root 用户运行,提升安全性RUN addgroup -S appgroup && adduser -S appuser -G appgroupCOPY package*.json ./RUN npm ci --only=productionCOPY --from=builder /app/dist ./distUSER appuserEXPOSE 3000CMD ["node", "dist/main.js"]为什么用 node:20-alpine 而不是 node:20?Alpine 镜像只有约 50MB,相比完整 Debian 镜像的 350MB,体积差距明显。对于 NestJS 这类不需要原生 C++ 编译的应用,Alpine 完全够用。npm ci 代替 npm install 的原因是:ci 严格按 package-lock.json 安装,版本完全锁定,构建结果可重复。这在 CI 环境下尤其重要。.dockerignore 配置node_modulesdist.git.env*.logcoverage.vscode不要把 node_modules 和 dist 打进构建上下文——前者体积大且会在容器内重新安装,后者会被容器内编译覆盖。.env 文件包含敏感信息,绝对不能进镜像。镜像构建与本地验证# 构建镜像docker build -t nestjs-app:1.0.0 .# 本地运行验证docker run --rm -p 3000:3000 \ -e DATABASE_HOST=host.docker.internal \ nestjs-app:1.0.0加上 --rm 参数,容器退出后自动清理,避免本地堆积无用容器。数据库地址用 host.docker.internal 可以在开发阶段方便地连接宿主机上的数据库。Docker Compose:本地联调与多服务编排开发环境通常需要同时启动应用、数据库、缓存等多个服务。Docker Compose 把这些服务的启动顺序和依赖关系统一定义,一条命令就能拉起完整的本地环境。完整的 Compose 配置version: '3.8'services: app: build: context: . dockerfile: Dockerfile ports: - "3000:3000" environment: - NODE_ENV=development - DATABASE_HOST=db - DATABASE_PORT=3306 - DATABASE_USER=root - DATABASE_PASSWORD=password - DATABASE_NAME=nestjs - REDIS_HOST=redis - REDIS_PORT=6379 depends_on: db: condition: service_healthy redis: condition: service_started volumes: - ./src:/app/src restart: unless-stopped db: image: mysql:8.0 environment: - MYSQL_ROOT_PASSWORD=password - MYSQL_DATABASE=nestjs ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped redis: image: redis:7-alpine ports: - "6379:6379" restart: unless-stoppedvolumes: mysql_data:这里有几个容易忽略的细节:depends_on 配合 condition: service_healthy 确保数据库真正就绪后才启动应用,而不仅仅是容器启动。如果只用 depends_on: db,应用可能比数据库初始化先跑起来,导致连接失败。volumes: ./src:/app/src 把源码挂载进容器,配合 NestJS 的热重载,开发时改代码不需要重新构建镜像。但这个挂载只在开发环境使用,生产镜像不挂载任何源码卷。Kubernetes:生产级容器编排当应用需要高可用、自动伸缩、滚动更新时,Kubernetes 是最主流的编排方案。NestJS 作为无状态应用,在 K8s 上部署相对直观,但配置细节决定稳定性。Deployment:声明式管理应用实例apiVersion: apps/v1kind: Deploymentmetadata: name: nestjs-app labels: app: nestjs-appspec: replicas: 3 selector: matchLabels: app: nestjs-app strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: metadata: labels: app: nestjs-app spec: containers: - name: nestjs-app image: registry.example.com/nestjs-app:1.0.0 ports: - containerPort: 3000 env: - name: NODE_ENV value: "production" - name: DATABASE_HOST valueFrom: secretKeyRef: name: db-secret key: host - name: DATABASE_PASSWORD valueFrom: secretKeyRef: name: db-secret key: password resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "512Mi" cpu: "500m" livenessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 5 periodSeconds: 5 failureThreshold: 3strategy 部分的 maxUnavailable: 0 表示滚动更新时不允许任何时刻有实例不可用——每次先启动新实例,健康检查通过后才销毁旧实例,实现零停机部署。resources 的 requests 和 limits 必须设置。不设 limits 的容器可能占用节点全部内存导致 OOM Killer 波及其他 Pod;不设 requests 则调度器无法做出合理的节点分配决策。NestJS 应用的资源需求取决于业务复杂度,建议从 requests 256Mi/250m、limits 512Mi/500m 起步,根据监控数据逐步调优。Service 和 Ingress:流量入口apiVersion: v1kind: Servicemetadata: name: nestjs-app-servicespec: selector: app: nestjs-app ports: - protocol: TCP port: 80 targetPort: 3000 type: ClusterIP---apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: nestjs-app-ingress annotations: cert-manager.io/cluster-issuer: letsencrypt-prod nginx.ingress.kubernetes.io/rate-limit: "100"spec: ingressClassName: nginx tls: - hosts: - api.example.com secretName: nestjs-tls rules: - host: api.example.com http: paths: - path: / pathType: Prefix backend: service: name: nestjs-app-service port: number: 80Service 用 ClusterIP 类型(默认值),不直接对外暴露,流量统一由 Ingress 管理。Ingress 配合 cert-manager 自动管理 TLS 证书,加上 rate-limit 注解做基础的限流保护。CI/CD 管道:自动化构建与发布手动部署容易出错且无法追溯。CI/CD 管道把测试、构建、发布串联成自动化流程,每次代码变更都经过完整验证后才到达生产环境。GitHub Actions 实战配置name: CI/CD Pipelineon: push: branches: [main] pull_request: branches: [main]jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - name: Run unit tests run: npm run test - name: Run e2e tests run: npm run test:e2e - name: Run lint run: npm run lint - name: Check build run: npm run build build-and-push: needs: test runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: | ghcr.io/${{ github.repository }}:latest ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max deploy: needs: build-and-push runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' environment: production steps: - name: Deploy to Kubernetes uses: azure/k8s-deploy@v4 with: manifests: | k8s/deployment.yaml k8s/service.yaml k8s/ingress.yaml images: | ghcr.io/${{ github.repository }}:${{ github.sha }} kubeconfig: ${{ secrets.KUBE_CONFIG }}管道分为三个阶段,职责清晰:test 阶段跑在每次 PR 和 main 分支推送时,验证代码质量。npm ci 保证依赖版本一致,e2e 测试确保接口行为正确。build-and-push 只在 main 分支的 push 事件触发,构建镜像并推送到 GitHub Container Registry。镜像标签同时使用 latest 和 commit SHA,前者方便拉取最新版,后者用于精确回滚。cache-from: type=gha 利用 GitHub Actions 缓存加速 Docker 构建。deploy 阶段通过 environment: production 配置保护规则——可以在 GitHub 仓库设置中要求审批人确认后才能部署到生产环境。部署时用 commit SHA 标签精确指定镜像版本,K8s 滚动更新自动完成实例替换。环境变量与密钥管理环境变量是配置管理的基石,但不同环境的管理策略差异很大。分层配置方案// config/configuration.tsexport default () => ({ port: parseInt(process.env.PORT, 10) || 3000, database: { host: process.env.DATABASE_HOST, port: parseInt(process.env.DATABASE_PORT, 10) || 3306, username: process.env.DATABASE_USER, password: process.env.DATABASE_PASSWORD, name: process.env.DATABASE_NAME, }, jwt: { secret: process.env.JWT_SECRET, expiresIn: process.env.JWT_EXPIRES_IN || '1h', },});// app.module.tsimport { Module } from '@nestjs/common';import { ConfigModule } from '@nestjs/config';import configuration from './config/configuration';@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [configuration], envFilePath: [`.env.${process.env.NODE_ENV}`, '.env'], validationSchema: Joi.object({ DATABASE_HOST: Joi.string().required(), DATABASE_PORT: Joi.number().default(3306), JWT_SECRET: Joi.string().required(), }), }), ],})export class AppModule {}validationSchema 用 Joi 校验必填变量——启动时如果缺少 DATABASE_HOST 或 JWT_SECRET,应用直接报错退出,而不是带着空值跑起来然后在运行时莫名其妙地失败。这种 fail-fast 策略在容器环境中尤其有价值,能被健康检查迅速捕获。密钥的安全存储本地开发用 .env 文件没问题,但生产环境的密钥不应该以明文存储。Kubernetes Secrets 虽然只是 Base64 编码而非加密,但配合 RBAC 权限控制和外部密钥管理服务(如 HashiCorp Vault、AWS Secrets Manager),能形成完整的密钥保护链路。apiVersion: v1kind: Secretmetadata: name: db-secrettype: OpaquestringData: host: "your-db-host.internal" port: "3306" user: "app_user" password: "s3cur3P@ssw0rd"注意这里用 stringData 而不是 data——前者直接写明文字符串,K8s 自动做 Base64 编码;后者需要自己先编码。功能上等价,但 stringData 在编写时不容易出错。健康检查:让编排系统了解应用状态Kubernetes 的自愈能力依赖健康检查。如果应用没有暴露健康端点,K8s 只能根据进程是否存在来判断状态——进程活着但已经死锁的情况无法检测。Terminus 健康检查import { Controller, Get } from '@nestjs/common';import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator, MemoryHealthIndicator, DiskHealthIndicator,} from '@nestjs/terminus';@Controller('health')export class HealthController { constructor( private health: HealthCheckService, private db: TypeOrmHealthIndicator, private memory: MemoryHealthIndicator, private disk: DiskHealthIndicator, ) {} @Get() @HealthCheck() check() { return this.health.check([ () => this.db.pingCheck('database'), () => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024), () => this.disk.checkStorage('storage', { thresholdPercent: 0.9, path: '/', }), ]); }}这个端点同时检查三个维度:数据库连通性、堆内存是否接近上限(150MB)、磁盘空间是否快满。任何一项失败,健康检查返回 503,K8s 就会把该实例从 Service 后端摘除,流量不再路由到异常实例。livenessProbe 和 readinessProbe 的区别要注意:liveness 检测应用是否需要重启,readiness 检测应用是否可以接收流量。数据库连不上时 readiness 应该失败(不接流量但不重启),而只有应用内部死锁无法恢复时 liveness 才应该失败(触发重启)。把两者搞混会导致频繁重启或者流量打进有问题的实例。日志与监控:生产环境的眼睛部署不是终点,而是运维的起点。没有可观测性的生产环境就像盲飞——出了问题完全不知道发生了什么。结构化日志生产日志必须结构化,方便日志平台(ELK、Loki)检索和聚合:import { WinstonModule } from 'nest-winston';import * as winston from 'winston';@Module({ imports: [ WinstonModule.forRoot({ transports: [ new winston.transports.Console({ format: winston.format.combine( winston.format.timestamp(), winston.format.json(), ), }), ], }), ],})export class AppModule {}用 JSON 格式输出到 stdout,这是容器日志的最佳实践——由日志收集器(Fluentd、Promtail)统一采集,不需要应用自己写文件。timestamp 字段确保日志时间不受采集延迟影响。Prometheus 指标采集import { Controller, Get } from '@nestjs/common';import { Injectable } from '@nestjs/common';import { makeCounterProvider, makeHistogramProvider, NestPromModule } from '@digikare/nestjs-prom';@Module({ imports: [ NestPromModule.forRoot({ defaultMetrics: { enabled: true }, }), ], providers: [ makeCounterProvider({ name: 'http_requests_total', help: 'Total number of HTTP requests', labelNames: ['method', 'route', 'status'], }), makeHistogramProvider({ name: 'http_request_duration_seconds', help: 'HTTP request duration in seconds', labelNames: ['method', 'route'], buckets: [0.1, 0.3, 0.5, 1, 3, 5], }), ],})export class AppModule {}关键指标包括请求总数(按路由和状态码分类)、请求耗时分布(P50/P95/P99)。这些数据配合 Grafana 仪表板,能直观反映系统健康状况和性能瓶颈。告警规则示例groups:- name: nestjs-alerts rules: - alert: HighErrorRate expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05 for: 5m labels: severity: critical annotations: summary: "NestJS 5xx error rate exceeds 5%" - alert: HighLatency expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 3 for: 10m labels: severity: warning annotations: summary: "NestJS P95 latency exceeds 3 seconds"5xx 错误率超过 5% 持续 5 分钟触发 critical 告警,P95 延迟超过 3 秒持续 10 分钟触发 warning。阈值根据业务 SLA 调整,不是固定值。负载均衡与流量管理多实例部署后,流量如何分发到各个实例是可用性的关键环节。Nginx 反向代理upstream nestjs_backend { least_conn; server nestjs-app-1:3000; server nestjs-app-2:3000; server nestjs-app-3:3000; keepalive 32;}server { listen 80; server_name api.example.com; location / { proxy_pass http://nestjs_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Connection ""; }}least_conn 策略把新请求分配给当前连接数最少的后端,比默认的轮询更适合请求耗时不均匀的场景。keepalive 32 维持与后端的 32 个长连接,避免每次请求都重新建 TCP 连接。proxy_http_version 1.1 和 Connection "" 是 Nginx 与后端保持长连接的必要配置,很多人漏掉。云平台负载均衡在 AWS 上用 ALB 时,Target Group 的健康检查路径设为 /health,检查间隔建议 10 秒,不健康阈值设为 3 次。 deregistration_delay 设为 60 秒——实例从 Target Group 移除后等待 60 秒才断开连接,确保正在处理的请求能正常完成。弹性伸缩与故障恢复HPA 自动伸缩apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: nestjs-hpaspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: nestjs-app minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Pods pods: metric: name: http_requests_per_second target: type: AverageValue averageValue: "100" behavior: scaleUp: stabilizationWindowSeconds: 60 policies: - type: Pods value: 2 periodSeconds: 60 scaleDown: stabilizationWindowSeconds: 300伸缩策略不只是看 CPU——加了自定义指标 http_requests_per_second,每秒 100 请求就扩容。behavior 配置了扩容窗口 60 秒(快速响应流量增长)、缩容窗口 300 秒(避免流量抖动时反复缩容扩容),每次最多扩 2 个 Pod。数据库备份与恢复#!/bin/bashset -euo pipefailDATE=$(date +%Y%m%d_%H%M%S)BACKUP_DIR="/backups"DATABASE="nestjs"mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" "$DATABASE" \ --single-transaction \ --quick \ | gzip > "$BACKUP_DIR/db_backup_$DATE.sql.gz"# 保留最近 7 天的备份find "$BACKUP_DIR" -name "db_backup_*.sql.gz" -mtime +7 -delete# 上传到对象存储aws s3 cp "$BACKUP_DIR/db_backup_$DATE.sql.gz" \ s3://your-backup-bucket/mysql/--single-transaction 保证 InnoDB 备份的一致性而不锁表。set -euo pipefail 让脚本在任何命令失败时立即退出,避免静默失败。备份不仅要留本地,还要上传到对象存储做异地容灾。部署策略选型不同场景适合不同的发布策略,理解它们的差异才能做出正确选择:滚动更新(K8s 默认):逐步替换旧实例。优点是简单无需额外资源,缺点是新旧版本短暂共存,如果有数据库 schema 不兼容变更可能出问题。蓝绿部署:同时维护两套完整环境,切换流量瞬间完成。优点是回滚极快,缺点是资源成本翻倍。金丝雀发布:先让少量流量到新版本,观察无误后逐步放大比例。优点是风险可控,缺点是需要流量管理能力(Istio、Nginx Ingress canary annotation)。对 NestJS 应用来说,API 版本管理比部署策略更基础——如果接口做到了向后兼容,滚动更新就够了;如果有破坏性变更,金丝雀发布是更稳妥的方案。从开发到生产的检查清单在点下部署按钮之前,确认这些事项:环境变量通过密钥管理服务注入,没有硬编码或明文存储Docker 镜像使用多阶段构建,非 root 用户运行健康检查端点就绪,liveness 和 readiness 探针配置正确CI 管道覆盖单元测试、集成测试和构建验证日志以 JSON 格式输出到 stdout,由收集器统一处理Prometheus 指标采集就绪,关键告警规则已配置HPA 最小副本数大于 1,保证单实例故障不影响可用性数据库备份脚本经过恢复演练验证回滚方案明确:kubectl rollout undo 或切换镜像标签Ingress 配置了 TLS 和基础限流这套部署体系的核心思路是:每个环节都有自动化保障,每个故障都有检测和恢复手段。容器化保证环境一致性,编排系统保证可用性,CI/CD 保证发布可追溯,可观测性保证问题可定位。把这些拼起来,就是一个经得起生产考验的 NestJS 部署方案。
前端阅读 1082024年7月15日 23:37

解释@nestjs/typeorm包的用途。

@nestjs/typeorm 包是一个NestJS模块,用于集成TypeORM库到NestJS应用中。TypeORM是一个可以使用TypeScript(或JavaScript)工作的ORM(对象关系映射)工具,它使得数据库的操作变得更加简单和直接。使用@nestjs/typeorm包的主要用途包括:数据库集成:该包允许NestJS应用轻松连接到不同类型的数据库(如MySQL, PostgreSQL, SQLite等),并且通过装饰器和其他TypeScript功能,可以直接在代码中定义模型和关系。数据操作:通过Repository模式,可以实现对数据库中数据的各种操作,如增删改查(CRUD)。事务管理:支持使用装饰器或其他方式来处理数据库事务,确保数据的一致性和完整性。自动化数据库迁移:TypeORM支持自动化迁移功能,@nestjs/typeorm包使得这些功能可以更加便捷地集成到NestJS项目中。简而言之,@nestjs/typeorm包为NestJS提供了一个强大、灵活的方式来操作和管理数据库,使得开发人员可以更加专注于业务逻辑的实现,而不必担心底层的数据库操作细节。
前端阅读 862024年7月15日 23:35

解释Nest.js ExecutionContext的作用。

ExecutionContext 是 Nest.js 中的一个核心概念,它为请求处理流程中的执行上下文提供了详细的信息。它继承自 ArgumentsHost 类,提供了一种方法来获取当前处理请求的详细信息,包括原始的请求对象、处理请求的处理器(handler)、当前的控制器等信息。在 Nest.js 中,ExecutionContext 的主要作用是:访问请求的详细信息:通过 ExecutionContext,可以访问到 HTTP 请求的所有相关信息(如请求对象、响应对象等),这有助于在拦截器(Interceptors)、过滤器(Filters)、守卫(Guards)和管道(Pipes)等中间件中进行更加详细和具体的操作。处理程序上下文:它提供了当前处理程序的上下文信息,例如当前的控制器类和方法名称。这对于日志记录、权限检查等功能非常有用。跨平台的兼容性:ExecutionContext 也设计用来在不同的平台(如 HTTP、WebSockets、GraphQL 等)之间提供一致的接口,使得中间件代码可以在不同类型的应用程序中重用而无需修改。通过这种方式,ExecutionContext 提供了一个强大的工具,使开发者能够根据请求的上下文信息执行复杂的逻辑,同时保持代码的清晰和维护性。