标签

NestJS

NestJS 是一种基于 TypeScript 的后端框架,它结合了 Express 和 Angular 的优点,提供了一种现代化、模块化和可扩展的开发方式。NestJS 的主要目标是提供一个高效、可维护和可测试的服务端应用程序框架,同时提供了许多有用的功能和工具,如依赖注入、模块化体系结构、中间件、管道、拦截器、异常过滤器等。 NestJS 的主要特点包括: 基于 TypeScript:NestJS 是一种基于 TypeScript 的框架,支持静态类型检查和强类型编程,提高了代码的可维护性和可读性。 模块化体系结构:NestJS 使用模块化体系结构,将应用程序拆分为多个模块,每个模块可以独立开发、测试和部署,提高了代码的可扩展性和可维护性。 依赖注入:NestJS 支持依赖注入,通过注入依赖项来实现松耦合的架构设计,提高了代码的可测试性和可维护性。 中间件和管道:NestJS 提供了中间件和管道机制,可以在请求和响应之间添加额外的逻辑,如身份验证、日志记录、异常处理等,提高了应用程序的可靠性和安全性。 异常过滤器:NestJS 提供了异常过滤器,可以捕获应用程序中的异常并进行处理,提高了应用程序的稳定性和可靠性。 NestJS 可以用于构建各种类型的后端服务,如 RESTful API、WebSocket 服务、微服务等。NestJS 社区提供了许多有用的扩展和插件,如 Swagger UI、TypeORM、GraphQL 等,可以帮助开发人员更加高效地构建和管理后端服务。 如果您想成为一名后端开发人员,NestJS 是一个非常有用的框架,需要掌握 TypeScript 的基础知识和 NestJS 的开发方式,了解常用的模块和工具,如路由、控制器、服务、中间件、管道、拦截器等。掌握 NestJS 可以帮助您更加高效和灵活地构建和管理后端服务,为自己的职业发展和个人成长打下坚实的基础。

