优雅处理异常

Introduction

我们开发的业务系统,或者是产品,常常面临着这样的问题:

  • 系统运行出错,但是完全不知道错误发生的位置;
  • 我们找到了错误的位置,但是完全不知道是因为什么;
  • 系统明明出了错误,但是就是看不到错误堆栈信息。

什么时候需要自定异常?

经常看到一些项目,在全局定义一个 AppException,然后所有地方都只抛出这个异常,并且把捕获的异常case到这个AppException中。

这样做会有如下问题:

  • 浪费log日志存储空间,并且栈顶并不是最接近发生异常的代码位置;
  • 只有一种异常类,无法精准区分开异常类型;
  • 异常类后期难以修改以增加其携带的信息。

什么情况需要手动处理异常?

  • 你有能力处理异常,并且你知道如何处理;
  • 你有责任处理异常。

如何优雅地处理异常

首先,有一点我们要清楚:MVC设计模式告诉我们,Controller是用来接收页面参数,并且调用逻辑处理,最后组织页面响应的地方。不建议在Controller进行逻辑处理,Controller只应该负责用户API入口和响应的处理。

思考一下如果有一天Service的代码打包成jar放到另一个平台,没有controller了,该怎么办?

定义Service异常

1
2
3
4
5
open class BaseExp(val code: Int, error: String) : RuntimeException(error){
}

class ServiceExp(code: Int, message: String) : BaseExp(code, message) {
}

这个异常继承自RuntimeException,并且包含一个接受一个错误原因的构造器,这样Controller层也不需要知道异常,只要全局捕获到ServiceExp做统一的处理即可。

这无论是在Struct1、2时代,还是SpringMVC中,甚至Servlet年代,都是极为容易的。

此外,异常不能有无参构造函数,因为绝对不允许你抛出一个逻辑处理异常,但是不指明原因。


Controller处理举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 修改用户信息
* @param userID 用户ID
* @param user 修改用户信息表单数据
*/
@PutMapping("{userID}")
public JSONResult updateUser(@PathVariable("userID") Integer userID, @RequestBody UpdateUserForm userForm) {
User user = new User(); //准备业务逻辑层使用的领域模型
BeanUtils.copyProperties(userForm, user); //拷贝要修改的值
user.setUserId(userID); //设置主键到用户数据中
userService.updateUser(user); //调用更新业务逻辑
JSONResult json = new JSONResult(); //准备要响应的数据
json.put("user", user); //把修改后的用户数据还给页面
return json;
}

数据校验一般分为两部分:

  • 有效性:比如用户所在岗位,是否属于数据库有记录的岗位ID,如果不存在,无效。
  • 合法性:比如用户名只允许输入最多12个字符,用户提交了20个字符,不合法。建议使用校验框架,比如JSR303

假如出现以下问题,如何通知用户?

  • 要修改的用户ID不存在;
  • 用户被锁定,不允许修改;
  • 乐观锁机制发现用户已经被被人修改过等等。

常见的处理方式有:

  1. 在Controller调用UserService的checkUserExist()方法;
  2. 在Controller直接书写业务逻辑;
  3. 在Service响应一个状态码机制,比如1 2 3表示错误信息,0表示没有任何错误。

根据MVC设计模式来说,第一种和第二种都不考虑。而第三种似乎是一种可选的方式,可是如此一来,用户保存逻辑变了,比如增加一个情况,不允许修改已经离职的用户,那么我们还需要修改Controller的代码,代码量增加,维护成本增高,并且还耦合了Service,不符合MVC设计模式。


Service处理举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 修改用户信息
* @param user 要修改的用户数据
*/
public void updateUser(User user) {
User userOrig = userDao.getUserById(user.getUserID());
if (null == userOrig) {
throw new ServiceExp("用户不存在");
}
if (userOrig.isLocked()) {
throw new ServiceExp("用户被锁定,不允许修改");
}
if (!user.getVersion().equals(userOrig.getVersion())) {
throw new ServiceExp("用户已经被别人修改过,请刷新重试");
}
// TODO 保存用户数据 ...
}

只要我们检查到不允许保存的对象,我们就可以直接throw一个新的异常,异常机制会帮助我们中断代码执行。

Service上层处理异常的方式有:

  1. 在Controller使用try-catch进行处理;
  2. 直接把异常抛给上层框架统一处理。

第一种方式直接pass掉,注意我们抛出的ServiceExp,它仅仅逻辑处理异常,并且我们的方法前面也没有声明throws ServiceExp,这表示它是一个非受查异常。

Q:为什么不定义成受查异常? 如果是一个受查异常,那么意味着Controller必须要处理你的异常。并且如果有一天你的业务逻辑变了,可能多一种检查项,就需要增加一个异常。反之需要删除一个异常,那么你的方法签名也需要改变,Controller也随之要改变,这又变成了紧耦合。

Q:可以为每一种检查项定义一个异常吗?可以,但是那样显得太多余了。因为业务逻辑处理失败的时候,根据我们需求,我们只需要通知用户失败的原因(通常应该是一段字符串),以及服务器受理失败的一个状态码,这样这需要一个包含原因属性的异常即可满足我们需求。


ControllerAdvice处理举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@ControllerAdvice(basePackages = { "com.xxx.bussiness.xxx" })
public class ModuleControllerAdvice {
private static final Logger LOGGER = LoggerFactory.getLogger(ModuleControllerAdvice.class);
private static final Logger SERVICE_LOGGER = LoggerFactory.getLogger(ServiceExp.class);

/**
* 业务受理失败
*/
@ResponseBody
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(ServiceExp.class)
private JSONResult handleServiceException(ServiceExp exception) {
String message = "业务受理失败,原因:" + exception.getLocalizedMessage();
SERVICE_LOGGER.info(message);
JSONResult json = new JSONResult();
json.serCode(500001);
json.setMessage(message);
return json;
}
}

注意一点,在这个类中,我们定义了2个log对象,分别指向ServiceExp.class 和 ControllerAdvice.class,处理ServiceExp的时候使用了info级别的日志输出。

  • ServiceExp一定要和其他的代码错误分离,不应该混为一谈。
  • ServiceExp并不一定要记录日志,我们应该提供独立的log对象,方便开关。

异常分类建议

  • 逻辑异常类:用于描述业务无法按照预期的情况处理下去,属于用户制造的意外。
  • 代码错误类:这类异常用于描述开发的代码错误,例如NPE、ILLARG等都属于程序员制造的BUG。
  • 专用异常类:多用于特定业务场景,用于描述指定作业出现意外情况无法预先处理。

各类异常必须要有单独的日志记录,或者分级,便于日志管理。比如,有的时候仅仅想给三方运维看到逻辑异常。

Counter-Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 处理业务消息
* @param message 要处理的消息
*/
public void processMessage(Message<String> message) {
try{
// 处理消息验证
// 处理消息解析
// 处理消息入库
}catch(ValidateException e ){
// 验证失败
}catch(ParseException e ){
// 解析失败
}catch(PersistException e ){
// 入库失败
}
}

修改后的代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 处理业务消息
* @param message 要处理的消息
*/
public void processMessage(Message<String> message) {
// 处理消息验证
if(!message.isValud()){
MessageLogService.log("消息校验失败" + message.errors())
return ;
}
// 处理消息解析
if(!message.parse()){
MessageLogService.log("消息解析失败" + message.errors())
return ;
}
// TODO ....
}

Reference

https://mp.weixin.qq.com/s/GXSZ-3w3Px43fA3XEuyWxw