NestJS-系列
NestJS[一]-基础
RxJS
NestJS[二]-核心
NestJS[三]-进阶
NestJS[四]-数据库
NestJS实践杂记

前言

最近在捣鼓毕设,加上去实习了,博客也好久没打理了。

毕设用 NestJS 写后端,这个框架在年初跟着官方文档学习过,还未用它写过项目,坑还是很多的,但打理好了,用起来非常顺手。

统一响应与swagger

使用拦截器、过滤器对响应格式进行统一处理是很常见的事

但swagger并不能识别这种处理,在api文档中仍然是路由方法的返回类型,当然可以制造一个泛型工具,使用 @ApiResponse 手动标识返回类型,但这样还是太麻烦了。

官网提供了一个案例 高级:通用 ApiResponse,但我试了后,响应还是缺少了除data字段外的其它字段。不过思路确实是如此,封装一个统一的装饰器,简化标注操作。

先定义响应的dto类型,注意文件名后缀需要是 .dto.ts,否则 swagger 无法识别。也可以在 cli 配置中通过 dtoFileNameSuffix 自定义后缀。使用 @ApiProperty 标注字段,swagger 会自动识别。

api.dto.ts
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
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;
}

/**
* 基础Ok响应类
*/
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;
}

/**
* Ok响应类
*/
export class ApiOkRes<TData = any> extends ApiBaseOkRes {
@ApiProperty({ description: '数据' })
data: TData | null;
}

/**
* Err响应类
*/
export class ApiErrRes extends ApiBaseRes {
@ApiProperty({ description: '时间' })
time: string;
@ApiProperty({ description: '请求方法' })
method: string;
@ApiProperty({ description: '请求路径' })
path: string;
}

/**
* 空模型类
*/
export class EmptyModel {}

API_CODESAPI_MSGS 是自定义的响应代码和信息,这里不再赘述。

为了方便构造响应,封装对应的 class,这样好处是统一管理响应格式,方便维护。

api.ts
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
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
// 拦截器pipe中
map((data) => {
// 如果是对象且有code字段,约定直接返回
// 因为除了标准的成功响应,难免有些路由方法存在自定义的响应
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,
};

// 获取某个class、构造函数的实例类型
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

实际上我们只是对 @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
// 获取某个class、构造函数的实例类型
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;
// {'200': { description: '请求成功', content: { 'application/json': [Object] } } }
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
// 生成swagger文档
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 {
/**
* http状态码,默认400
*/
status: HttpStatus;
/**
* 业务错误code,默认同status
*/
code: number;
/**
* 业务错误信息,默认空
*/
message: string;
/**
* log输出,默认同message
*/
logMsg: string;
/**
* log等级
*/
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,
}),
);
}

// 捕获 HttpException
catchHttpException(exception: HttpException, host: ArgumentsHost) {
let message = exception.message;
// 判断原来res中是否存在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,
);
}

// 捕获Mongo异常
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));
}

手动使用 IsDefinedIsNotEmpty 来标注必顼属性。

正则校验

对字段值进行正则校验。

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);
}
// export class PayloadDto {
// // .....
// constructor(partial: Partial<PayloadDto>) {
// Object.assign(this, partial);
// }
// }

控制器必须返回一个 DTO 类实例,否则拦截器不会生效,将无法正确序列化。

但也可以通过 @SerializeOptions({ type: <DTO> }) 来指定序列化的 DTO 类型,这样控制器就能够返回一个普通对象。