Nest.js 脚手架改造(二)接口包装和异常处理

这篇填下面两个坑:

  • 接口包装:将return的数据按约定好的结构进行返回
    1
    2
    3
    4
    5
    {
    "code": 0,
    "msg": "success",
    "data": "Hello world"
    }
  • 异常处理:按约定好的结构,返回业务状态码和异常messge
    1
    2
    3
    4
    {
    "code": 4002,
    "msg": "手机号长度错误"
    }

业务状态码

常见的第三方接口,会带上自己定义的业务状态码,举一个简单的例子

1
2
3
4
5
6
7
8
9
10
// enums/api-code.enums.ts
export enum ApiCode {
TIMEOUT = -1, // 系统繁忙
SUCCESS = 0, // 请求成功

BUSINESS_ERROR = 4001, // 业务错误
PARAMS_ERROR = 4002, // 参数不合法
SIGN_ERROR = 4003, // 验签失败
TOKEN_ERROR = 4004, // token不合法
}

接口包装

1
2
3
4
@Get()
async getString(): Promise<string> {
return 'Hello world'
}

默认情况下,客户端拿到的就只有'Hello world'字符串,但是我们不可能在每个controller里都写一遍包装的逻辑再return掉,这时候就需要拦截器替我们自动包装了。

自定义拦截器需要实现NestInterceptor接口,定义如下:

1
2
3
export interface NestInterceptor<T = any, R = any> {
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<R> | Promise<Observable<R>>;
}

ExecutionContext接口定义:

1
2
3
4
export interface ExecutionContext extends ArgumentsHost {
getClass<T = any>(): Type<T>;
getHandler(): Function;
}

ExecutionContext继承自ArgumentsHost, ArgumentsHost则是对上下文的包装(其实是个数组):如果是HTTP应用,那么就是[request, response];如果是Web Socket应用,那么就是[client, data]

ExecutionContext则添加了两种方法,getHandler获取的是处理响应的Handler(比如上文的getString);getClass获取的是Hanlder所属的Controller类(不是实例)

再看一下CallHandler的接口定义

1
2
3
export interface CallHandler<T = any> {
handle(): Observable<T>;
}

它的作用很简单,调用handle时返回订阅的ObservableObservableRxJS相关概念,不展开描述,它的核心是流和操作符(用起来其实很方便,但是理解真的很抽象)

回到实际场景,新建拦截器providers/interceptor/api.interceptor.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
// providers/interceptor/api.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler
} from '@nestjs/common'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { ApiCode } from '../../enum/api-code.enum'

// 约定好的返回格式
interface Response<T> {
code: number
msg: string
data: T
}

@Injectable()
export class ApiInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(
context: ExecutionContext,
next: CallHandler
): Observable<Response<T>> {
return next.handle().pipe(
map(data => {
return {
code: ApiCode.SUCCESS,
msg: 'success',
data
}
})
)
}
}

这样子一个简单的接口包装就完成了,由于我们对每一个响应都需要包装,就把它注册为全局拦截器:

1
2
3
4
5
6
7
8
9
10
// app.module.ts
import { Module } from '@nestjs/common'
import { APP_INTERCEPTOR } from '@nestjs/core'

@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: ApiInterceptor }
]
})
export class ApplicationModule {}

完成。

异常包装

上面针对的是正常返回(return)的情况,在实际业务时会有各类异常情况出现,按照最直接的方法来的话(不含上述拦截器):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// user.controller.ts
@Get(':id')
async getUser(@Res() res, @Param('id', new ParseIntPipe()) id) {
if(isNaN(id)) {
return res.status(HttpStatus.OK).send({
code: 4002,
msg: 'id不合法'
})
}

return res.status(HttpStatus.OK).send({
code: 0,
msg: 'success',
data: { name: 'a', age: 18 }
})
}

这种写法会带来耦合度高、代码冗余等问题,且和刚才写的接口包装拦截器有冲突,那么这些异常该如何优雅地进行返回呢?

Nest会对应用抛出的异常进行捕获,生成一个JSON返回,比如访问一个不存在的path:

1
2
3
4
5
{
"statusCode": 404,
"error": "Not Found",
"message": "Cannot GET /member"
}

因此我们只需要抛出异常就行了,正常返回也可以通过拦截器进行包装:

1
2
3
4
5
6
7
8
9
// user.controller.ts
@Get(':id')
async getUser(@Res() res, @Param('id', new ParseIntPipe()) id) {
if(isNaN(id)) {
throw new HttpException('ID不合法', HttpStatus.OK)
}

return { name: 'a', age: 18 }
}

但是这样返回的异常并不符合我们需要的结构,我们需要自定义一个异常过滤器来处理异常的返回。

在这之前,我们先自定义一个异常,把我们的业务码放进去:

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
// api.exception.ts
import { HttpException, HttpStatus } from '@nestjs/common'
import { ApiCode } from '../../enum/api-code.enum'

export class ApiException extends HttpException {
private errorMessage: string
private errorCode: ApiCode

constructor(
errorMessage: string,
errorCode: ApiCode,
statusCode: HttpStatus
) {
super(errorMessage, statusCode)

this.errorMessage = errorMessage
this.errorCode = errorCode
}

gerErrorCode(): ApiCode {
return this.errorCode
}

getErrorMessage(): string {
return this.errorMessage
}
}

自定义过滤器需要实现ExceptionFilter接口:

1
2
3
export interface ExceptionFilter<T = any> {
catch(exception: T, host: ArgumentsHost): any;
}

exception是捕获到的异常,hostArgumentsHost实例,上文有提到。

然后写我们需要的自定义过滤器:

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
// http-exception.filter.ts 
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException
} from '@nestjs/common'
import { ApiException } from '../exception/api.exception'

// 该装饰器告诉filter要捕获哪些类型异常
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception, host: ArgumentsHost): void {
const ctx = host.switchToHttp()
const response = ctx.getResponse()
const request = ctx.getRequest()
const status = exception.getStatus()

if (exception instanceof ApiException) {
response.status(status).json({
msg: (exception as ApiException).getErrorMessage(),
code: (exception as ApiException).gerErrorCode(),
path: request.url
})
} else {
// 处理非手动抛出的情况
response.status(status).json((exception as HttpException).getResponse())
}
}
}

最后把过滤器也注册到全局上:

1
2
3
4
5
6
7
8
9
10
11
// app.module.ts
import { Module } from '@nestjs/common'
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'

@Module({
providers: [
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
{ provide: APP_INTERCEPTOR, useClass: ApiInterceptor }
]
})
export class ApplicationModule {}

使用方法:

1
2
3
4
5
6
7
8
9
// user.controller.ts
@Get(':id')
async getUser(@Res() res, @Param('id', new ParseIntPipe()) id): Promise<User> {
if(isNaN(id)) {
throw new ApiException('ID不合法', ApiCode.PARAMS_ERROR, 200)
}

return { name: 'a', age: 18 }
}

HTTP状态码返回200还是4xx?

方案一:200 + 业务码 + message

方案二(RESTful):原生http status code + message

别问,问就是看约定,本前端都能接受…

(据说某些浏览器对部分状态码有奇特处理)