From 75dbfd305bf7c97b4b3bb1ed49ff720f31024bb7 Mon Sep 17 00:00:00 2001 From: wehub-skill-sync Date: Mon, 13 Jul 2026 21:36:02 +0800 Subject: [PATCH] chore: import zh skill nestjs-expert --- README.wehub.md | 9 + SKILL.md | 208 +++++ references/authentication.md | 166 ++++ references/controllers-routing.md | 225 +++++ references/dtos-validation.md | 153 ++++ references/migration-from-express.md | 1244 ++++++++++++++++++++++++++ references/services-di.md | 140 +++ references/testing-patterns.md | 186 ++++ 8 files changed, 2331 insertions(+) create mode 100644 README.wehub.md create mode 100644 SKILL.md create mode 100644 references/authentication.md create mode 100644 references/controllers-routing.md create mode 100644 references/dtos-validation.md create mode 100644 references/migration-from-express.md create mode 100644 references/services-di.md create mode 100644 references/testing-patterns.md diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..4b010ba --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,9 @@ +# WeHub 来源说明 + +- Skill 名称:`nestjs-expert` +- 中文类目:Node.js/TypeScript后端框架开发 +- 上游仓库:`jeffallan__claude-skills` +- 上游路径:`skills/nestjs-expert/SKILL.md` +- 上游链接:https://github.com/jeffallan/claude-skills/blob/HEAD/skills/nestjs-expert/SKILL.md +- 本仓库为 WeHub 中文 Skill 汉化包,基于 skill 市场筛选 Top200 清单整理 +- 原作者、版权和许可证信息以上游仓库为准 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..5cd0a48 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,208 @@ +--- +name: nestjs-expert +description: 创建并配置适用于企业级 TypeScript 后端应用的 NestJS 模块、控制器、服务、DTO、守卫和拦截器。在构建 NestJS REST API 或 GraphQL 服务、实现依赖注入、搭建模块化架构、添加 JWT/Passport 身份验证、集成 TypeORM 或 Prisma,以及处理 .module.ts、.controller.ts 和 .service.ts 文件时使用。在 NestJS 项目中调用守卫、拦截器、管道、验证、Swagger 文档以及单元/E2E 测试。 +license: MIT +metadata: + author: https://github.com/Jeffallan + version: "1.1.0" + domain: backend + triggers: NestJS, Nest, Node.js backend, TypeScript backend, dependency injection, controller, service, module, guard, interceptor + role: specialist + scope: implementation + output-format: code + related-skills: fullstack-guardian, test-master, devops-engineer +--- + +# NestJS Expert + +高级 NestJS 专家,精通企业级、可扩展的 TypeScript 后端应用。 + +## 核心工作流 + +1. **分析需求** — 确定模块、端点、实体及其关系 +2. **设计结构** — 规划模块组织及模块间的依赖关系 +3. **实现** — 创建模块、服务和控制器,并正确进行依赖注入连接 +4. **安全加固** — 添加守卫、验证管道和身份验证 +5. **验证** — 运行 `npm run lint`、`npm run test`,并通过 `nest info` 确认依赖注入图 +6. **测试** — 为服务编写单元测试,为控制器编写 E2E 测试 + +## 参考指南 + +根据上下文加载详细指导: + +| 主题 | 参考文档 | 加载时机 | +|-------|-----------|-----------| +| 控制器 | `references/controllers-routing.md` | 创建控制器、路由、Swagger 文档时 | +| 服务 | `references/services-di.md` | 服务、依赖注入、提供者时 | +| DTO | `references/dtos-validation.md` | 验证、class-validator、DTO 时 | +| 身份验证 | `references/authentication.md` | JWT、Passport、守卫、授权时 | +| 测试 | `references/testing-patterns.md` | 单元测试、E2E 测试、模拟时 | +| Express 迁移 | `references/migration-from-express.md` | 从 Express.js 迁移到 NestJS 时 | + +## 代码示例 + +### 带 DTO 验证和 Swagger 的控制器 + +```typescript +// create-user.dto.ts +import { IsEmail, IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateUserDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'strongPassword123', minLength: 8 }) + @IsString() + @MinLength(8) + password: string; +} + +// users.controller.ts +import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; + +@ApiTags('users') +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiCreatedResponse({ description: 'User created successfully.' }) + create(@Body() createUserDto: CreateUserDto) { + return this.usersService.create(createUserDto); + } +} +``` + +### 带依赖注入和错误处理的服务 + +```typescript +// users.service.ts +import { Injectable, ConflictException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from './entities/user.entity'; +import { CreateUserDto } from './dto/create-user.dto'; + +@Injectable() +export class UsersService { + constructor( + @InjectRepository(User) + private readonly usersRepository: Repository, + ) {} + + async create(createUserDto: CreateUserDto): Promise { + const existing = await this.usersRepository.findOneBy({ email: createUserDto.email }); + if (existing) { + throw new ConflictException('Email already registered'); + } + const user = this.usersRepository.create(createUserDto); + return this.usersRepository.save(user); + } + + async findOne(id: number): Promise { + const user = await this.usersRepository.findOneBy({ id }); + if (!user) { + throw new NotFoundException(`User #${id} not found`); + } + return user; + } +} +``` + +### 模块定义 + +```typescript +// users.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { User } from './entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], // 仅在其他模块需要此服务时导出 +}) +export class UsersModule {} +``` + +### 服务的单元测试 + +```typescript +// users.service.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConflictException } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { User } from './entities/user.entity'; + +const mockRepo = { + findOneBy: jest.fn(), + create: jest.fn(), + save: jest.fn(), +}; + +describe('UsersService', () => { + let service: UsersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: getRepositoryToken(User), useValue: mockRepo }, + ], + }).compile(); + service = module.get(UsersService); + jest.clearAllMocks(); + }); + + it('当邮箱已存在时抛出 ConflictException', async () => { + mockRepo.findOneBy.mockResolvedValue({ id: 1, email: 'user@example.com' }); + await expect( + service.create({ email: 'user@example.com', password: 'pass1234' }), + ).rejects.toThrow(ConflictException); + }); +}); +``` + +## 约束 + +### 必须遵循 +- 对所有服务使用 `@Injectable()` 和构造器注入 — 绝不要用 `new` 实例化服务 +- 在 DTO 上使用 `class-validator` 装饰器验证所有输入,并在全局启用 `ValidationPipe` +- 对所有请求/响应体使用 DTO;绝不要将原始 `req.body` 传递给服务 +- 在服务中抛出带类型的 HTTP 异常(`NotFoundException`、`ConflictException` 等) +- 使用 `@ApiTags`、`@ApiOperation` 和响应装饰器记录所有端点 +- 使用 `Test.createTestingModule` 为每个服务方法编写单元测试 +- 通过 `ConfigModule` 和 `process.env` 存储所有配置值;绝不要硬编码 + +### 严禁操作 +- 在响应中暴露密码、密钥或内部堆栈跟踪信息 +- 接受未经验证的用户输入 — 始终应用 `ValidationPipe` +- 使用 `any` 类型,除非绝对必要且已记录说明 +- 在模块之间创建循环依赖 — 仅作为最后手段使用 `forwardRef()` +- 在源文件中硬编码主机名、端口或凭据 +- 在服务方法中跳过错误处理 + +## 输出模板 + +实现 NestJS 功能时,按以下顺序提供: +1. 模块定义(`.module.ts`) +2. 带 Swagger 装饰器的控制器(`.controller.ts`) +3. 带类型化错误处理的服务(`.service.ts`) +4. 带 `class-validator` 装饰器的 DTO(`dto/*.dto.ts`) +5. 服务方法的单元测试(`*.service.spec.ts`) + +## 知识参考 + +NestJS, TypeScript, TypeORM, Prisma, Passport, JWT, class-validator, class-transformer, Swagger/OpenAPI, Jest, Supertest, Guards, Interceptors, Pipes, Filters + +[文档](https://jeffallan.github.io/claude-skills/skills/backend/nestjs-expert/) diff --git a/references/authentication.md b/references/authentication.md new file mode 100644 index 0000000..61ee474 --- /dev/null +++ b/references/authentication.md @@ -0,0 +1,166 @@ +# 认证与守卫 + +## JWT 策略 + +```typescript +// jwt.strategy.ts +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: config.get('JWT_SECRET'), + }); + } + + async validate(payload: { sub: string; email: string; role: string }) { + return { userId: payload.sub, email: payload.email, role: payload.role }; + } +} +``` + +## JWT 认证守卫 + +```typescript +// jwt-auth.guard.ts +import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.get('isPublic', context.getHandler()); + if (isPublic) return true; + return super.canActivate(context); + } + + handleRequest(err: any, user: any) { + if (err || !user) { + throw err || new UnauthorizedException('无效的令牌'); + } + return user; + } +} + +// 公开装饰器 +export const Public = () => SetMetadata('isPublic', true); +``` + +## 角色守卫 + +```typescript +// roles.decorator.ts +export const Roles = (...roles: string[]) => SetMetadata('roles', roles); + +// roles.guard.ts +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const roles = this.reflector.getAllAndOverride('roles', [ + context.getHandler(), + context.getClass(), + ]); + if (!roles) return true; + + const { user } = context.switchToHttp().getRequest(); + return roles.includes(user.role); + } +} + +// 用法 +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('admin') +@Get('admin') +adminEndpoint() {} +``` + +## 认证服务 + +```typescript +@Injectable() +export class AuthService { + constructor( + private usersService: UsersService, + private jwtService: JwtService, + ) {} + + async validateUser(email: string, password: string): Promise { + const user = await this.usersService.findByEmail(email); + if (user && await bcrypt.compare(password, user.password)) { + return user; + } + return null; + } + + async login(user: User) { + const payload = { sub: user.id, email: user.email, role: user.role }; + return { + access_token: this.jwtService.sign(payload), + refresh_token: this.jwtService.sign(payload, { expiresIn: '7d' }), + }; + } + + async register(dto: CreateUserDto) { + const hashedPassword = await bcrypt.hash(dto.password, 10); + return this.usersService.create({ ...dto, password: hashedPassword }); + } +} +``` + +## 认证模块配置 + +```typescript +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET'), + signOptions: { expiresIn: '15m' }, + }), + }), + UsersModule, + ], + providers: [AuthService, JwtStrategy], + exports: [AuthService], +}) +export class AuthModule {} +``` + +## 全局应用守卫 + +```typescript +// app.module.ts +@Module({ + providers: [ + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], +}) +export class AppModule {} +``` + +## 快速参考 + +| 组件 | 用途 | +|-----------|---------| +| `JwtStrategy` | 验证 JWT 令牌 | +| `JwtAuthGuard` | 保护路由 | +| `RolesGuard` | 基于角色的访问控制 | +| `@Public()` | 跳过认证 | +| `@Roles('admin')` | 要求角色 | +| `@UseGuards()` | 应用守卫 | diff --git a/references/controllers-routing.md b/references/controllers-routing.md new file mode 100644 index 0000000..79ed182 --- /dev/null +++ b/references/controllers-routing.md @@ -0,0 +1,225 @@ +```typescript +// Controllers & Routing + +## Controller with Swagger + +```typescript +import { + Controller, Get, Post, Patch, Delete, + Body, Param, Query, HttpCode, HttpStatus, UseGuards +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { ParseUUIDPipe, ParseIntPipe } from '@nestjs/common'; + +@Controller('users') +@ApiTags('users') +@UseGuards(JwtAuthGuard) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + @ApiOperation({ summary: 'Create user' }) + @ApiResponse({ status: 201, type: UserDto }) + @ApiResponse({ status: 400, description: 'Validation failed' }) + create(@Body() dto: CreateUserDto): Promise { + return this.usersService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'Get all users' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + findAll( + @Query('page', new ParseIntPipe({ optional: true })) page = 1, + @Query('limit', new ParseIntPipe({ optional: true })) limit = 20, + ): Promise { + return this.usersService.findAll({ page, limit }); + } + + @Get(':id') + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + @ApiResponse({ status: 200, type: UserDto }) + @ApiResponse({ status: 404, description: 'User not found' }) + findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.usersService.findOne(id); + } + + @Patch(':id') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateUserDto, + ): Promise { + return this.usersService.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.usersService.remove(id); + } +} +``` + +## Nested Routes + +```typescript +@Controller('posts/:postId/comments') +@ApiTags('comments') +export class CommentsController { + @Get() + findAll(@Param('postId', ParseUUIDPipe) postId: string) { + return this.commentsService.findByPost(postId); + } + + @Post() + create( + @Param('postId', ParseUUIDPipe) postId: string, + @Body() dto: CreateCommentDto, + ) { + return this.commentsService.create(postId, dto); + } +} +``` + +## Global Prefix & Versioning + +```typescript +// main.ts +const app = await NestFactory.create(AppModule); +app.setGlobalPrefix('api'); +app.enableVersioning({ type: VersioningType.URI }); + +// controller.ts +@Controller({ path: 'users', version: '1' }) // /api/v1/users +export class UsersV1Controller {} + +@Controller({ path: 'users', version: '2' }) // /api/v2/users +export class UsersV2Controller {} +``` + +## Quick Reference + +| Decorator | Purpose | +|-----------|---------| +| `@Controller('path')` | Define route prefix | +| `@Get()`, `@Post()` | HTTP method | +| `@Param('name')` | Path parameter | +| `@Query('name')` | Query parameter | +| `@Body()` | Request body | +| `@HttpCode(201)` | Override status code | +| `@ApiTags()` | Swagger grouping | +| `@ApiOperation()` | Endpoint description | +| `@ApiResponse()` | Document response | +``` + +# 控制器与路由 + +## 带 Swagger 的控制器 + +```typescript +import { + Controller, Get, Post, Patch, Delete, + Body, Param, Query, HttpCode, HttpStatus, UseGuards +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { ParseUUIDPipe, ParseIntPipe } from '@nestjs/common'; + +@Controller('users') +@ApiTags('users') +@UseGuards(JwtAuthGuard) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + @ApiOperation({ summary: 'Create user' }) + @ApiResponse({ status: 201, type: UserDto }) + @ApiResponse({ status: 400, description: 'Validation failed' }) + create(@Body() dto: CreateUserDto): Promise { + return this.usersService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'Get all users' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + findAll( + @Query('page', new ParseIntPipe({ optional: true })) page = 1, + @Query('limit', new ParseIntPipe({ optional: true })) limit = 20, + ): Promise { + return this.usersService.findAll({ page, limit }); + } + + @Get(':id') + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + @ApiResponse({ status: 200, type: UserDto }) + @ApiResponse({ status: 404, description: 'User not found' }) + findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.usersService.findOne(id); + } + + @Patch(':id') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateUserDto, + ): Promise { + return this.usersService.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.usersService.remove(id); + } +} +``` + +## 嵌套路由 + +```typescript +@Controller('posts/:postId/comments') +@ApiTags('comments') +export class CommentsController { + @Get() + findAll(@Param('postId', ParseUUIDPipe) postId: string) { + return this.commentsService.findByPost(postId); + } + + @Post() + create( + @Param('postId', ParseUUIDPipe) postId: string, + @Body() dto: CreateCommentDto, + ) { + return this.commentsService.create(postId, dto); + } +} +``` + +## 全局前缀与版本控制 + +```typescript +// main.ts +const app = await NestFactory.create(AppModule); +app.setGlobalPrefix('api'); +app.enableVersioning({ type: VersioningType.URI }); + +// controller.ts +@Controller({ path: 'users', version: '1' }) // /api/v1/users +export class UsersV1Controller {} + +@Controller({ path: 'users', version: '2' }) // /api/v2/users +export class UsersV2Controller {} +``` + +## 快速参考 + +| 装饰器 | 用途 | +|-----------|---------| +| `@Controller('path')` | 定义路由前缀 | +| `@Get()`, `@Post()` | HTTP 方法 | +| `@Param('name')` | 路径参数 | +| `@Query('name')` | 查询参数 | +| `@Body()` | 请求体 | +| `@HttpCode(201)` | 覆盖状态码 | +| `@ApiTags()` | Swagger 分组 | +| `@ApiOperation()` | 端点描述 | +| `@ApiResponse()` | 文档响应 | diff --git a/references/dtos-validation.md b/references/dtos-validation.md new file mode 100644 index 0000000..9340bd6 --- /dev/null +++ b/references/dtos-validation.md @@ -0,0 +1,153 @@ +# DTO 与验证 + +## DTO 模式 + +```typescript +import { + IsEmail, IsString, IsOptional, IsBoolean, IsInt, + MinLength, MaxLength, Min, Max, IsUUID, IsEnum, + IsArray, ArrayMinSize, ValidateNested, Matches +} from 'class-validator'; +import { Type, Transform } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional, PartialType, OmitType, PickType } from '@nestjs/swagger'; + +export class CreateUserDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ minLength: 8 }) + @IsString() + @MinLength(8) + @Matches(/^(?=.*[A-Z])(?=.*\d)/, { message: '密码必须包含大写字母和数字' }) + password: string; + + @ApiProperty() + @IsString() + @MinLength(2) + @MaxLength(50) + name: string; + + @ApiPropertyOptional({ enum: UserRole, default: UserRole.USER }) + @IsOptional() + @IsEnum(UserRole) + role?: UserRole = UserRole.USER; +} + +// 用于更新的 Partial(所有字段可选) +export class UpdateUserDto extends PartialType( + OmitType(CreateUserDto, ['password'] as const) +) {} + +// 选取特定字段 +export class LoginDto extends PickType(CreateUserDto, ['email', 'password'] as const) {} +``` + +## 嵌套验证 + +```typescript +export class CreateOrderDto { + @ApiProperty({ type: [OrderItemDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => OrderItemDto) + items: OrderItemDto[]; + + @ApiProperty({ type: AddressDto }) + @ValidateNested() + @Type(() => AddressDto) + shippingAddress: AddressDto; +} + +export class OrderItemDto { + @IsUUID() + productId: string; + + @IsInt() + @Min(1) + @Max(100) + quantity: number; +} +``` + +## 自定义验证 + +```typescript +import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator'; + +// 自定义装饰器 +export function IsStrongPassword(options?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isStrongPassword', + target: object.constructor, + propertyName, + options, + validator: { + validate(value: string) { + return /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$/.test(value); + }, + defaultMessage(): string { + return '密码必须包含大写字母、小写字母、数字和特殊字符'; + }, + }, + }); + }; +} + +// 使用示例 +@IsStrongPassword() +password: string; +``` + +## 转换与清理 + +```typescript +export class QueryDto { + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(1) + page: number = 1; + + @Transform(({ value }) => value?.trim().toLowerCase()) + @IsString() + @IsOptional() + search?: string; + + @Transform(({ value }) => value === 'true') + @IsBoolean() + isActive: boolean = true; +} +``` + +## 全局启用验证 + +```typescript +// main.ts +app.useGlobalPipes(new ValidationPipe({ + whitelist: true, // 剥离未知属性 + forbidNonWhitelisted: true, // 遇到未知属性时抛出异常 + transform: true, // 自动转换类型 + transformOptions: { + enableImplicitConversion: true, + }, +})); +``` + +## 快速参考 + +| 装饰器 | 用途 | +|-----------|---------| +| `@IsString()` | 字符串类型 | +| `@IsEmail()` | 有效邮箱 | +| `@MinLength(n)` | 最小字符串长度 | +| `@IsInt()`, `@Min(n)` | 整数验证 | +| `@IsEnum(Enum)` | 枚举值 | +| `@IsOptional()` | 可选字段 | +| `@ValidateNested()` | 验证嵌套对象 | +| `@Type(() => Class)` | 转换为类实例 | +| `@Transform()` | 自定义转换 | +| `PartialType()` | 所有字段变为可选 | +| `OmitType()` | 排除字段 | +| `PickType()` | 仅保留指定字段 | diff --git a/references/migration-from-express.md b/references/migration-from-express.md new file mode 100644 index 0000000..6790303 --- /dev/null +++ b/references/migration-from-express.md @@ -0,0 +1,1244 @@ +--- +name: express-to-nestjs-migration-guide +description: Express 到 NestJS 迁移指南 +metadata: + type: reference +--- + +# Express 到 NestJS 迁移指南 + +--- + +## 何时使用本指南 + +**适用场景:** +- 将现有的 Express.js 应用迁移到 NestJS +- 使用结构化架构现代化改造遗留的 Node.js API +- 为 Express 代码库添加 TypeScript 和依赖注入 +- 扩展需要更好组织结构的 Express 应用 +- 团队需要强制性的架构模式与约定 +- 应用复杂度证明了框架开销的合理性 + +**不适用场景:** + +- 简单的 CRUD API(少于 10 个端点)(Express 可能已足够) +- 对冷启动时间要求极低的 Serverless 函数 +- 原型或 MVP(速度优先于结构) +- 团队缺乏 TypeScript 经验且时间紧迫 +- 对性能要求极高的微服务(框架开销成为问题) +- 有特殊架构需求且与 NestJS 模式冲突的项目 + +--- + +## 概念映射:Express → NestJS + +| Express 概念 | NestJS 等价物 | 关键区别 | +|----------------|-------------------|----------------| +| `app.get('/path', handler)` | `@Get('/path')` 装饰器 | 声明式 vs 命令式 | +| 中间件函数 | Guards、Interceptors、Pipes | 按用途专业化 | +| `req.params`、`req.body` | `@Param()`、`@Body()` 装饰器 | 自动注入 | +| 手动 `require()` | 依赖注入 | IoC 容器管理 | +| `express.Router()` | 控制器类 | 面向对象分组 | +| `app.use(express.json())` | 内置 body 解析 | 自动配置 | +| 错误处理中间件 | 异常过滤器 | 基于类,支持继承 | +| `app.listen(3000)` | `NestFactory.create()` | 启动模式 | +| 自定义验证 | `class-validator` pipes | 基于装饰器的验证 | +| 手动服务实例 | 提供者注册 | 默认为单例 | + +--- + +## 架构对比 + +### Express 应用结构 + +``` +src/ +├── routes/ +│ ├── users.js +│ └── posts.js +├── controllers/ +│ ├── userController.js +│ └── postController.js +├── services/ +│ ├── userService.js +│ └── postService.js +├── middleware/ +│ ├── auth.js +│ └── errorHandler.js +└── app.js +``` + +### NestJS 应用结构 + +``` +src/ +├── users/ +│ ├── users.controller.ts +│ ├── users.service.ts +│ ├── users.module.ts +│ ├── dto/ +│ │ ├── create-user.dto.ts +│ │ └── update-user.dto.ts +│ └── entities/ +│ └── user.entity.ts +├── posts/ +│ ├── posts.controller.ts +│ ├── posts.service.ts +│ └── posts.module.ts +├── common/ +│ ├── guards/ +│ ├── interceptors/ +│ └── filters/ +├── app.module.ts +└── main.ts +``` + +--- + +## 迁移模式:路由处理 → 控制器 + +### 迁移前:Express 路由处理 + +```typescript +// routes/users.js +const express = require('express'); +const router = express.Router(); +const UserService = require('../services/userService'); + +const userService = new UserService(); + +router.get('/', async (req, res, next) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + + const users = await userService.findAll(page, limit); + res.json({ + success: true, + data: users, + page, + limit + }); + } catch (error) { + next(error); + } +}); + +router.get('/:id', async (req, res, next) => { + try { + const user = await userService.findById(req.params.id); + if (!user) { + return res.status(404).json({ + success: false, + message: 'User not found' + }); + } + res.json({ success: true, data: user }); + } catch (error) { + next(error); + } +}); + +router.post('/', async (req, res, next) => { + try { + const { email, name } = req.body; + + // Manual validation + if (!email || !name) { + return res.status(400).json({ + success: false, + message: 'Email and name are required' + }); + } + + const user = await userService.create({ email, name }); + res.status(201).json({ success: true, data: user }); + } catch (error) { + next(error); + } +}); + +module.exports = router; +``` + +### 迁移后:NestJS 控制器 + +```typescript +// users/dto/create-user.dto.ts +import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class CreateUserDto { + @IsEmail() + @IsNotEmpty() + email: string; + + @IsString() + @MinLength(2) + name: string; +} + +// users/dto/pagination-query.dto.ts +import { IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class PaginationQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; +} + +// users/users.controller.ts +import { + Controller, + Get, + Post, + Body, + Param, + Query, + HttpCode, + HttpStatus, + ParseIntPipe, +} from '@nestjs/common'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { PaginationQueryDto } from './dto/pagination-query.dto'; + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + async findAll(@Query() query: PaginationQueryDto) { + const users = await this.usersService.findAll(query.page, query.limit); + return { + success: true, + data: users, + page: query.page, + limit: query.limit, + }; + } + + @Get(':id') + async findOne(@Param('id', ParseIntPipe) id: number) { + const user = await this.usersService.findById(id); + return { success: true, data: user }; + } + + @Post() + @HttpCode(HttpStatus.CREATED) + async create(@Body() createUserDto: CreateUserDto) { + const user = await this.usersService.create(createUserDto); + return { success: true, data: user }; + } +} +``` + +--- + +## 迁移模式:中间件 → Guards/Interceptors + +### 迁移前:Express 认证中间件 + +```typescript +// middleware/auth.js +const jwt = require('jsonwebtoken'); + +function authMiddleware(req, res, next) { + const token = req.headers.authorization?.split(' ')[1]; + + if (!token) { + return res.status(401).json({ + success: false, + message: 'No token provided' + }); + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + req.user = decoded; + next(); + } catch (error) { + return res.status(401).json({ + success: false, + message: 'Invalid token' + }); + } +} + +// Usage in routes +router.get('/profile', authMiddleware, async (req, res) => { + const user = await userService.findById(req.user.id); + res.json({ success: true, data: user }); +}); +``` + +### 迁移后:NestJS Guard + +```typescript +// common/guards/jwt-auth.guard.ts +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor(private jwtService: JwtService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + + if (!token) { + throw new UnauthorizedException('No token provided'); + } + + try { + const payload = await this.jwtService.verifyAsync(token); + request['user'] = payload; + } catch { + throw new UnauthorizedException('Invalid token'); + } + + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} + +// Usage in controller +import { UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; + +@Controller('users') +export class UsersController { + @Get('profile') + @UseGuards(JwtAuthGuard) + async getProfile(@Request() req) { + return this.usersService.findById(req.user.id); + } +} +``` + +### 迁移前:Express 日志中间件 + +```typescript +// middleware/logger.js +function loggerMiddleware(req, res, next) { + const start = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - start; + console.log(`${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`); + }); + + next(); +} + +// app.js +app.use(loggerMiddleware); +``` + +### 迁移后:NestJS Interceptor + +```typescript +// common/interceptors/logging.interceptor.ts +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger(LoggingInterceptor.name); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const { method, url } = request; + const start = Date.now(); + + return next.handle().pipe( + tap(() => { + const response = context.switchToHttp().getResponse(); + const duration = Date.now() - start; + this.logger.log( + `${method} ${url} - ${response.statusCode} - ${duration}ms`, + ); + }), + ); + } +} + +// main.ts - Apply globally +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalInterceptors(new LoggingInterceptor()); + await app.listen(3000); +} +bootstrap(); +``` + +--- + +## 迁移模式:依赖注入 + +### 迁移前:Express 手动实例化 + +```typescript +// services/userService.js +const UserRepository = require('../repositories/userRepository'); +const EmailService = require('./emailService'); + +class UserService { + constructor() { + this.userRepository = new UserRepository(); + this.emailService = new EmailService(); + } + + async create(userData) { + const user = await this.userRepository.create(userData); + await this.emailService.sendWelcomeEmail(user.email); + return user; + } +} + +module.exports = UserService; + +// controllers/userController.js +const UserService = require('../services/userService'); +const userService = new UserService(); + +async function createUser(req, res) { + const user = await userService.create(req.body); + res.json({ success: true, data: user }); +} +``` + +### 迁移后:NestJS 依赖注入 + +```typescript +// users/users.repository.ts +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from './entities/user.entity'; + +@Injectable() +export class UsersRepository { + constructor( + @InjectRepository(User) + private readonly repository: Repository, + ) {} + + async create(userData: Partial): Promise { + const user = this.repository.create(userData); + return this.repository.save(user); + } + + async findById(id: number): Promise { + return this.repository.findOne({ where: { id } }); + } +} + +// email/email.service.ts +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + + async sendWelcomeEmail(email: string): Promise { + this.logger.log(`Sending welcome email to ${email}`); + // Email sending logic + } +} + +// users/users.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { UsersRepository } from './users.repository'; +import { EmailService } from '../email/email.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { User } from './entities/user.entity'; + +@Injectable() +export class UsersService { + constructor( + private readonly usersRepository: UsersRepository, + private readonly emailService: EmailService, + ) {} + + async create(createUserDto: CreateUserDto): Promise { + const user = await this.usersRepository.create(createUserDto); + await this.emailService.sendWelcomeEmail(user.email); + return user; + } + + async findById(id: number): Promise { + const user = await this.usersRepository.findById(id); + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + return user; + } +} + +// users/users.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { UsersRepository } from './users.repository'; +import { User } from './entities/user.entity'; +import { EmailModule } from '../email/email.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([User]), EmailModule], + controllers: [UsersController], + providers: [UsersService, UsersRepository], + exports: [UsersService], +}) +export class UsersModule {} +``` + +--- + +## 迁移模式:错误处理 + +### 迁移前:Express 错误中间件 + +```typescript +// middleware/errorHandler.js +function errorHandler(err, req, res, next) { + console.error(err.stack); + + if (err.name === 'ValidationError') { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: err.errors + }); + } + + if (err.name === 'UnauthorizedError') { + return res.status(401).json({ + success: false, + message: 'Unauthorized' + }); + } + + res.status(500).json({ + success: false, + message: 'Internal server error' + }); +} + +// app.js +app.use(errorHandler); +``` + +### 迁移后:NestJS 异常过滤器 + +```typescript +// common/filters/http-exception.filter.ts +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + let errors: any = undefined; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + if (typeof exceptionResponse === 'object') { + message = (exceptionResponse as any).message || message; + errors = (exceptionResponse as any).errors; + } else { + message = exceptionResponse; + } + } else if (exception instanceof Error) { + message = exception.message; + this.logger.error(exception.stack); + } + + response.status(status).json({ + success: false, + statusCode: status, + message, + errors, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} + +// main.ts +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalFilters(new HttpExceptionFilter()); + await app.listen(3000); +} +bootstrap(); +``` + +--- + +## 迁移模式:验证 + +### 迁移前:Express 使用 express-validator + +```typescript +// routes/users.js +const { body, validationResult } = require('express-validator'); + +router.post( + '/', + [ + body('email').isEmail().normalizeEmail(), + body('name').trim().isLength({ min: 2, max: 50 }), + body('age').optional().isInt({ min: 0, max: 120 }), + ], + async (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + errors: errors.array() + }); + } + + try { + const user = await userService.create(req.body); + res.status(201).json({ success: true, data: user }); + } catch (error) { + next(error); + } + } +); +``` + +### 迁移后:NestJS 使用 class-validator + +```typescript +// users/dto/create-user.dto.ts +import { + IsEmail, + IsNotEmpty, + IsString, + MinLength, + MaxLength, + IsOptional, + IsInt, + Min, + Max, +} from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class CreateUserDto { + @IsEmail() + @IsNotEmpty() + @Transform(({ value }) => value.toLowerCase().trim()) + email: string; + + @IsString() + @MinLength(2) + @MaxLength(50) + @Transform(({ value }) => value.trim()) + name: string; + + @IsOptional() + @IsInt() + @Min(0) + @Max(120) + age?: number; +} + +// users/users.controller.ts +import { Controller, Post, Body, ValidationPipe } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + async create(@Body(ValidationPipe) createUserDto: CreateUserDto) { + const user = await this.usersService.create(createUserDto); + return { success: true, data: user }; + } +} + +// main.ts - Global validation pipe +import { ValidationPipe } from '@nestjs/common'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip non-whitelisted properties + forbidNonWhitelisted: true, // Throw error for non-whitelisted + transform: true, // Auto-transform payloads to DTO instances + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + await app.listen(3000); +} +``` + +--- + +## 迁移模式:测试 + +### 迁移前:Express 使用 Mocha/Chai + +```typescript +// test/users.test.js +const request = require('supertest'); +const { expect } = require('chai'); +const app = require('../src/app'); + +describe('Users API', () => { + describe('POST /users', () => { + it('should create a new user', async () => { + const userData = { + email: 'test@example.com', + name: 'Test User' + }; + + const response = await request(app) + .post('/users') + .send(userData) + .expect(201); + + expect(response.body.success).to.be.true; + expect(response.body.data).to.have.property('id'); + expect(response.body.data.email).to.equal(userData.email); + }); + + it('should return 400 for invalid email', async () => { + const response = await request(app) + .post('/users') + .send({ email: 'invalid', name: 'Test' }) + .expect(400); + + expect(response.body.success).to.be.false; + }); + }); +}); +``` + +### 迁移后:NestJS 使用 Jest + +```typescript +// users/users.controller.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; + +describe('UsersController', () => { + let controller: UsersController; + let service: UsersService; + + const mockUsersService = { + create: jest.fn(), + findById: jest.fn(), + findAll: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [ + { + provide: UsersService, + useValue: mockUsersService, + }, + ], + }).compile(); + + controller = module.get(UsersController); + service = module.get(UsersService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a new user', async () => { + const createUserDto: CreateUserDto = { + email: 'test@example.com', + name: 'Test User', + }; + + const expectedUser = { + id: 1, + ...createUserDto, + createdAt: new Date(), + }; + + mockUsersService.create.mockResolvedValue(expectedUser); + + const result = await controller.create(createUserDto); + + expect(result.success).toBe(true); + expect(result.data).toEqual(expectedUser); + expect(service.create).toHaveBeenCalledWith(createUserDto); + expect(service.create).toHaveBeenCalledTimes(1); + }); + }); + + describe('findOne', () => { + it('should return a user by id', async () => { + const userId = 1; + const expectedUser = { + id: userId, + email: 'test@example.com', + name: 'Test User', + }; + + mockUsersService.findById.mockResolvedValue(expectedUser); + + const result = await controller.findOne(userId); + + expect(result.data).toEqual(expectedUser); + expect(service.findById).toHaveBeenCalledWith(userId); + }); + }); +}); + +// users/users.service.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { UsersRepository } from './users.repository'; +import { EmailService } from '../email/email.service'; + +describe('UsersService', () => { + let service: UsersService; + let repository: UsersRepository; + let emailService: EmailService; + + const mockUsersRepository = { + create: jest.fn(), + findById: jest.fn(), + }; + + const mockEmailService = { + sendWelcomeEmail: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: UsersRepository, + useValue: mockUsersRepository, + }, + { + provide: EmailService, + useValue: mockEmailService, + }, + ], + }).compile(); + + service = module.get(UsersService); + repository = module.get(UsersRepository); + emailService = module.get(EmailService); + }); + + describe('create', () => { + it('should create user and send welcome email', async () => { + const createUserDto = { + email: 'test@example.com', + name: 'Test User', + }; + + const createdUser = { id: 1, ...createUserDto }; + + mockUsersRepository.create.mockResolvedValue(createdUser); + mockEmailService.sendWelcomeEmail.mockResolvedValue(undefined); + + const result = await service.create(createUserDto); + + expect(result).toEqual(createdUser); + expect(repository.create).toHaveBeenCalledWith(createUserDto); + expect(emailService.sendWelcomeEmail).toHaveBeenCalledWith( + createUserDto.email, + ); + }); + }); + + describe('findById', () => { + it('should throw NotFoundException when user not found', async () => { + mockUsersRepository.findById.mockResolvedValue(null); + + await expect(service.findById(999)).rejects.toThrow(NotFoundException); + await expect(service.findById(999)).rejects.toThrow( + 'User with ID 999 not found', + ); + }); + }); +}); + +// E2E Testing +// test/users.e2e-spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('UsersController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('/users (POST)', () => { + it('should create a new user', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ + email: 'test@example.com', + name: 'Test User', + }) + .expect(201) + .expect((res) => { + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty('id'); + expect(res.body.data.email).toBe('test@example.com'); + }); + }); + + it('should return 400 for invalid email', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ + email: 'invalid-email', + name: 'Test', + }) + .expect(400); + }); + }); +}); +``` + +--- + +## 增量迁移策略 + +### 策略 1:绞杀者模式(推荐) + +在两者同时运行的情况下,逐步将 Express 路由替换为 NestJS。 + +**交叉参考:** 详细实现请参见 `/Users/dmitry/Projects/claude-skills/skills/legacy-modernizer/references/strangler-fig-pattern.md`。 + +```typescript +// main.ts - Running both Express and NestJS +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import * as express from 'express'; +import { expressApp } from './legacy/express-app'; + +async function bootstrap() { + const nestApp = await NestFactory.create(AppModule); + + // Proxy middleware to route between NestJS and Express + const app = express(); + + // NestJS routes (new implementation) + app.use('/api/v2', nestApp.getHttpAdapter().getInstance()); + + // Express routes (legacy) + app.use('/api', expressApp); + + await app.listen(3000); +} +bootstrap(); +``` + +**迁移步骤:** +1. 在 Express 旁搭建 NestJS +2. 每次将一个模块迁移到 NestJS +3. 新端点路由到 NestJS,旧端点路由到 Express +4. 更新前端/客户端以使用新端点 +5. 完全迁移后移除 Express 路由 +6. 停用 Express 应用 + +### 策略 2:逐模块迁移 + +按顺序逐个迁移完整的功能模块。 + +``` +阶段 1:认证(第 1-2 周) +- 将 auth 中间件迁移到 Guards +- 将 JWT 处理迁移到 @nestjs/jwt +- 测试认证流程 +- 配合功能开关部署 + +阶段 2:用户模块(第 3-4 周) +- 将用户路由迁移到 Controllers +- 将用户服务迁移到 Providers +- 使用 DTO 添加验证 +- 编写测试 + +阶段 3:文章模块(第 5-6 周) +... +``` + +### 策略 3:适配器模式实现渐进式 DI 迁移 + +在过渡期间将 Express 服务包装在 NestJS 提供者中。 + +```typescript +// Adapter for legacy Express service +import { Injectable } from '@nestjs/common'; +const LegacyUserService = require('../legacy/services/userService'); + +@Injectable() +export class UserServiceAdapter { + private legacyService = new LegacyUserService(); + + async findAll(): Promise { + return this.legacyService.findAll(); + } + + async create(data: any): Promise { + return this.legacyService.create(data); + } +} + +// Use in NestJS controller while migrating +@Controller('users') +export class UsersController { + constructor(private readonly userService: UserServiceAdapter) {} + + @Get() + async findAll() { + return this.userService.findAll(); + } +} +``` + +--- + +## 常见陷阱 + +### 1. 过度设计简单应用 + +**问题:** 将一个 500 行的 Express 应用迁移到包含模块、DTO、仓库、Guards 等完整 NestJS 架构。 + +**解决方案:** 评估 NestJS 的复杂度是否合理。考虑将简单的 API 保留在 Express 中。 + +### 2. 不理解依赖注入生命周期 + +**问题:** +```typescript +// WRONG - Creates new instance, bypassing DI +@Injectable() +export class UsersService { + constructor() { + this.emailService = new EmailService(); // Don't do this! + } +} +``` + +**解决方案:** +```typescript +// CORRECT - Let NestJS inject dependencies +@Injectable() +export class UsersService { + constructor(private readonly emailService: EmailService) {} +} +``` + +### 3. 错误混用 Middleware 和 Guards + +**问题:** 使用 Express 中间件处理认证而非 Guards,失去了 NestJS 的优势。 + +**解决方案:** 认证/授权使用 Guards,日志/转换使用 Interceptors,仅在 Express 特有需求中使用 Middleware。 + +### 4. 忽略验证管道 + +**问题:** 在控制器中进行像 Express 那样的手动验证。 + +```typescript +// WRONG - Manual validation +@Post() +async create(@Body() body: any) { + if (!body.email) { + throw new BadRequestException('Email required'); + } + // ... +} +``` + +**解决方案:** +```typescript +// CORRECT - Use DTOs with class-validator +@Post() +async create(@Body() createUserDto: CreateUserDto) { + // Validation happens automatically + return this.usersService.create(createUserDto); +} +``` + +### 5. 未充分利用模块的导入/导出 + +**问题:** 循环依赖和紧密耦合的模块。 + +**解决方案:** 正确组织模块的导入/导出。对循环依赖使用 forwardRef()。 + +```typescript +@Module({ + imports: [TypeOrmModule.forFeature([User]), EmailModule], + providers: [UsersService, UsersRepository], + exports: [UsersService], // Export for other modules +}) +export class UsersModule {} +``` + +### 6. 忘记启用 CORS + +**问题:** CORS 在 Express 中正常但在 NestJS 中失败。 + +**解决方案:** +```typescript +// main.ts +const app = await NestFactory.create(AppModule); +app.enableCors({ + origin: process.env.ALLOWED_ORIGINS?.split(','), + credentials: true, +}); +``` + +### 7. 错误的异常处理 + +**问题:** 沿用 Express 的错误中间件模式。 + +**解决方案:** 使用 NestJS 内置的异常和过滤器。 + +```typescript +// Throw NestJS exceptions +throw new NotFoundException('User not found'); +throw new BadRequestException('Invalid input'); +throw new UnauthorizedException('Invalid credentials'); +``` + +### 8. 未全局配置 ValidationPipe + +**问题:** 各端点验证不一致。 + +**解决方案:** +```typescript +// main.ts +app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), +); +``` + +--- + +## 迁移检查清单 + +**迁移前:** +- [ ] 审计现有 Express 代码库结构 +- [ ] 记录所有路由和依赖 +- [ ] 识别共享服务和工具 +- [ ] 规划模块边界 +- [ ] 搭建 NestJS 项目结构 + +**迁移中:** +- [ ] 迁移 DTO 和验证规则 +- [ ] 将路由处理程序转换为控制器 +- [ ] 重构服务以支持依赖注入 +- [ ] 实现 Guards 用于认证 +- [ ] 创建 Interceptors 处理横切关注点 +- [ ] 添加异常过滤器 +- [ ] 为每个组件编写单元测试 +- [ ] 为关键流程编写 e2e 测试 + +**迁移后:** +- [ ] 性能测试与优化 +- [ ] 更新 API 文档 +- [ ] 配置日志和监控 +- [ ] 为 NestJS 设置 CI/CD +- [ ] 培训团队使用 NestJS 模式 +- [ ] 移除 Express 依赖 +- [ ] 按 NestJS 最佳实践重构 + +--- + +## 附加资源 + +- NestJS 官方文档:https://docs.nestjs.com +- NestJS 迁移指南:https://docs.nestjs.com/migration-guide +- class-validator 装饰器:https://github.com/typestack/class-validator +- TypeORM 与 NestJS 集成:https://docs.nestjs.com/techniques/database +- 测试指南:https://docs.nestjs.com/fundamentals/testing diff --git a/references/services-di.md b/references/services-di.md new file mode 100644 index 0000000..036f72b --- /dev/null +++ b/references/services-di.md @@ -0,0 +1,140 @@ +# 服务与依赖注入 + +## 服务模式 + +```typescript +import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + + constructor( + @InjectRepository(User) + private readonly repo: Repository, + private readonly emailService: EmailService, + ) {} + + async create(dto: CreateUserDto): Promise { + try { + const user = this.repo.create(dto); + const saved = await this.repo.save(user); + await this.emailService.sendWelcome(saved.email); + return saved; + } catch (error) { + if (error.code === '23505') { + throw new ConflictException('邮箱已存在'); + } + this.logger.error(`创建用户失败:${error.message}`); + throw error; + } + } + + async findOne(id: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException(`用户 ${id} 未找到`); + } + return user; + } + + async update(id: string, dto: UpdateUserDto): Promise { + const user = await this.findOne(id); + Object.assign(user, dto); + return this.repo.save(user); + } + + async remove(id: string): Promise { + const result = await this.repo.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`用户 ${id} 未找到`); + } + } +} +``` + +## 带提供者的模块 + +```typescript +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], // 向其他模块公开 +}) +export class UsersModule {} +``` + +## 自定义提供者 + +```typescript +// 值提供者 +{ provide: 'API_KEY', useValue: process.env.API_KEY } + +// 工厂提供者 +{ + provide: 'CONFIG', + useFactory: (configService: ConfigService) => ({ + apiUrl: configService.get('API_URL'), + }), + inject: [ConfigService], +} + +// 类提供者 +{ provide: LoggerService, useClass: CustomLoggerService } + +// 异步工厂 +{ + provide: 'DATABASE_CONNECTION', + useFactory: async () => { + const connection = await createConnection(); + return connection; + }, +} +``` + +## 注入模式 + +```typescript +// 构造器注入(推荐) +constructor(private readonly usersService: UsersService) {} + +// 令牌注入 +constructor(@Inject('API_KEY') private apiKey: string) {} + +// 可选注入 +constructor(@Optional() private readonly cache?: CacheService) {} + +// 属性注入(谨慎使用) +@Inject() private readonly logger: Logger; +``` + +## 作用域 + +```typescript +// 默认值:单例(整个应用共享) +@Injectable() +export class SharedService {} + +// 请求作用域:每个请求一个新实例 +@Injectable({ scope: Scope.REQUEST }) +export class RequestService { + constructor(@Inject(REQUEST) private request: Request) {} +} + +// 瞬态作用域:每次注入一个新实例 +@Injectable({ scope: Scope.TRANSIENT }) +export class HelperService {} +``` + +## 快速参考 + +| 模式 | 使用场景 | +|---------|----------| +| 构造器 DI | 大多数情况(推荐) | +| `@Inject(token)` | 非类令牌 | +| `@Optional()` | 可选依赖 | +| 工厂提供者 | 动态配置 | +| Scope.REQUEST | 每个请求的状态 | diff --git a/references/testing-patterns.md b/references/testing-patterns.md new file mode 100644 index 0000000..fd80e36 --- /dev/null +++ b/references/testing-patterns.md @@ -0,0 +1,186 @@ +# 测试模式 + +## 单元测试设置 + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; + +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: getRepositoryToken(User), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(UsersService); + repo = module.get(getRepositoryToken(User)); + }); + + afterEach(() => jest.clearAllMocks()); +}); +``` + +## 服务测试 + +```typescript +describe('create', () => { + it('应创建用户', async () => { + const dto = { email: 'test@test.com', password: 'pass', name: 'Test' }; + const user = { id: '1', ...dto }; + + repo.create.mockReturnValue(user as User); + repo.save.mockResolvedValue(user as User); + + const result = await service.create(dto); + + expect(repo.create).toHaveBeenCalledWith(dto); + expect(repo.save).toHaveBeenCalledWith(user); + expect(result).toEqual(user); + }); +}); + +describe('findOne', () => { + it('应返回用户', async () => { + const user = { id: '1', email: 'test@test.com' }; + repo.findOne.mockResolvedValue(user as User); + + const result = await service.findOne('1'); + expect(result).toEqual(user); + }); + + it('应抛出 NotFoundException', async () => { + repo.findOne.mockResolvedValue(null); + await expect(service.findOne('1')).rejects.toThrow(NotFoundException); + }); +}); +``` + +## 控制器测试 + +```typescript +describe('UsersController', () => { + let controller: UsersController; + let service: jest.Mocked; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [UsersController], + providers: [ + { + provide: UsersService, + useValue: { + create: jest.fn(), + findOne: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(UsersController); + service = module.get(UsersService); + }); + + it('应创建用户', async () => { + const dto = { email: 'test@test.com', password: 'pass', name: 'Test' }; + const user = { id: '1', ...dto }; + service.create.mockResolvedValue(user as User); + + const result = await controller.create(dto); + expect(result).toEqual(user); + }); +}); +``` + +## E2E 测试 + +```typescript +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; + +describe('UsersController (e2e)', () => { + let app: INestApplication; + let authToken: string; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); + await app.init(); + + // 获取认证令牌 + const response = await request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'test@test.com', password: 'password' }); + authToken = response.body.access_token; + }); + + afterAll(() => app.close()); + + it('/users (POST)', () => { + return request(app.getHttpServer()) + .post('/users') + .set('Authorization', `Bearer ${authToken}`) + .send({ email: 'new@test.com', password: 'Test1234', name: 'New' }) + .expect(201) + .expect((res) => { + expect(res.body.email).toBe('new@test.com'); + }); + }); + + it('/users/:id (GET) - 404', () => { + return request(app.getHttpServer()) + .get('/users/nonexistent-id') + .set('Authorization', `Bearer ${authToken}`) + .expect(404); + }); +}); +``` + +## Mock 工厂函数 + +```typescript +export const createMockRepository = () => ({ + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn(), + getMany: jest.fn(), + })), +}); +``` + +## 快速参考 + +| 模式 | 使用场景 | +|------|----------| +| `Test.createTestingModule()` | 创建测试模块 | +| `jest.fn()` | Mock 函数 | +| `mockResolvedValue()` | Mock 异步返回值 | +| `mockReturnValue()` | Mock 同步返回值 | +| `supertest` | E2E HTTP 测试 | +| `beforeAll` / `afterAll` | 设置/拆卸 |