Egg06 参数校验和异常处理

Egg中参数校验和异常处理的实践

参数校验

手动校验

之前的参数都是在Controller的入口处,手动的进行校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async index() {
const {ctx } = this
const { query } = ctx.request
try {
const { type } = query
// 缺少参数,没法查
if (!type) {
const errMsg = '缺少参数'
ctx.response.status = this.config.httpCodeHash.badRequest
ctx.response.body = ctx.helper.makeErrorResponse(errMsg)
this.logger.error(new Error(errMsg))
return
}
// 响应内容
const data = await ctx.service.settings.findSettings(type)
ctx.response.status = this.config.httpCodeHash.ok
ctx.response.body = data
} catch (err) {
ctx.response.body = err.message || '查询规则错误'
ctx.response.status = this.config.httpCodeHash.serverError
this.logger.error(err)
}
}

这样很很导致大量的代码冗余,每个Controll都要写这样进行校验,如果失败手动返回错误结果(实际上参数校验失败也应该统一处理,后面的异常处理部分会提到)

egg-validate

实际上使用egg-validate插件可以大大简化和标准化参数校验的流程。

安装:

1
npm i egg-validate --save

需要在plugin.js中开启插件:

1
2
3
4
5
// config/plugin.js
exports.validate = {
enable: true,
package: 'egg-validate',
};

egg-validate实际上是由parameter这个库封装而来,它可以针对很多类型的参数进行校验,比如stringdateTimenumberenum等,具体的使用方法可以参考它的文档。

使用egg-validate进行参数校验的正确姿势:

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
'use strict'
const Controller = require('egg').Controller

// 创建规则的校验规则
const createRule = {
type: { type: 'enum', values: [ 'pre', 'single', 'other' ] },
name: { type: 'string', trim: true },
packageName: { type: 'string', trim: true },
content: { type: 'object' },
}

class PrivacyController extends Controller {
// 新建预设规则
async create() {
const { ctx } = this
const { name, packageName, type, content } = ctx.request.body

// 参数校验
ctx.validate(createRule, ctx.request.body)

// 创建新规则
const data = await ctx.service.settings.createSetting(name.trim(), packageName.trim(), type.trim(), content)

// 创建成功
ctx.response.status = this.config.httpCodeHash.created.code
ctx.response.body = insertSetting
}
}

module.exports = PrivacyController

ctx.validate的第一个参数就是校验的规则,第二个参数是被校验的参数,我们的请求方法是POST,所有的参数都在body中,所以传入的是ctx.request.body

如果参数校验没有通过,将会抛出一个status422的异常:

这个错误我们没有在Controller中捕获,后面会提到是如何处理的。

要注意的是,在校验规则中,某些类型是可以传入自定义的错误提示信息的,比如对string的校验,如果使用了formate选项,那么传入的message就会有效,其他时刻传入message无效,无法自定义错误提示信息:

1
2
3
4
const indexRule = {
id: { type: 'string', trim: true, format: /^.{24}$/, message: '非法ID' }, // Mongo生成的ID长度为24位
packageName: { type: 'string', trim: true },
}

查看它的源码,发现它只有显示或者隐式(typeemail等)这种情况下才会提示自定义的提示信息:

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
function checkString(rule, value) {
if (typeof value !== 'string') {
return this.t('should be a string');
}

// if required === false, set allowEmpty to true by default
if (!rule.hasOwnProperty('allowEmpty') && rule.required === false) {
rule.allowEmpty = true;
}

var allowEmpty = rule.hasOwnProperty('allowEmpty')
? rule.allowEmpty
: rule.empty;

if (!value) {
if (allowEmpty) return;
return this.t('should not be empty');
}

if (rule.hasOwnProperty('max') && value.length > rule.max) {
return this.t('length should smaller than %s', rule.max);
}
if (rule.hasOwnProperty('min') && value.length < rule.min) {
return this.t('length should bigger than %s', rule.min);
}

if (rule.format && !rule.format.test(value)) {
return rule.message || this.t('should match %s', rule.format);
}
}

function checkEnum(rule, value) {
if (!Array.isArray(rule.values)) {
throw new TypeError('check enum need array type values');
}
if (rule.values.indexOf(value) === -1) {
return this.t('should be one of %s', rule.values.join(', '));
}
}

有时间想提一个PR,支持所有的类型校验都支持自定义提示信息,但是现在由于无法完全自定义,所以索性在异常处理的时候不对外暴漏具体的message了,只给出统一的参数校验失败的提示:

1
2
3
4
{
"code": -1,
"message": "Validation Failed"
}

统一异常处理

一开始我都是在Controller中使用try...catch来捕获错误,每个Controller都这样做很烦,虽然编写了一个helper中的生成错误响应的方法,但是到处都要调用也很麻烦。

在Controller和Service中都有可能抛出异常,这也是Egg推荐的编码方式。当发现客户端参数传递错误或者调用后端服务异常时,通过抛出异常的方式来进行中断

常见的终端的情形有:

  1. Controller中this.ctx.validate进行参数校验,失败抛出异常
  2. Service中调用this.ctx.curl()进行HTTP请求,可能由于网络问题等原因抛出服务端异常
  3. Service中获取到this.ctx.curl()的调用失败的结果,也会抛出异常
  4. 其他意料之外的错误,也会抛出异常

Egg提供了默认的异常处理,但是可能与系统中统一的接口约定不一致,因此需要自己实现一个统一错误处理的中间件来对错误处理。

