1)背景:
问题出现在近期一个在线教育项目中的登录校验服务,主要分为统一接口模块、服务模块和 API 模块。执行逻辑是这样的:一旦用户调用对应的登录 API 之后,会通过 Dubbo 远程调用到校验服务,之后返回对应的结果。在服务模块中为了方便对业务异常进行处理,使用了自定义的登录异常,这里的逻辑封装在统一实体模块的一个枚举类中,作为外部包导入。
代码语言:javascript复制public enum BusinessExceptionCode {
// 在这里声明业务异常
PHONE_NUMBER_EXIST("手机号已存在"),
STUDENT_NOT_EXIST("学员不存在"),
LOGIN_FAIL("登录失败,用户名或密码错误"),
BE_BAN_FAIL("该账号处于封禁状态,请联系管理员");
private final String desc;
BusinessExceptionCode(String desc) {
this.desc = desc;
}
public String getDesc() {
return desc;
}
}
这里还要提一下全局异常处理的类,这里主要是将业务异常进行特殊处理,以便前端展示:
代码语言:javascript复制@ControllerAdvice
public class ControllerExceptionHandler {
private static final Logger LOG = LoggerFactory.getLogger(ControllerExceptionHandler.class);
/**
* 业务异常处理
*
* @param e : 异常信息
* @return : cn.gpnusz.ucloudteachentity.common.CommonResp<java.lang.Object>
* @author h0ss
*/
@ExceptionHandler(value = BusinessException.class)
@ResponseBody
public CommonResp<Object> businessExceptionHandler(BusinessException e) {
CommonResp<Object> commonResp = new CommonResp<>();
LOG.warn("业务异常:{}", e.getCode().getDesc());
commonResp.setSuccess(false);
commonResp.setMessage(e.getCode().getDesc());
return commonResp;
}
}
也就是说理想效果是,在登录校验失败之后前端应该是要能收到对应的业务异常的提示的:
image.png
这在单体项目中是可以实现的,而在服务拆分之后测试中却发现前端并不能收到对应的业务异常提示,也就是根本就没走到自定义异常的处理逻辑。
2)探究与解决
首先查看登录 API 模块的日志,可以发现在日志中抛出的是 RuntimeException ,日志也显示是系统异常,也就是说根本就没封装成业务异常传出来。
接着尝试在服务模块中对应业务代码块中加断点调试,可以确定确实是抛出了自定义的业务异常信息。
最终回到 API 模块中查看日志信息,可以发现这里抛出的异常信息实际上是经过 dubbo 的 filter 之后的结果:
跟进去这个异常类的 onResponse 方法看看:
重点关注第 98 行代码,可以看到这里重新 new 了一个 RuntimeException 去包装捕获到的异常信息,这也就可以解释为什么在日志中输出的是运行时异常了。
那么 dubbo 为什么要将异常封装呢?先看看这个类的注解信息:
代码语言:javascript复制/**
* Wrap the exception not introduced in API package into RuntimeException.
* Framework will serialize the outer exception but stringnize its cause
* in order to avoid of possible serialization problem on client side
*/
大意就是:dubbo 会将 API 包中没有引入的异常包装到 RuntimeException 中,框架将序列化外部异常,但对其原因进行字符串化,以避免客户端可能出现的序列化问题。
明确了这一点之后就要考虑如何解决了,先返回去看 onResponse 方法,会发现有这么两段代码:
也就是说 ① 如果异常类和接口类在同一个 jar 包中,那么不会走封装的逻辑,会直接返回;② 如果异常类是以 java. 或者 javax. 开头的那么也会直接抛出。 解决方案可以针对这两点处理,第二点对于异常类的要求有点苛刻了,我们考虑从第一个点入手。
将异常类复制一份存到公共接口模块中,然后再看看效果。
奇怪了,并没有预期的显示业务异常的效果,而是抛出了一个新的异常信息,大意是业务异常类无法实例化,对应的是 Hessian 序列化协议的信息。我们跳到异常类去看看:
可以看到这里应该是实例化抛异常了,我们再跟进去 newInstance 方法中看看:
可以看到这里开始获取构造器,并且传入的参数为空,我们跟进去 getConstructor0 这个方法看看:
到这里基本明朗了,这里应该是遍历时没有找到无参构造器,导致实例化失败。我们回过头去看看异常定义类,确实定义少了空参的构造器:
补上之后我们再运行看看结果,可以看到已经正常可以处理业务异常了:
3)总结
实际上对于 dubbo 的异常处理还有多种解决方案,在上面第二点中只写了其中一种,也是我认为的开发人员可以通过日志、源码信息找到的一种解决方案,尽管这样做需要定义额外的自定义异常类。这里再介绍两种解决方案:
① 重写 dubbo 的异常过滤类,加上一个判断:对于以自定义异常类包名开头的异常都不进行拦截,而是直接抛出。这种解决方案的好处是无需定义冗余的自定义异常类,直接从源码级别上进行增补;
② 在 dubbo 配置文件中直接忽略掉对于异常的过滤,对应的 yml 配置如下:
这种方案实际上并不是一个很好的选择,因为这相当于对异常过滤一棍打死,可能会隐含着异常类序列化的问题,建议慎用。