NestJS
服务端6月5日 20:04
NestJS 拦截器和异常过滤器:响应转换、日志、缓存和统一错误处理拦截器和异常过滤器是 NestJS 请求处理管道中的两个关键环节。拦截器在请求成功时介入,做响应转换、日志记录、缓存等;异常过滤器在请求出错时介入,统一错误格式。两者配合,就能控制 API 返回给客户端的所有内容。 ## 先搞清执行顺序 NestJS 处理一个请求的完整流程: ``` 请求 → Middleware → Guard → Interceptor(前) → Controller → Interceptor(后) → 响应 ↓ (异常) Exception Filter → 错误响应 ``` 拦截器包住了 Controller——请求进来时先执行拦截器的"前"逻辑,Controller 处理完后执行"后"逻辑。如果 Controller 抛异常,跳过"后"逻辑,直接进异常过滤器。 ## 拦截器:请求前后的横切逻辑 ### 响应格式统一 后端 API 最常见的需求:所有响应都包成统一格式 `{ code, data, message }`: ```typescript // interceptors/transform.interceptor.ts import { 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` 操作符在"后"阶段转换格式。 ### 请求耗时日志 ```typescript @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` 操作符只观察不修改——适合做日志、指标采集等副作用操作。 ### 缓存拦截器 ```typescript @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。 ### 超时控制 ```typescript 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); }), ); } } ``` ### 绑定拦截器 ```typescript // 方法级别 @UseInterceptors(LoggingInterceptor) @Get('users') getUsers() {} // Controller 级别 @UseInterceptors(TransformInterceptor) @Controller('users') export class UserController {} // 全局 app.useGlobalInterceptors(new TransformInterceptor()); ``` 全局绑定时用 `useGlobalInterceptors` 简单直接,但如果拦截器需要依赖注入,必须用 Module 方式注册: ```typescript // 在任意 Module 中 providers: [ { provide: APP_INTERCEPTOR, useClass: TransformInterceptor, }, ] ``` ## 异常过滤器:统一错误处理 ### 基本异常过滤器 NestJS 内置了默认的异常处理,但返回格式不够友好。自定义过滤器可以统一错误格式: ```typescript // filters/http-exception.filter.ts import { 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 的错误(如数据库异常、第三方库异常): ```typescript @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 错误不要把内部细节暴露给客户端——统一返回"服务器内部错误",详细信息只写日志。 ### 业务异常的优雅处理 定义业务异常码,让过滤器按类型处理: ```typescript // 自定义业务异常 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, }); } } ``` ### 绑定过滤器 ```typescript // 方法级别 @UseFilters(HttpExceptionFilter) @Get('users') getUsers() {} // 全局 app.useGlobalFilters(new AllExceptionsFilter()); // 全局(支持依赖注入) providers: [ { provide: APP_FILTER, useClass: AllExceptionsFilter, }, ] ``` ## 拦截器 vs 异常过滤器的边界 | 场景 | 用哪个 | 原因 | |------|--------|------| | 响应格式统一 | 拦截器 | 处理正常响应 | | 日志记录 | 拦截器 | 记录请求和响应 | | 缓存 | 拦截器 | 控制是否调用 Controller | | 超时控制 | 拦截器 | 用 RxJS timeout 操作符 | | 错误格式统一 | 异常过滤器 | 处理异常响应 | | 错误日志 | 异常过滤器 | 记录异常堆栈 | | 错误码映射 | 异常过滤器 | 把技术异常翻译成业务错误码 | **一个常见错误**:在拦截器里用 `catchError` 处理异常。虽然技术上可行,但违反了职责分离——拦截器负责"正常路径",过滤器负责"异常路径"。混在一起会让代码难以维护。 ## 全局注册的最佳实践 在 `main.ts` 中统一注册全局拦截器和过滤器: ```typescript // main.ts const app = await NestFactory.create(AppModule); app.useGlobalInterceptors( new TransformInterceptor(), new LoggingInterceptor(), ); app.useGlobalFilters( new AllExceptionsFilter(), new BusinessExceptionFilter(), ); await app.listen(3000); ``` 需要注意注册顺序:**过滤器按注册顺序反向执行**(后注册的先执行),**拦截器按注册顺序正向执行**。所以 `AllExceptionsFilter` 放最后——它兜底处理所有未捕获的异常。
服务端6月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 的项目 | ## 安装和基本配置 ```bash npm install @nestjs/graphql @nestjs/apollo graphql @apollo/server ``` ### 代码优先配置 ```typescript // app.module.ts import { 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 优先配置 ```typescript GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, typePaths: ['./**/*.graphql'], // 指向你的 .graphql 文件 definitions: { path: join(process.cwd(), 'src/graphql.ts'), // 生成的 TypeScript 类型 outputAs: 'class', }, }) ``` ## 定义 ObjectType(对应数据库模型) ObjectType 是 GraphQL 的返回类型,相当于 REST 里的响应 DTO: ```typescript // models/user.model.ts import { 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: ```typescript // dto/create-user.input.ts import { 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 ```typescript @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 的"控制器",处理查询和变更请求: ```typescript // resolvers/user.resolver.ts import { 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 的优势是客户端可以按需获取关联数据: ```typescript // 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 批量加载: ```typescript import * as DataLoader from 'dataloader'; // 在 Module 中注册 DataLoader providers: [ { 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 层面守卫 ```typescript @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 略有不同: ```typescript @Injectable() export class GqlAuthGuard extends AuthGuard('jwt') { getRequest(context: ExecutionContext) { const ctx = GqlExecutionContext.create(context); return ctx.getContext().req; // GraphQL 请求对象 } } ``` ## 分页查询 GraphQL 常用的游标分页(Relay 风格): ```typescript @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 噪音 |
服务端6月4日 15:45
NestJS控制器和路由:装饰器、参数获取、响应处理和常见坑NestJS 的控制器用装饰器声明路由,不用手动写 `app.get('/users/:id', ...)`——装饰器既是文档又是路由注册。这篇文章把控制器的声明、路由参数获取、响应处理、以及常见的坑都过一遍。 ## 基本路由声明 ```typescript @Controller('users') // 路由前缀 /users export 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 | | ### 路径参数 ```typescript @Get(':id') findOne(@Param('id') id: string) { // 拿单个参数 return this.usersService.findOne(id); } @Get(':category/:id') findByCategory( @Param('category') category: string, @Param('id') id: string, // 多个路径参数 ) {} ``` ### 查询参数 ```typescript @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 ```typescript @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); } ``` DTO 配合 ValidationPipe 使用,验证逻辑在 DTO 类上声明,控制器保持干净。 ## 响应处理 ### 修改状态码 ```typescript @Post() @HttpCode(200) // POST 默认 201,改成 200 create(@Body() dto: CreateUserDto) {} @Delete(':id') @HttpCode(204) // 删除成功返回 204 No Content remove(@Param('id') id: string) {} ``` ### 设置响应头 ```typescript @Get() @Header('Cache-Control', 'max-age=3600') findAll() {} ``` ### 重定向 ```typescript @Get('docs') @Redirect('https://docs.nestjs.com', 302) redirectToDocs() {} ``` 动态重定向(返回值覆盖装饰器): ```typescript @Get('docs') @Redirect('https://docs.nestjs.com') redirectToDocs(@Query('version') version?: string) { if (version === 'v7') { return { url: 'https://v7.docs.nestjs.com' }; } } ``` ### 流式响应 大文件下载、SSE 等场景需要流式返回: ```typescript 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()`: ```typescript 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 })`: ```typescript @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 就行: ```typescript @Get() async findAll(): Promise<User[]> { return this.usersService.findAll(); // service 返回 Promise } ``` 也可以返回 RxJS Observable: ```typescript @Get() findAll(): Observable<User[]> { return from(this.usersService.findAll()); } ``` ## 路由版本控制 API 版本升级时,同一接口需要同时支持 v1 和 v2: ```typescript // main.ts 启用版本控制 app.enableVersioning({ type: VersioningType.URI }); ``` ```typescript @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 错误: ```json { "statusCode": 404, "message": "User not found" } ``` ## 常见坑 **路由顺序**:NestJS 按声明顺序匹配路由。`@Get(':id')` 在 `@Get('profile')` 前面的话,`/users/profile` 会被 `:id` 匹配,`id` 值变成 `"profile"`。把具体路由放在参数路由前面: ```typescript @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()` 没被禁用。
服务端6月4日 15:43
NestJS提供者详解:四种注册方式、循环依赖和作用域选择NestJS 的提供者(Provider)就是"可以被注入的东西"——`@Injectable()` 装饰的类,通过依赖注入(DI)容器管理生命周期,在控制器或其他服务里通过构造函数参数自动获得实例。服务是最常见的提供者,但提供者不只有服务:配置对象、数据库连接、工厂函数都可以是提供者。 ## 最常用的提供者:服务(Service) 服务封装业务逻辑,控制器只负责接收请求和返回响应: ```typescript // users.service.ts import { 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; } } ``` ```typescript // 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(); } } ``` 在模块里注册: ```typescript @Module({ controllers: [UsersController], providers: [UsersService], // 注册服务,DI 容器会自动创建实例 }) export class UsersModule {} ``` ## 提供者的四种注册方式 ### useClass:默认方式 ```typescript providers: [UsersService] // 等价于: providers: [{ provide: UsersService, useClass: UsersService }] ``` 最常用,DI 容器自动 new 一个实例。 ### useValue:提供常量或外部对象 ```typescript providers: [ { provide: 'API_KEY', useValue: process.env.API_KEY, // 直接给一个值 }, ] ``` 注入时用 `@Inject()` 指定令牌: ```typescript constructor(@Inject('API_KEY') private apiKey: string) {} ``` 适合配置值、环境变量、第三方 SDK 实例等不需要 DI 创建的东西。 ### useFactory:动态创建,可以注入依赖 ```typescript 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:别名 ```typescript providers: [ UsersService, { provide: 'IUsersService', // 接口令牌 useExisting: UsersService, // 指向已有的提供者 }, ] ``` 接口在 TypeScript 编译后不存在,不能用 `provide: IUsersService`,用字符串或 Symbol 令牌 + useExisting 是标准做法。 ## 依赖注入令牌 DI 容器通过令牌(token)匹配依赖。令牌可以是类、字符串或 Symbol: ```typescript // 类令牌(最常见) 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`。 ```typescript // service-a 依赖 service-b,service-b 又依赖 service-a @Injectable() export class ServiceA { constructor(private serviceB: ServiceB) {} // ❌ 循环依赖 } ``` 解决方案:用 `forwardRef` 延迟解析: ```typescript @Injectable() export class ServiceA { constructor( @Inject(forwardRef(() => ServiceB)) private serviceB: ServiceB, ) {} } @Injectable() export class ServiceB { constructor( @Inject(forwardRef(() => ServiceA)) private serviceA: ServiceA, ) {} } ``` 模块里也要加 forwardRef: ```typescript @Module({ imports: [forwardRef(() => ServiceBModule)], }) export class ServiceAModule {} ``` 但 `forwardRef` 只是绕过了报错,说明设计有问题——更好的做法是提取公共逻辑到第三个服务,或者通过事件解耦。 ## 提供者作用域 默认情况下所有提供者都是单例(Singleton)——整个应用共享一个实例。NestJS 支持三种作用域: | 作用域 | 生命周期 | 适用场景 | |--------|----------|----------| | DEFAULT(单例) | 应用启动时创建,共享 | 几乎所有服务 | | REQUEST | 每个请求创建一个实例 | 请求上下文数据(如当前用户) | | TRANSIENT | 每次注入都创建新实例 | 无状态的临时对象 | ```typescript @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: ```typescript // users.module.ts @Module({ controllers: [UsersController], providers: [UsersService], exports: [UsersService], // 暴露给其他模块 }) export class UsersModule {} ``` 其他模块 import 后就能注入 UsersService: ```typescript // 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 的服务。 ## 可选注入 某些依赖不是必须的,找不到时不报错: ```typescript import { Optional } from '@nestjs/common'; @Injectable() export class MyService { constructor(@Optional() private cacheService?: CacheService) {} getData() { return this.cacheService?.get('key') ?? this.fetchFromDB(); } } ``` 有 CacheService 就用缓存,没有就直接查数据库。适合功能增强型依赖。
服务端6月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`: ```typescript 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(); // 放行,必须调,否则请求卡住 } } ``` 在模块里注册(中间件不能装饰器注册): ```typescript @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): ```typescript 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 就放行 } } ``` 使用: ```typescript @Controller('users') @UseGuards(AuthGuard) // 整个控制器都要认证 export class UsersController { @Get() findAll() { /* ... */ } @Post() @UseGuards(AdminGuard) // 这个方法额外要管理员权限 create() { /* ... */ } } ``` ### 基于角色的权限控制(RBAC) 守卫真正的威力是配合 `SetMetadata` + `Reflector` 实现声明式权限: ```typescript // 自定义装饰器 import { SetMetadata } from '@nestjs/common'; export const Roles = (...roles: string[]) => SetMetadata('roles', roles); ``` ```typescript // 守卫里读取元数据 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)); } } ``` 控制器上使用: ```typescript @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()` 的,可以注入数据库、缓存等服务: ```typescript @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 不看业务 → 中间件;要看路由决定权限 → 守卫**。
服务端6月4日 15:41
NestJS管道和验证:class-validator配置、自定义Pipe和常见坑NestJS 的管道(Pipe)就两件事:**转换**和**验证**。转换是把字符串参数变成数字、把日期字符串变成 Date 对象;验证是检查请求体里的字段是否合法,不合法就拒绝请求。听起来简单,但管道是 NestJS 请求生命周期里的关键一环——守卫之后、控制器之前,数据必须过管道这一关。 ## 管道的两种用途 - **转换**:把输入数据转成目标类型(如 `ParseIntPipe` 把路由参数 `"42"` 变成数字 `42`) - **验证**:检查输入数据是否合法,不合法抛异常(如 `class-validator` 检查 email 格式) 一个管道可以只做转换、只做验证,或者两者都做。NestJS 内置的管道偏向转换,实际项目里的验证管道通常结合 `class-validator`。 ## 内置管道 ### ParseIntPipe:路由参数转数字 ```typescript @Get(':id') findOne(@Param('id', ParseIntPipe) id: number) { // 如果 id 不是数字,自动返回 400 Bad Request return this.usersService.findOne(id); } ``` 不加 ParseIntPipe,`id` 是字符串 `"42"`,你的 service 里拿到的类型和声明不一致。加了之后,不合法的值直接被拦截,控制器方法不会被调用。 ### ParseUUIDPipe ```typescript @Get(':id') findOne(@Param('id', new ParseUUIDPipe()) id: string) { return this.usersService.findOne(id); } ``` 验证 UUID 格式。非法 UUID 返回 400,不需要自己写正则。 ### ParseArrayPipe ```typescript @Get() findAll(@Query('ids', new ParseArrayPipe({ items: String, separator: ',' })) ids: string[]) { // ?ids=a,b,c → ['a', 'b', 'c'] return this.usersService.findByIds(ids); } ``` ### DefaultValuePipe ```typescript @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 做请求体验证 这是实际项目里用得最多的验证方式。 ### 安装依赖 ```bash npm install class-validator class-transformer ``` ### 定义 DTO ```typescript import { 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 ```typescript // main.ts import { 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()`: ```typescript 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()` 无法对其中的属性做验证。 ### 自定义错误消息 ```typescript export class CreateUserDto { @IsString({ message: '用户名必须是字符串' }) name: string; @IsEmail({}, { message: '邮箱格式不正确' }) email: string; } ``` 或者统一格式: ```typescript 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 }); }, }) ``` ## 自定义管道 内置管道不够用时,写自己的: ```typescript 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; } } ``` 使用: ```typescript @Get(':date') findByDate(@Param('date', ParseDatePipe) date: Date) { return this.recordsService.findByDate(date); } ``` ## 管道的作用范围 管道可以用在四个层级: | 层级 | 写法 | 影响范围 | |------|------|----------| | 参数 | `@Param('id', ParseIntPipe)` | 只验证这一个参数 | | 方法 | `@UsePipes(new ValidationPipe())` | 这个路由方法的所有参数 | | 控制器 | `@Controller() @UsePipes(...)` | 这个控制器所有方法 | | 全局 | `app.useGlobalPipes(...)` | 整个应用 | 全局管道有两种注册方式: ```typescript // 方式一: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) → 过滤器 ``` 管道在守卫之后、控制器之前。这意味着守卫可以先判断权限,没权限直接拒绝,不会走到管道的验证逻辑。管道验证失败抛出的异常,会被异常过滤器捕获。
服务端6月2日 01:30
NestJS 是什么?和 Express 有什么区别?核心概念和应用场景NestJS 是一个 Node.js 后端框架,底层用 Express(或 Fastify)做 HTTP 处理,上层加了模块化架构、依赖注入、装饰器语法。你可以把 NestJS 理解为 Node.js 版的 Spring Boot——同样的分层架构、同样的开箱即用。 ## NestJS vs Express Express 是一个极简的 HTTP 路由库,给你一个 `app.get()` 然后自由发挥。项目小的时候很爽,项目大了没有约束——路由、中间件、数据库连接、业务逻辑全混在一起,没人知道代码应该放哪。 NestJS 解决的是"团队协作时的代码组织"问题: - **Module** 划分功能边界(用户模块、订单模块、支付模块互不干扰) - **Controller** 处理 HTTP 请求,只做参数校验和路由转发 - **Service** 处理业务逻辑,可以被多个 Controller 复用 - **依赖注入**自动管理实例创建和依赖关系,不用手动 new 和传参 Express 5000 行代码以上的项目,不靠团队约定基本没法维护。NestJS 通过架构约束让你写出的代码天然就是分层的。 ## 核心概念 **Module(模块)**:组织代码的边界。每个功能模块有自己的 Controller、Service、Provider。Module 之间通过 `imports` 和 `exports` 通信,类似 JavaScript 的模块化但更严格——不 export 的东西外部不可见。 **Controller(控制器)**:处理 HTTP 请求。用装饰器定义路由: ```typescript @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 做参数校验: ```typescript 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 的架构开销不值得。 ## 快速开始 ```bash npm i -g @nestjs/cli nest new my-project cd my-project npm 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 里。
服务端6月2日 01:29
NestJS 依赖注入是怎么工作的?Module、Provider 和注入机制详解NestJS 的依赖注入(DI)是从 Angular 借鉴的核心机制。你不需要手动创建实例和传递依赖——在 Provider 里声明,在构造函数里接收,Nest 容器自动装配。Module 是组织 Provider 的边界,控制哪些可以对外暴露、哪些只在内部使用。 ## 依赖注入基本原理 没有 DI 的写法:手动创建依赖,耦合度高。 ```typescript // 没有 DI const repo = new UserRepository(); const service = new UserService(repo); const controller = new UserController(service); ``` 有 DI 的写法:只声明需要什么,Nest 自动注入。 ```typescript @Controller('users') export class UsersController { constructor(private usersService: UsersService) {} // 自动注入 } ``` Nest 的 IoC 容器在启动时扫描所有 Module,根据构造函数的参数类型自动创建和注入实例。`private` 关键字同时声明和赋值——TypeScript 的参数属性简写。 ## Module:组织代码的边界 每个 Module 是一个独立的 DI 容器。Module 里注册的 Provider 默认只在 Module 内部可用。 ```typescript @Module({ controllers: [UsersController], providers: [UsersService], exports: [UsersService], // 对外暴露 }) export class UsersModule {} ``` - `providers`:注册到本 Module 的服务,本 Module 内可注入 - `exports`:声明哪些 Provider 可以被其他 Module 使用 - `imports`:导入其他 Module 暴露的 Provider ```typescript @Module({ imports: [UsersModule], // 导入后可以注入 UsersService providers: [PostsService], }) export class PostsModule {} ``` 如果不 `exports: [UsersService]`,PostsModule 里注入 UsersService 会报错——Nest 找不到这个 Provider。 ## Provider 的三种注册方式 **1. 类名注册(最常见)** ```typescript providers: [UsersService] // 等价于 providers: [{ provide: UsersService, useClass: UsersService }] ``` **2. 值注册(Mock 或配置对象)** ```typescript providers: [{ provide: 'CONFIG', useValue: { dbHost: 'localhost', port: 5432 }, }] ``` **3. 工厂注册(动态创建,依赖其他 Provider)** ```typescript providers: [{ provide: 'DATABASE_CONNECTION', useFactory: (configService: ConfigService) => { return createConnection(configService.get('db')); }, inject: [ConfigService], // 工厂的依赖 }] ``` ## 注入方式 **构造函数注入(推荐)**: ```typescript constructor(private usersService: UsersService) {} ``` **属性注入(可选依赖)**: ```typescript @Inject('CONFIG') config: ConfigType<typeof config>; ``` 用 `@Inject()` 指定 token——当 Provider 不是用类名注册时(字符串 token、Symbol token),必须显式指定。 ## 作用域 默认情况下所有 Provider 是 **Singleton**(单例)——整个应用共享一个实例。这是最高效的模式,也是 99% 场景的正确选择。 其他作用域: | 作用域 | 生命周期 | 适用场景 | |--------|----------|----------| | DEFAULT(Singleton) | 应用启动时创建,共享 | 几乎所有情况 | | REQUEST | 每个请求创建新实例 | 需要请求上下文(如租户隔离) | | TRANSIENT | 每次注入都创建新实例 | 极少用 | ```typescript @Injectable({ scope: Scope.REQUEST }) export class RequestLogger {} ``` 不要随意改作用域——REQUEST scope 会显著增加内存和 GC 压力,因为每个请求都要创建和销毁实例。 ## 循环依赖 A 依赖 B,B 依赖 A——Nest 无法确定先创建谁。解决方式:用 `forwardRef` 延迟解析。 ```typescript @Module({ imports: [forwardRef(() => ModuleB)], }) export class ModuleA {} ``` ```typescript constructor( @Inject(forwardRef(() => ServiceB)) private serviceB: ServiceB, ) {} ``` 但循环依赖通常意味着设计有问题——考虑抽取共享逻辑到第三个 Module 里。
服务端6月2日 01:27
NestJS 怎么写测试?单元测试、E2E 测试和 Mock 实战NestJS 内置 Jest 支持,开箱即用。测试分两层:单元测试(测 Service/Controller 的逻辑)和 E2E 测试(测整个请求链路)。关键是学会 Mock 依赖——单元测试不应该依赖数据库或外部服务。 ## 单元测试:测 Service ```typescript // users/users.service.spec.ts describe('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——测试不碰数据库,跑得快而且稳定。 ## 测 Controller Controller 的测试重点是"路由是否正确调用 Service": ```typescript 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 服务器,发真实请求,验证整个链路: ```typescript // test/app.e2e-spec.ts describe('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,测完自动销毁: ```typescript 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(如支付、邮件),测试时不应该真的调: ```typescript providers: [ PaymentService, { provide: 'PAYMENT_CLIENT', useValue: { charge: jest.fn().mockResolvedValue({ success: true }) }, }, ], ``` 用自定义 Provider 替换外部服务客户端。测试只验证你的代码逻辑是否正确,不验证第三方服务是否正常。 ## 测试覆盖率 ```bash 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 方法。
服务端6月2日 01:26
NestJS 怎么做实时通信?WebSocket Gateway 和 Socket.IO 集成NestJS 的 WebSocket 支持基于 Socket.IO,用装饰器风格的 Gateway 替代传统的事件监听写法。和 HTTP Controller 几乎一样的开发体验,底层自动处理连接、重连、房间管理。 ## 基本 Gateway ```bash npm install @nestjs/websockets @nestjs/platform-socket.io socket.io ``` ```typescript // 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 的房间机制让消息只发给特定用户群: ```typescript @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: ```typescript @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: ```javascript const socket = io('http://localhost:3000', { auth: { token: 'your-jwt-token' } }); ``` ## 配合 HTTP Controller WebSocket 和 HTTP 可以共享 Service 层: ```typescript // messages/messages.module.ts @Module({ imports: [TypeOrmModule.forFeature([Message])], providers: [MessagesService, MessagesGateway], controllers: [MessagesController], }) export class MessagesModule {} ``` ```typescript // 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: ```typescript import { RedisIoAdapter } from '@nestjs/platform-socket.io'; const redisIoAdapter = new RedisIoAdapter(app); await redisIoAdapter.connectToRedis('redis://localhost:6379'); app.useWebSocketAdapter(redisIoAdapter); ``` Redis Adapter 让所有进程通过 Redis 共享房间和消息状态。
服务端6月2日 01:25
NestJS 微服务怎么设计?Transport 层、消息模式和架构选型NestJS 的微服务支持不是"把单体拆成微服务"的完整方案,而是提供了跨服务通信的 Transport 层。你可以用同样的 Controller/Service 写法,底层换成 Redis/RabbitMQ/Kafka/gRPC 通信,应用代码几乎不用改。 ## 微服务模式 vs 单体 NestJS 应用默认是 HTTP 单体。改成微服务只需要换一个传输层: ```typescript // 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 一样——发请求等响应。适合需要立即拿到结果的场景。 ```typescript // 服务端 @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"。适合通知类场景(发邮件、写日志、更新缓存)。 ```typescript // 发布者 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 支持混合模式: ```typescript // main.ts const 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。
服务端6月2日 01:25
NestJS 怎么连数据库?TypeORM 和 Prisma 集成实战NestJS 集成数据库的主流方案有两个:TypeORM(官方推荐,装饰器风格)和 Prisma(类型安全更好,迁移体验好)。选哪个看团队偏好——TypeORM 和 NestJS 风格统一,Prisma 的类型推导更强。 ## TypeORM 集成 ```bash npm install @nestjs/typeorm typeorm pg ``` ### 配置连接 ```typescript // app.module.ts import { 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` 防止生产环境自动改表结构(丢数据风险)。 ### 定义实体 ```typescript // users/user.entity.ts @Entity() export class User { @PrimaryGeneratedColumn('uuid') id: string; @Column({ unique: true }) email: string; @Column() password: string; @CreateDateColumn() createdAt: Date; } ``` ### 在模块中注册 ```typescript // users/users.module.ts @Module({ imports: [TypeOrmModule.forFeature([User])], providers: [UsersService], controllers: [UsersController], }) export class UsersModule {} ``` ### 使用 Repository ```typescript // 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 集成 ```bash npm install prisma @prisma/client npx prisma init ``` ### 定义 Schema ```prisma // prisma/schema.prisma model 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()) } ``` ### 生成迁移并应用 ```bash npx prisma migrate dev --name init ``` ### 在 NestJS 中使用 ```typescript // 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 迁移**: ```bash npx typeorm migration:generate -n AddUserTable npx typeorm migration:run ``` **Prisma 迁移**: ```bash npx prisma migrate dev --name add_user_table # 开发 npx prisma migrate deploy # 生产 ``` `migrate dev` 生成迁移文件并应用,`migrate deploy` 只应用已有文件,适合 CI/CD。
服务端6月2日 01:23
NestJS 怎么做认证和授权?JWT、Guards 和 RBAC 实战NestJS 的认证授权用 Guards(守卫)+ 策略模式实现。认证(Authentication)验证"你是谁",授权(Authorization)验证"你能做什么"。JWT 是最常用的认证方案,RBAC 是最常用的授权模型。 ## JWT 认证 安装依赖: ```bash npm install @nestjs/jwt @nestjs/passport passport passport-jwt ``` ### 配置 JWT 策略 ```typescript // 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 ```typescript // 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), }; } } ``` ### 保护路由 ```typescript @UseGuards(AuthGuard('jwt')) @Get('profile') getProfile(@Request() req) { return req.user; } ``` 没带 Token 或 Token 过期的请求返回 401。 ## RBAC 角色授权 认证只解决"你是谁",授权解决"你能做什么"。 ```typescript // auth/roles.decorator.ts export 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)); } } ``` 使用: ```typescript @UseGuards(AuthGuard('jwt'), RolesGuard) @Roles('admin') @Delete('users/:id') removeUser() { return '仅管理员可操作'; } ``` 先过 `AuthGuard` 验证身份(拿到 user),再过 `RolesGuard` 验证角色。顺序不能反——没有 user 对象就没法检查角色。 ## 密码安全 永远不要明文存密码。用 bcrypt 哈希: ```typescript 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` 限制登录接口的请求频率。 ```typescript ThrottlerModule.forRoot([{ ttl: 60000, limit: 5 }]), ``` 60 秒内同一 IP 最多 5 次登录请求。 **2. CORS 配置**:只允许可信域名访问。 ```typescript 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 验证所有入参,防止注入攻击。 ```typescript export class CreateUserDto { @IsEmail() email: string; @MinLength(8) password: string; } ``` ## Token 刷新 JWT 一旦签发无法撤销(这是无状态的设计)。如果用户改了密码或被踢下线,旧的 Token 仍然有效直到过期。 两种解决方式: - **短过期 + Refresh Token**:access_token 15 分钟过期,refresh_token 7 天过期。refresh_token 存数据库可以主动撤销 - **Token 黑名单**:过期前把 Token 加入 Redis 黑名单,每次请求检查黑名单。牺牲了无状态的优点但更安全
服务端5月27日 23:03
NestJS 性能优化有哪些方法?## 为什么 NestJS 应用需要性能优化 NestJS 默认基于 Express 构建,开箱即用时很多配置偏向开发便利而非运行效率。当并发量上来之后,数据库查询堆积、内存泄漏、序列化开销大等问题会集中暴露。优化的核心思路就三条:减少不必要的计算、减少不必要的等待、让该并行的并行起来。 ## 一、换掉 Express,用 Fastify 适配器 这是投入产出比最高的一步。Fastify 的路由匹配和序列化性能显著优于 Express,NestJS 官方原生支持切换,代码改动极小: ```typescript 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` 验证索引是否被命中: ```typescript @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: ```typescript // 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` 一次搞定: ```typescript // 1 次查询搞定 const users = await this.userRepo.find({ relations: ['orders'] }); // 或者需要更精细控制时 const users = await this.userRepo .createQueryBuilder('user') .leftJoinAndSelect('user.orders', 'order') .getMany(); ``` ### 2.3 连接池调优 默认连接池大小往往不够用或用不满。关键参数: ```typescript TypeOrmModule.forRoot({ type: 'mysql', // ... extra: { connectionLimit: 50, // 活跃连接上限 waitForConnections: true, // 连接耗尽时排队等待 queueLimit: 0, // 排队不限人数 acquireTimeout: 30000, // 等待连接超时 30s }, poolSize: 50, // TypeORM 原生参数(PostgreSQL 用这个) }) ``` 连接池大小有个经验公式:`连接数 = (CPU 核心数 * 2) + 有效磁盘数`。但实际要根据监控数据调整——如果活跃连接数长期接近上限就加,如果大部分时间空闲就减。 ### 2.4 分页必须做 不分页的列表查询是定时炸弹,数据量增长后直接拖垮数据库: ```typescript 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` 可以快速接入: ```typescript 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 {} ``` 然后直接用拦截器缓存整个接口响应: ```typescript @UseInterceptors(CacheInterceptor) @Get('hot-articles') getHotArticles() { return this.articleService.findHot(); } ``` 注意:POST 请求默认不被缓存,缓存 key 基于 URL,带查询参数的接口要确保参数一致时 key 也一致。 ### 3.2 多级缓存 单层 Redis 缓存的问题是每次都要走网络。如果某些数据读多写极少,可以在 Redis 前面加一层内存缓存: ```typescript @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 能并行就不要串行 多个互不依赖的异步操作,串行等就是浪费: ```typescript // 串行 —— 总耗时 = 查用户 + 查订单 + 查通知 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 队列异步处理: ```typescript 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 封装,但自己集成不难: ```typescript 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%: ```typescript 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 握手开销: ```typescript 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 超时: ```typescript 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 支持模块懒加载,只在首次请求时初始化: ```typescript 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` 后每次序列化都要遍历所有属性,数据量大时开销不可忽视: ```typescript // 全局开启严格序列化 app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, // 自动剔除未装饰的属性 forbidNonWhitelisted: true, // 有未装饰属性直接报错 }), ); ``` 如果某个接口返回数据量大且不需要复杂转换,可以绕过 class-transformer 直接返回 Plain Object: ```typescript @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 可以抓堆快照对比定位: ```bash node --inspect dist/main.js # 然后打开 chrome://inspect,点击 Profile 抓堆快照 ``` 代码层面的防御性写法: ```typescript @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 多个进程: ```javascript // ecosystem.config.js module.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 终止、静态文件、负载均衡: ```nginx 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` 暴露事件循环延迟、堆内存、活跃句柄数等指标 ```typescript 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 适配器和加索引可能只要半天,效果却比花一周重构代码架构明显得多。
服务端5月27日 23:03
NestJS 部署到生产环境有哪些关键步骤?## 从开发到生产:部署的全局视角 把一个 NestJS 应用从本地跑通到稳定上线,中间要跨越的不仅仅是"能跑起来"这么简单。生产环境面对的是真实流量、不可控的依赖服务、随时可能出现的故障——部署方案的选择直接影响应用的可用性和团队迭代效率。 这篇内容围绕 NestJS 应用的生产部署展开,从容器化打包、编排调度、CI/CD 自动化、环境配置管理、可观测性建设到弹性伸缩,把每个环节中值得关注的实践细节梳理清楚。 ## Docker 容器化:构建可复制的运行环境 容器化是现代部署的起点。把应用和它的依赖打包成一个不可变的镜像,消除了"我这能跑你那不行"的环境差异问题。对 NestJS 来说,多阶段构建是减少镜像体积的关键手段。 ### 多阶段 Dockerfile 一个面向生产的 Dockerfile 应该把构建和运行分开: ```dockerfile # 构建阶段:安装全部依赖,编译 TypeScript FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 运行阶段:只装生产依赖,复制编译产物 FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # 非 root 用户运行,提升安全性 RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY package*.json ./ RUN npm ci --only=production COPY --from=builder /app/dist ./dist USER appuser EXPOSE 3000 CMD ["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 配置 ```text node_modules dist .git .env *.log coverage .vscode ``` 不要把 `node_modules` 和 `dist` 打进构建上下文——前者体积大且会在容器内重新安装,后者会被容器内编译覆盖。`.env` 文件包含敏感信息,绝对不能进镜像。 ### 镜像构建与本地验证 ```bash # 构建镜像 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 配置 ```yaml 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-stopped volumes: mysql_data: ``` 这里有几个容易忽略的细节: `depends_on` 配合 `condition: service_healthy` 确保数据库真正就绪后才启动应用,而不仅仅是容器启动。如果只用 `depends_on: db`,应用可能比数据库初始化先跑起来,导致连接失败。 `volumes: ./src:/app/src` 把源码挂载进容器,配合 NestJS 的热重载,开发时改代码不需要重新构建镜像。但这个挂载只在开发环境使用,生产镜像不挂载任何源码卷。 ## Kubernetes:生产级容器编排 当应用需要高可用、自动伸缩、滚动更新时,Kubernetes 是最主流的编排方案。NestJS 作为无状态应用,在 K8s 上部署相对直观,但配置细节决定稳定性。 ### Deployment:声明式管理应用实例 ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nestjs-app labels: app: nestjs-app spec: 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: 3 ``` `strategy` 部分的 `maxUnavailable: 0` 表示滚动更新时不允许任何时刻有实例不可用——每次先启动新实例,健康检查通过后才销毁旧实例,实现零停机部署。 `resources` 的 requests 和 limits 必须设置。不设 limits 的容器可能占用节点全部内存导致 OOM Killer 波及其他 Pod;不设 requests 则调度器无法做出合理的节点分配决策。NestJS 应用的资源需求取决于业务复杂度,建议从 requests 256Mi/250m、limits 512Mi/500m 起步,根据监控数据逐步调优。 ### Service 和 Ingress:流量入口 ```yaml apiVersion: v1 kind: Service metadata: name: nestjs-app-service spec: selector: app: nestjs-app ports: - protocol: TCP port: 80 targetPort: 3000 type: ClusterIP --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: 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: 80 ``` Service 用 `ClusterIP` 类型(默认值),不直接对外暴露,流量统一由 Ingress 管理。Ingress 配合 `cert-manager` 自动管理 TLS 证书,加上 `rate-limit` 注解做基础的限流保护。 ## CI/CD 管道:自动化构建与发布 手动部署容易出错且无法追溯。CI/CD 管道把测试、构建、发布串联成自动化流程,每次代码变更都经过完整验证后才到达生产环境。 ### GitHub Actions 实战配置 ```yaml name: CI/CD Pipeline on: 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 滚动更新自动完成实例替换。 ## 环境变量与密钥管理 环境变量是配置管理的基石,但不同环境的管理策略差异很大。 ### 分层配置方案 ```typescript // config/configuration.ts export 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', }, }); ``` ```typescript // app.module.ts import { 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),能形成完整的密钥保护链路。 ```yaml apiVersion: v1 kind: Secret metadata: name: db-secret type: Opaque stringData: host: "your-db-host.internal" port: "3306" user: "app_user" password: "s3cur3P@ssw0rd" ``` 注意这里用 `stringData` 而不是 `data`——前者直接写明文字符串,K8s 自动做 Base64 编码;后者需要自己先编码。功能上等价,但 `stringData` 在编写时不容易出错。 ## 健康检查:让编排系统了解应用状态 Kubernetes 的自愈能力依赖健康检查。如果应用没有暴露健康端点,K8s 只能根据进程是否存在来判断状态——进程活着但已经死锁的情况无法检测。 ### Terminus 健康检查 ```typescript 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)检索和聚合: ```typescript 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 指标采集 ```typescript 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 仪表板,能直观反映系统健康状况和性能瓶颈。 ### 告警规则示例 ```yaml 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 反向代理 ```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 自动伸缩 ```yaml apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: nestjs-hpa spec: 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。 ### 数据库备份与恢复 ```bash #!/bin/bash set -euo pipefail DATE=$(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 部署方案。