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 exports .validate = { enable : true , package : 'egg-validate' , };
egg-validate
实际上是由parameter 这个库封装而来,它可以针对很多类型的参数进行校验,比如string
、dateTime
、number
、enum
等,具体的使用方法可以参考它的文档。
使用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
如果参数校验没有通过,将会抛出一个status
为422
的异常:
这个错误我们没有在Controller中捕获,后面会提到是如何处理的。
要注意的是,在校验规则中,某些类型是可以传入自定义的错误提示信息的,比如对string
的校验,如果使用了formate
选项,那么传入的message
就会有效,其他时刻传入message
无效,无法自定义错误提示信息:
1 2 3 4 const indexRule = { id : { type : 'string' , trim : true , format : /^.{24}$/ , message : '非法ID' }, packageName : { type : 'string' , trim : true }, }
查看它的源码,发现它只有显示或者隐式(type
为email
等)这种情况下才会提示自定义的提示信息:
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 (!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推荐的编码方式。当发现客户端参数传递错误或者调用后端服务异常时,通过抛出异常的方式来进行中断
常见的终端的情形有:
Controller中this.ctx.validate
进行参数校验,失败抛出异常
Service中调用this.ctx.curl()
进行HTTP请求,可能由于网络问题等原因抛出服务端异常
Service中获取到this.ctx.curl()
的调用失败的结果,也会抛出异常
其他意料之外的错误,也会抛出异常
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 module .exports = () => { return async function errorHandler (ctx, next ) { try { await next () } catch (err) { ctx.app .emit ('error' , err, ctx) const status = err.status || 500 const message = err.message || 'Internal Server Error' ctx.status = status const isProd = ctx.app .config .env === 'prod' ctx.body = { code : -1 , message : (status === 500 && isProd) ? 'Internal Server Error' : message, } } } }
生产环境时500错误的消息错误内容不应该返回给客户端,因为可能包含敏感信息,所以只返回固定的错误信息。
通过这个中间件,可以捕获所有异常,并且按照想要的格式封装了响应,将这个中间件通过配置文件加载进来:
1 2 3 4 5 6 7 8 9 module .exports = { middleware : [ 'errorHandler' ], 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.js
和config.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);
这就是我们的中间件没有生效的原因,我们的目录里面同时配置了config.local.js
和config.default.js
,在config.default.js
虽然配置了中间件,但是在config.local.js
中的middleware
对应的属性值是一个空数组
根据上面的合并规则,导致最终的middleware
是一个空数组,没有加载任何的中间件,所以或者在所有的配置文件的middleware
的数组中都加上errorHandler
中间件,或者直接在除了config.default.js
之外的配置文件中删除middleware
属性。
统一的错误对象 我们现在有了统一的异常处理机制,在Controller或者Service中有时候我们要主动抛出异常,抛出的异常应该是一个Error对象,这样才会带上堆栈信息。
但是有一些与HTTP状态有关的异常,应该统一进行管理,保持整个系统的统一。所以使用了egg-errors
插件,它内置了统一的异常和错误对象。
安装:
这里主要使用的是egg-errors
内置的HTTP错误对象,它内置了400
到500
的错误对象,它提供了对应的status
和headers
属性:
1 2 3 const { ForbiddenError } = require ('egg-errors' );const err = new ForbiddenError ('your request is forbidden' );console .log (err.status );
也可以使用简写来调用对应的错误:
1 2 3 const { E403 } = require ('egg-errors' );const err = new E403 ('your request is forbidden' );console .log (err.status );
我们在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 const errors = require ('egg-errors' )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 ('检测记录不存在' )
这样保证了抛出的错误对象的语义化且统一。
参考