app/middleware目录下新建errorHanlder.js文件,新建一个中间件:

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
// app/middleware/error_handler.js
module.exports = () => {
return async function errorHandler(ctx, next) {
try {
await next()
} catch (err) {
// 所有的异常都在 app 上触发一个 error 事件,框架会记录一条错误日志
ctx.app.emit('error', err, ctx)
const status = err.status || 500
const message = err.message || 'Internal Server Error'

// HTTP Code
ctx.status = status

// 生产环境
const isProd = ctx.app.config.env === 'prod'

// 错误响应对象
ctx.body = {
code: -1,
message: (status === 500 && isProd) ? 'Internal Server Error' : message,
// detail: status === 422 ? err.errors : undefined, // 参数校验未通过
}
}
}
}

生产环境时500错误的消息错误内容不应该返回给客户端,因为可能包含敏感信息,所以只返回固定的错误信息。

通过这个中间件,可以捕获所有异常,并且按照想要的格式封装了响应,将这个中间件通过配置文件加载进来:

1
2
3
4
5
6
7
8
9
// config/config.default.js
module.exports = {
// 加载 errorHandler 中间件
middleware: [ 'errorHandler' ],
// 只对 /api 前缀的 url 路径生效
errorHandler: {
match: '/api',
},
};

中间件的加载

单独拿出来这一节,是因为当时踩了一个坑,按照上面的配置之后,发现所有的请求根本没有经过我们的errorHandler中间件。

这是因为Egg支持定义多个环境的配置文件:

1
2
3
4
5
config
|- config.default.js
|- config.prod.js
|- config.unittest.js
`- config.local.js

config.default.js是默认的配置文件,所有所有环境都会加载这个配置文件,一般也会作为开发环境的默认配置文件。

当指定env时也会同时加载对应的额配置文件,并且覆盖默认配置文件的同名配置,比如prod环境会加载config.prod.jsconfig.default.js文件,前者会覆盖后者的同名配置

配置合并使用了extend2模块进行深度拷贝,对数组进行合并时会直接覆盖数组,而不是进行合并

1
2
3
4
5
6
7
8
9
const a = {
arr: [ 1, 2 ],
};
const b = {
arr: [ 3 ],
};

extend(true, a, b);
// => { arr: [ 3 ] }

这就是我们的中间件没有生效的原因,我们的目录里面同时配置了config.local.jsconfig.default.js,在config.default.js虽然配置了中间件,但是在config.local.js中的middleware对应的属性值是一个空数组

根据上面的合并规则,导致最终的middleware是一个空数组,没有加载任何的中间件,所以或者在所有的配置文件的middleware的数组中都加上errorHandler中间件,或者直接在除了config.default.js之外的配置文件中删除middleware属性。

统一的错误对象

我们现在有了统一的异常处理机制,在Controller或者Service中有时候我们要主动抛出异常,抛出的异常应该是一个Error对象,这样才会带上堆栈信息。

但是有一些与HTTP状态有关的异常,应该统一进行管理,保持整个系统的统一。所以使用了egg-errors插件,它内置了统一的异常和错误对象。

安装:

1
npm i egg-errors --save

这里主要使用的是egg-errors内置的HTTP错误对象,它内置了400500的错误对象,它提供了对应的statusheaders属性:

1
2
3
const { ForbiddenError } = require('egg-errors');
const err = new ForbiddenError('your request is forbidden');
console.log(err.status); // 403

也可以使用简写来调用对应的错误:

1
2
3
const { E403 } = require('egg-errors');
const err = new E403('your request is forbidden');
console.log(err.status); // 403

我们在config中新建了一个httpCodeHash.js配置文件,在这个配置文件中引入了egg-errors,根据语义化的HTTP返回值进行了配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// HTTP 响应代码: https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status
const errors = require('egg-errors')

// TODO: httpCodeHash.code
module.exports = {
continue: { code: 100, message: 'Continue' },
ok: { code: 200, message: 'OK' },
created: { code: 201, message: 'Created' },
noContent: { code: 204, message: 'No Content' },
movedPermanently: { code: 301, message: 'Moved Permanently' },
found: { code: 302, message: 'Found' },
notModified: { code: 304, message: 'Not Modified' },
badRequest: { code: 400, message: 'Bad Request', error: errors.E400 },
unauthorized: { code: 401, message: 'Unauthorized', error: errors.E401 },
forbidden: { code: 403, message: 'Forbidden', error: errors.E403 },
notFound: { code: 404, message: 'Not Found', error: errors.E404 },
conflict: { code: 409, message: 'Conflict', error: errors.E409 },
unprocessable: { code: 422, message: 'Unprocessable Entity', error: errors.E422 },
serverError: { code: 500, message: 'serverError', error: errors.E500 },
otherServerError: { code: 502, message: 'Bad Gateway', error: errors.E502 },
errors,
}

使用的时候如果只需要加载对应的信息而不需要抛出错误,那么对应的信息都是统一的:

1
2
3
4
// 响应内容
const data = await ctx.service.log.findPrivacyLog({ id, packageName })
ctx.response.status = this.config.httpCodeHash.ok.code
ctx.response.body = data

如果需要抛出错误的时候,那么就是用对应的error属性,新建一个错误对象,并传入对应的自定义错误提示:

1
throw new this.config.httpCodeHash.notFound.error('检测记录不存在')

这样保证了抛出的错误对象的语义化且统一。

参考