前言
最近在捣鼓毕设,加上去实习了,博客也好久没打理了。
毕设用 NestJS 写后端,这个框架在年初跟着官方文档学习过,还未用它写过项目,坑还是很多的,但打理好了,用起来非常顺手。
统一响应与swagger
使用拦截器、过滤器对响应格式进行统一处理是很常见的事
但swagger并不能识别这种处理,在api文档中仍然是路由方法的返回类型,当然可以制造一个泛型工具,使用 @ApiResponse
手动标识返回类型,但这样还是太麻烦了。
官网提供了一个案例 高级:通用 ApiResponse,但我试了后,响应还是缺少了除data字段外的其它字段。不过思路确实是如此,封装一个统一的装饰器,简化标注操作。
先定义响应的dto类型,注意文件名后缀需要是 .dto.ts,否则 swagger 无法识别。也可以在 cli 配置中通过 dtoFileNameSuffix 自定义后缀。使用 @ApiProperty 标注字段,swagger 会自动识别。
api.dto.ts1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| import { ApiProperty } from '@nestjs/swagger'; import { API_CODES, API_MSGS } from '../api-code';
export class ApiBaseRes { @ApiProperty({ description: '代码' }) code: number; @ApiProperty({ description: '信息' }) message: string; @ApiProperty({ description: '是否成功' }) success: boolean; }
export class ApiBaseOkRes extends ApiBaseRes { @ApiProperty({ default: API_CODES.OK }) code: number; @ApiProperty({ default: API_MSGS[API_CODES.OK] }) message: string; @ApiProperty({ default: true }) success: boolean; }
export class ApiOkRes<TData = any> extends ApiBaseOkRes { @ApiProperty({ description: '数据' }) data: TData | null; }
export class ApiErrRes extends ApiBaseRes { @ApiProperty({ description: '时间' }) time: string; @ApiProperty({ description: '请求方法' }) method: string; @ApiProperty({ description: '请求路径' }) path: string; }
export class EmptyModel {}
|
API_CODES
和 API_MSGS
是自定义的响应代码和信息,这里不再赘述。
为了方便构造响应,封装对应的 class,这样好处是统一管理响应格式,方便维护。
api.ts1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import { API_CODES, API_MSGS } from './api-code'; import { ApiErrRes, ApiOkRes } from './dto/api.dto';
export class ApiOkCls<T = any> implements ApiOkRes<T> { code: number = API_CODES.OK; message: string = API_MSGS[API_CODES.OK]; data: T | null = null; success: boolean = true; constructor(partial: Partial<Omit<ApiOkRes<T>, 'success'>>) { Object.assign(this, partial); } }
export class ApiErrCls implements ApiErrRes { code: number = API_CODES.UNKNOWN; message: string = API_MSGS[API_CODES.UNKNOWN]; success: boolean = false; time: string = new Date().toISOString(); method: string = ''; path: string = '';
constructor(partial: Partial<Omit<ApiErrRes, 'success'>>) { Object.assign(this, partial); } }
|
现在在拦截器和过滤器中,只需要返回对应的实例即可。
1 2 3 4 5 6 7 8 9 10 11 12
| map((data) => { if (typeof data === 'object' && data?.code) { return data; } return new ApiOkCls({ data, message: '请求成功', }); }),
|
最后,最重要的便是自定义装饰器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
| import { ApiExtraModels, ApiResponse, ApiResponseOptions, getSchemaPath } from '@nestjs/swagger'; import { ApiErrRes, ApiOkRes, EmptyModel } from './dto/api.dto'; import { API_CODES, API_MSGS } from './api-code'; import { applyDecorators, Type } from '@nestjs/common'; import { ApiOkCls } from './api';
const baseTypeNames = new Set(['String', 'Number', 'Boolean']);
const baseTypeExamples = { String: 'string', Number: 0, Boolean: true, };
type ExtractInstanceType<T> = T extends { new (...args: any[]): infer R } ? R : T;
type ApiOkOptions<TModel extends Type<any>> = Pick<ApiResponseOptions, 'description'> & { status?: number; code?: number; example?: ExtractInstanceType<TModel>; };
export const ApiOk = <TModel extends Type<any>>( model?: TModel, options: ApiOkOptions<TModel> = {}, ) => { const { description, status = API_CODES.OK, code } = options; let example = options.example; let data: any = { $ref: getSchemaPath(model ?? EmptyModel), }; if (!model) { data = { type: 'null', default: null }; } else if (baseTypeNames.has(model.name)) { data = { type: model.name.toLocaleLowerCase() }; example = example ?? baseTypeExamples[model.name]; } const decorators = [ ApiExtraModels(ApiOkRes), ApiResponse({ description: description ?? API_MSGS[status], status: status, schema: { allOf: [ { $ref: getSchemaPath(ApiOkRes) }, { properties: { data, }, }, ], }, example: new ApiOkCls({ code: code ?? status, data: example, }), }), ]; if (model && !baseTypeNames.has(model.name)) { decorators.push(ApiExtraModels(model)); } return applyDecorators(...decorators); };
export const ApiErr = (code: number, message?: string) => { const decorators = [ ApiExtraModels(ApiErrRes), ApiResponse({ description: `${API_MSGS[code]}`, status: code, schema: { allOf: [ { $ref: getSchemaPath(ApiErrRes) }, { properties: { code: { default: code, }, message: { default: message ?? API_MSGS[code], }, }, }, ], }, }), ]; return applyDecorators(...decorators); };
|
现在就可以通过装饰器标注每个路由方法的响应类型了,虽然还是需要一个个标注,但比写 @ApiResponse
要方便很多,并且有完善的类型提示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @ApiOperation({ summary: '测试jwt验证', }) @Auth.Roles([]) @Post('verify') @ApiOk(PayloadDto, { example: { name: 'qcqx', role: Role.Admin, id: '123456', }, }) verify(@User() user: PayloadDto) { return new PayloadDto(user); }
|
实际上我们只是对 @ApiResponse
进行了封装。
@ApiExtraModels
用于在 Swagger 文档中注册额外的模型(没有直接出现在控制器的返回类型或参数中),确保这些模型在生成的 Swagger 文档中被正确引用和描述。
ApiResponse 中有非常多的配置,其中 schema 用于定义响应的模型,example 用于定义响应的示例。
在 schema 中我们使用 allOf
来合并多个模型,值是一个数组,依次合并覆盖相同字段。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| ApiResponse({ description: API_MSGS[API_CODES.OK], status: API_CODES.OK, schema: { allOf: [ { $ref: getSchemaPath(ApiOkRes) }, { properties: { data, }, }, ], }, example: new ApiOkCls({ data: example, }), }),
|
$ref
引用其它模型,getSchemaPath()
函数返回某个模型的引用路径(指向 OpenAPI 规范中定义的模型)。properties
定义模型的字段,在其中定义 data 字段,以覆盖原有的 data 字段。
类型处理
可以注意到一个类型工具。
1 2
| type ExtractInstanceType<T> = T extends { new (...args: any[]): infer R } ? R : T;
|
用于处理 TModel
类型,并给第 example
字段提供类型标注。
1 2 3 4 5
| type ApiOkOptions<TModel extends Type<any>> = Pick<ApiResponseOptions, 'description'> & { status?: number; code?: number; example?: ExtractInstanceType<TModel>; };
|
若不使用,example 类型是 typeof <Dto>
,在 TypeScript 中,对一个类使用 typeof 操作符会得到该类的构造函数类型,显然不是我们想要的,我们想要的是对应实例类型,所以需要去掉 typeof,也就是提取实例类型。
更统一的处理
一个更好的处理方式是直接对 swagger 文档进行修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
|
export function addResponseWrapper(doc: OpenAPIObject) { for (const path of Object.keys(doc.paths)) { const pathItem = doc.paths[path]; if (!pathItem) { continue; } for (const method of Object.keys(pathItem)) { const responses = doc.paths[path][method].responses; if (!responses) { continue; } for (const status of Object.keys(responses)) { const response = responses[status]; const json = response.content?.['application/json']; if (!json) { response.content = { 'application/json': { schema: { $ref: getSchemaPath(ApiOkRes), }, }, }; continue; } if (json.example && 'code' in json.example && 'message' in json.example) { continue; } const schema = json.schema as ApiResponseSchemaHost['schema']; response.description = status.startsWith('2') ? '默认推断的请求成功' : ''; json.schema = { allOf: [ { $ref: getSchemaPath(ApiOkRes), }, { type: 'object', properties: { data: schema, }, required: ['data'], }, ], }; } } } return doc; }
|
遍历 swagger 文档,对每个响应进行包装,这样就不需要在每个路由方法中标注响应类型了。对于已经标注的路由方法,则会跳过。
注意在 extraModels 配置中注册额外的模型,否则 swagger 无法识别。
1 2 3 4 5 6 7
| const document = addResponseWrapper( SwaggerModule.createDocument(app, swaggerConfig, { extraModels: [ApiBaseRes, ApiBaseOkRes, ApiOkRes, ApiErrRes], }), );
|
封装通用异常
封装好 logMsg 和业务错误码,方便统一处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import { HttpStatus, LogLevel } from '@nestjs/common'; import { genRanID } from 'src/utils';
interface BusinessExceptionType {
status: HttpStatus;
code: number;
message: string;
logMsg: string;
logLevel: LogLevel; }
export class BusinessException extends Error implements BusinessExceptionType { public readonly id: string = genRanID(16); public readonly time: Date = new Date(); public readonly status: HttpStatus = HttpStatus.BAD_REQUEST; public readonly code: number = this.status; public readonly logMsg: string = this.message; public readonly logLevel: LogLevel = 'error';
constructor(partial: Partial<BusinessExceptionType>) { super(partial.message ?? ''); Object.assign(this, partial); }
public toString(): string { return `BusinessException: ${this.code} ${this.message}`; }
[Symbol.toStringTag]: string = BusinessException.name; }
|
全局异常过滤器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
| import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Injectable } from '@nestjs/common'; import { Request, Response } from 'express'; import { LoggerService } from 'src/logger/logger.service'; import { ApiErrCls } from 'src/response/api'; import { format } from 'util'; import { BusinessException } from './business.exception'; import { ValidationError } from 'class-validator'; import { getConstructorNames, hasIntersection } from 'src/utils';
@Catch(Error) @Injectable() export class GlobalExceptionFilter<T extends Error> implements ExceptionFilter { mongoException = new Set(['MongoError', 'MongooseError', 'MongoServerError']);
constructor(private readonly logger: LoggerService = new LoggerService()) { this.logger.setContext(GlobalExceptionFilter.name); } catch(exception: T, host: ArgumentsHost) { console.log(exception.name); if (exception instanceof BusinessException) { this.catchBusinessException(exception, host); } else if (exception instanceof HttpException) { this.catchHttpException(exception, host); } else if (exception instanceof ValidationError) { this.catchValidationError(exception, host); } else if (hasIntersection(getConstructorNames(exception), this.mongoException)) { this.catchMongoException(exception, host); } else { this.catchUnknownException(exception, host); } }
catchBusinessException(exception: BusinessException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const req = ctx.getRequest<Request>(); const res = ctx.getResponse<Response>(); this.logger[exception.logLevel]( format('%s %s %s %s', req.method, req.url, exception.code, exception.logMsg), ); res.status(exception.status).json( new ApiErrCls({ code: exception.code, message: exception.message, method: req.method, path: req.url, }), ); }
catchHttpException(exception: HttpException, host: ArgumentsHost) { let message = exception.message; const exceptionRes = exception.getResponse(); if (typeof exceptionRes === 'object' && exceptionRes['message']) { const resMsg = exceptionRes['message']; if (typeof resMsg === 'string') { message = resMsg; } else if (Array.isArray(resMsg) && resMsg.length > 0) { message = resMsg.join(';'); } else if (typeof resMsg === 'object') { message = JSON.stringify(resMsg); } } this.catchBusinessException( new BusinessException({ message, status: exception.getStatus(), }), host, ); }
catchValidationError(exception: ValidationError, host: ArgumentsHost) { const message = exception.toString(true); this.catchBusinessException( new BusinessException({ message, status: 400, }), host, ); }
catchMongoException(exception: Error, host: ArgumentsHost) { this.catchBusinessException( new BusinessException({ message: '数据库错误', logMsg: exception.message, status: 500, }), host, ); }
catchUnknownException(exception: Error, host: ArgumentsHost) { this.catchBusinessException( new BusinessException({ message: '未知错误', logMsg: exception.stack, status: 500, }), host, ); } }
|
因为 mongodb 相关的异常类没有暴露出来,所以只能顺着原型链判断异常的构造函数名,算是手动实现下 instanceof。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
export function getConstructorNames(obj: object): string[] { if (!obj || typeof obj !== 'object') { return []; } const constructorNames: string[] = []; let proto = obj; while (proto) { constructorNames.push(proto.constructor.name); proto = Object.getPrototypeOf(proto); } return constructorNames; }
export function hasIntersection<T>(arr1: T[] | Set<T>, arr2: T[] | Set<T>): boolean { const set1 = arr1 instanceof Set ? arr1 : new Set(arr1); const set2 = arr2 instanceof Set ? arr2 : new Set(arr2);
for (const item of set1) { if (set2.has(item)) { return true; } } return false; }
|
瞬态的logger
自定义的 logger 配置为瞬态的,这样每次注入都会生成一个新的实例,确保在各个服务、控制器种注入 logger 并设置上下文,不会相互干扰。
1 2 3 4
| import { ConsoleLogger, Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.TRANSIENT }) export class LoggerService extends ConsoleLogger {}
|
鉴权与swagger
各种装饰器
Nest 中最方便的就是装饰器与元数据的使用。
必须属性
ValidationPipe
是必不可少的管道,通常设置为全局管理,以对所有 DTO 进行验证。
为了方便,通常会设置 skipUndefinedProperties 为 true,以跳过未定义的属性。这是很有用的,但也会导致一些必要的属性被跳过验证。
1 2 3 4 5 6 7
| export function IsRequired(validationOptions?: ValidationOptions) { const options = { message: '$property 是必须的且不能为空', ...validationOptions, }; return applyDecorators(IsDefined(options), IsNotEmpty(options)); }
|
手动使用 IsDefined
和 IsNotEmpty
来标注必顼属性。
正则校验
对字段值进行正则校验。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| export function IsMatch(regexp: RegExp, validationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ name: 'isMatch', target: object.constructor, propertyName: propertyName, options: validationOptions, constraints: [regexp], validator: { validate(value: any, args: ValidationArguments) { return regexp.test(value); }, defaultMessage(args: ValidationArguments) { return `${args.property} 不符合规则`; }, }, }); }; }
|
衍生的装饰器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
export function IsContainLetterAndNumber(validationOptions?: ValidationOptions) { return applyDecorators( IsMatch(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '$property 必须包含字母和数字', ...validationOptions, }), ); }
export function IsNoSpecialCharacter(validationOptions?: ValidationOptions) { const noSpecialCharacterRegexp = /^[a-zA-Z0-9@#_]*$/; return applyDecorators( IsMatch(noSpecialCharacterRegexp, { message: '$property 只允许字母、数字、@、#、_', ...validationOptions, }), ); }
|
序列化ClassSerializerInterceptor
ClassSerializerInterceptor 拦截器用于根据 DTO 自动转换、过滤返回的数据,简称序列化。
使用该拦截器,避免手动使用 plainToClass 函数转换,还可以获得更好的 TS 类型提示。
1 2 3 4 5 6 7 8 9 10
| app.useGlobalInterceptors( new ClassSerializerInterceptor(app.get(Reflector), { excludeExtraneousValues: true, excludePrefixes: ['_'], enableImplicitConversion: true, }), );
|
excludeExtraneousValues 选项是很有用的,这将保证只有在 DTO 中定义的字段才会被序列化,多余的字段将被忽略。不过这也要求必须手动对字段添加 @Expose() 装饰器。
1 2 3 4 5 6 7 8 9
| verify(@User() user: PayloadDto) { return new PayloadDto(user); }
|
控制器必须返回一个 DTO 类实例,否则拦截器不会生效,将无法正确序列化。
但也可以通过 @SerializeOptions({ type: <DTO> })
来指定序列化的 DTO 类型,这样控制器就能够返回一个普通对象。