惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

G
GRAHAM CLULEY
V
V2EX
WordPress大学
WordPress大学
博客园 - Franky
Last Week in AI
Last Week in AI
博客园 - 司徒正美
有赞技术团队
有赞技术团队
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - 【当耐特】
V
Visual Studio Blog
C
CERT Recently Published Vulnerability Notes
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Jina AI
Jina AI
Attack and Defense Labs
Attack and Defense Labs
腾讯CDC
The Hacker News
The Hacker News
Hugging Face - Blog
Hugging Face - Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
J
Java Code Geeks
人人都是产品经理
人人都是产品经理
阮一峰的网络日志
阮一峰的网络日志
T
Tailwind CSS Blog
S
SegmentFault 最新的问题
大猫的无限游戏
大猫的无限游戏
小众软件
小众软件
A
Arctic Wolf
量子位
博客园 - 聂微东
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
N
News and Events Feed by Topic
雷峰网
雷峰网
博客园_首页
Google Online Security Blog
Google Online Security Blog
Spread Privacy
Spread Privacy
罗磊的独立博客
H
Hacker News: Front Page
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
月光博客
月光博客
TaoSecurity Blog
TaoSecurity Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
博客园 - 三生石上(FineUI控件)
宝玉的分享
宝玉的分享
IT之家
IT之家
The Cloudflare Blog
爱范儿
爱范儿
博客园 - 叶小钗
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Apple Machine Learning Research
Apple Machine Learning Research
酷 壳 – CoolShell
酷 壳 – CoolShell

轻笑Chuckle

React-记-3 React-记-2 React-记-1 闭包与内存泄漏 Vue3.5 更新了啥 Git lint 相关 集成 lint 代码规范工具 Monorepo 单仓库多应用 Vue+TS 实现虚拟列表 开发了一个 Canvas 2D 渲染引擎 Canvas 2D 事件 Canvas 2D 贝塞尔曲线 Canvas 2D 进阶 Canvas 2D 基础 Git 规范与实践 Git 熟知熟用 解决System占CPU过高 浏览器HTTP缓存 浏览器渲染流程
NestJS实践杂记
『轻笑Chuckle』 · 2024-12-09 · via 轻笑Chuckle

前言

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

毕设用 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;
}




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_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

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));
}

手动使用 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);
}






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

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