本文介绍
本文仅按照业务系统开发角度描述异常的一些处理看法. 不涉及 java 的异常基础知识, 可以自行查阅 Java 核心技术 卷 I 和 java 编程思想 可以得到更多的基础信息.
写在前面的话
笔者文笔功力尚浅, 言语多有不妥, 请慷慨指正, 必定感激不尽. 本文提出了几个概念: 处理反馈 业务异常 代码错误 , 请认真思考一下各中区别.
在开发业务系统中, 我们目前绝大多数采用 MVC 模式, 但是往往有人把 service 跟 controller 紧紧的耦合在一起, 甚至直接使用 Threadlocal 来隐式传值, 并且复杂的逻辑几乎只能使用 service 中存储的全局对象来传递处理结果, 包括异常.
这样一来首先有违 MVC 模式, 二来逻辑十分不清晰, 难以维护. 本文结合工作经验, 给出一些异常使用建议, 使用 spring 来实战异常为我们带来的好处.
常常, 我们读罢了各种 java 的书, 异常的各种机制, 特性都很清楚, 但是始终还是不知道如何使用, 甚至背下了概念, 却不知道如何致用.
我们开发的业务系统, 或者是产品, 常常面临着这样的问题:
系统运行出错, 但是完全不知道错误发生的位置.
我们找到了错误的位置, 但是完全不知道是因为什么.
系统明明出了错误, 但是就是看不到错误堆栈信息.
什么情况需要自定义异常
经常看到一些项目, 在全局定义一个 AppException, 然后所有地方都只抛出这个异常, 并且把捕获的异常 case 到这个 AppException 中. 会有如下问题:
浪费 log 日志存储空间, 并且栈顶并不是最接近发生异常的代码位置.
只有一种异常类, 无法精准区分开异常类型
异常类后期难以修改以增加其携带的信息.
什么情况需要手动处理异常
我不会把书上的东西直接复制下来, 这里说一下容易记住的, 并且适合业务开发的.
你有能力处理异常, 并且你知道如何处理
你有责任处理异常
自定义业务异常
考虑如下场景: 系统提供一个 API, 用于修改用户信息, 服务器端采用 json 数据交互. 首先我们定义 ServiceException, 用来表示业务逻辑受理失败, 它仅表示我们处理业务的时候发现无法继续执行下去.
- /**
- * 业务受理失败异常
- */
- public class ServiceException extends RuntimeException {
- // 接收 reason 参数用来描述业务失败原因.
- public ServiceException(String reason) { super(reason); }
- }
接下来看下 Controller 层.
- // UserController.java
- /**
- * 修改用户信息
- * @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; // --
- }
关于上述 Controller 写法乍一看会有一些冗余, 如果无法理解, 请仔细研读 MVC 设计模式. 先不管 service, 我们来考虑下. 一个业务系统不可能不对用户提交的数据进行验证, 验证包括两方面 : 有效性和合法性,
有效性: 比如用户所在岗位, 是否属于数据库有记录的岗位 ID, 如果不存在, 无效.
合法性: 比如用户名只允许输入最多 12 个字符, 用户提交了 20 个字符, 不合法.
有效性检查, 可以交给 java 的校验框架执行, 比如 JSR303. 假设用户提交的数据经过验证都合法, 还是有一些情况是不能调用修改逻辑的.
要修改的用户 ID 不存在.
用户被锁定, 不允许修改.
乐观锁机制发现用户已经被被人修改过.
由于某种原因, 我们的程序无法保存到数据库.
一些程序员错误的开发了代码, 导致保存过程中出现异常, 比如 NPE.
对于前 3 种, 我们认为是有效性检查失败, 第 4 种属与我们无法处理的异常, 第 5 种就是程序员 bug.
现在的问题是, 前三种情况我们如何通知用户呢?
在 ccontroller 调用 userService 的 checkUserExist()方法.
在 controller 直接书写业务逻辑.
在 service 响应一个状态码机制, 比如 1 2 3 表示错误信息, 0 表示没有任何错误.
显然前 2 种方法都不可取 , 因为 MVC 不设计模式告诉我们, controller 是用来接收页面参数, 并且调用逻辑处理, 最后组织页面响应的地方. 我们不可以在 controller 进行逻辑处理, controller 只应该负责用户 API 入口和响应的处理(如若不然, 思考一下如果有一天 service 的代码打包成 jar 放到另一个平台, 没有 controller 了, 该怎么办?)
状态码机制是个不错的选择, 可是如此一来, 用户保存逻辑变了, 比如增加一个情况, 不允许修改已经离职的用户, 那么我们还需要修改 controller 的代码, 代码量增加, 维护成本增高, 并且还耦合了 service, 不符合 MVC 设计模式.
那么怎么办呢? 现在我们来看下 service 代码如何编写
- /**
- * 修改用户信息
- * @param user 要修改的用户数据
- */
- public void updateUser(User user) {
- User userOrig = userDao.getUserById(user.getUserID());
- if (null == userOrig) {
- throw new ServiceException("用户不存在");
- }
- if (userOrig.isLocked()) {
- throw new ServiceException("用户被锁定, 不允许修改");
- }
- if (!user.getVersion().equals(userOrig.getVersion())) {
- throw new ServiceException("用户已经被别人修改过, 请刷新重试");
- }
- // TODO 保存用户数据 ...
- }
这样一来只要我们检查到不允许保存的项目, 我们就可以直接 throw 一个新的异常, 异常机制会帮助我们中断代码执行.
接下来有 2 种选择:
在 controller 使用 try-catch 进行处理.
直接把异常抛给上层框架统一处理.
第 1 种方式是不可取的 , 注意我们抛出的 ServiceException, 它仅仅逻辑处理异常, 并且我们的方法前面没有声明 throws ServiceException, 这表示他是一个非受查异常. controller 也没有关心会发生什么异常.
为什么不定义成受查异常呢? 如果是一个受查异常, 那么意味着 controller 必须要处理你的异常. 并且如果有一天你的业务逻辑变了, 可能多一种检查项, 就需要增加一个异常, 反之需要删除一个异常, 那么你的方法签名也需要改变, controller 也随之要改变, 这又变成了紧耦合, 这和用状态码 123 表示处理结果没有什么不同.
我们可以为每一种检查项定义一个异常吗? 可以, 但是那样显得太多余了. 因为业务逻辑处理失败的时候, 根据我们需求, 我们只需要通知用户失败的原因(通常应该是一段字符串), 以及服务器受理失败的一个状态码(有时可能不需要状态码, 这要看你的设计了), 这样这需要一个包含原因属性的异常即可满足我们需求.
最后我们决定这个异常继承自 RuntimeException. 并且包含一个接受一个错误原因的构造器, 这样 controller 层也不需要知道异常, 只要全局捕获到 ServiceException 做统一的处理即可, 这无论是在 struct1,2 时代, 还是 springMVC 中, 甚至 servlet 年代, 都是极为容易的!
异常不提供无参构造器 , 因为绝对不允许你抛出一个逻辑处理异常, 但是不指明原因, 想想看, 你是必须要告诉用户为什么受理失败的!
如此一来, 我们只需要全局统一处理下 ServiceException 就可以了, 很好, spring 为我们提供了 ControllerAdvice 机制, 有关 ControllerAdvice, 可以查阅 springMVC 使用文档, 下面是一个简单的示例:
- @ControllerAdvice(basePackages = { "com.xxx.xxx.bussiness.xxx" })
- public class ModuleControllerAdvice {
- private static final Logger LOGGER = LoggerFactory.getLogger(ModuleControllerAdvice.class);
- private static final Logger SERVICE_LOGGER = LoggerFactory.getLogger(ServiceException.class);
- /**
- * 业务受理失败
- */
- @ResponseBody
- @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
- @ExceptionHandler(ServiceException.class)
- private JSONResult handleServiceException(ServiceException exception) {
- String message = "业务受理失败, 原因:" + exception.getLocalizedMessage();
- SERVICE_LOGGER.info(message);
- JSONResult json = new JSONResult();
- json.serCode(500001); // 500000 表示系统异常, 500001 表示业务逻辑异常
- json.setMessage(message);
- return json;
- }
- }
在这个时候, 我们就可以很轻松的处理各种情况了.
注意一点, 在这个类中, 我们定义了 2 个 log 对象, 分别指向 ServiceException.class 和 ModuleControllerAdvice.class . 并且处理 ServiceException 的时候使用了 info 级别的日志输出, 这是很有用的.
首先, ServiceException 一定要和其他的代码错误分离, 不应该混为一谈.
其次, ServiceException 并不一定要记录日志, 我们应该提供独立的 log 对象, 方便开关.
接下来你可以在修改用户的时候想客户端响应这样的 JSON
- {
- code: 200001,
- message: "业务受理失败, 原因: 用户名称不存在!"
- }
如此一来没有任何地方需要关心异常, 或者业务逻辑校验失败的情况. 用户也可以得到很友好的错误提示.
如何对异常进行分类
如果你只需要一句概括, 那么直接定义一个简单的异常, 用于中断处理, 并且与用户保持友好交互即可.
如果不可能一句话描述清楚, 并且包含附加信息, 比如需要在日志或者数据库记录消息 ID, 此时可能专门针对这种重要 / 复杂业务创建独立异常.
上述两种情况因为 web 系统, 是用户发起请求之后需要等待程序给予响应结果的.
如果是后台作业, 或者复杂业务需要追溯性. 这种通常用流程判断语句控制, 要用异常处理. 我们认为这些流程判断一定在一个原子性处理中. 并且检查到 (不是遇到) 的问题 (不是异常) 需要记录到用户可友好查看的日志. 这种情况属于处理反馈, 并不叫异常.
综上, 笔者通常分为如下几类:
逻辑异常, 这类异常用于描述业务无法按照预期的情况处理下去, 属于用户制造的意外.
代码错误, 这类异常用于描述开发的代码错误, 例如 NPE,ILLARG, 都属于程序员制造的 BUG.
专有异常, 多用于特定业务场景, 用于描述指定作业出现意外情况无法预先处理.
各类异常必须要有单独的日志记录, 或者分级, 分类可管理. 有的时候仅仅想给三方运维看到逻辑异常.
写在后面的注意
异常设计的初衷是解决程序运行中的各种意外情况, 且异常的处理效率比条件判断方式要低很多.
上面这句话出自 < java 编程思想>, 但是我们思考如下几点:
业务逻辑检查, 也是意外情况
UnknownHostException, 表示找不到这样的主机, 这个异常和 NoUserException 有什么区别么? 换言之, 没有这样的主机是异常, 没有这样的用户不是异常了么? 所以一定要弄明白什么是用异常来控制逻辑, 什么是定义程序异常.
异常处理效率很低
书中所示的例子, 是在循环中大量使用 try-catch 进行检查, 但是业务系统, 用户发起请求的次数与该场景天壤地别. 淘宝的 11`11 是个很好的反例. 但是请你的系统上到这个级别再考虑这种问题.
系统有千万并发, 不可能还去考虑这些中规中矩的按部就班的方式, 别忘了 MVC 本来就浪费很多资源, 代码量增加很多.
业务系统也存在很多巨量任务处理的情况. 但是那些任务都是原子性的, 现在 MVC 中的 controller 和 service 可不是原子性的, 不然为什么要区分这么多层呢.
如果那么在乎效率, 考虑下重写 Throwable 的 fillStackTrace 方法. 你要知道异常的开销大到底大在什么地方, fillStackTrace 是一个 native 方法, 会填充异常类内部的运行轨迹.
不要用异常进行业务逻辑处理
我们先来看一个例子:
- // 这是一个非常典型的反例, 也是一个误区.
- /**
- * 处理业务消息
- * @param message 要处理的消息
- */
- public void processMessage(Message<String> message) {
- try{
- // 处理消息验证
- // 处理消息解析
- // 处理消息入库
- }catch(ValidateException e ){
- // 验证失败
- }catch(ParseException e ){
- // 解析失败
- }catch(PersistException e ){
- // 入库失败
- }
- }
上述代码就是典型的使用异常来处理业务逻辑. 这种方式需要严重的禁止! 上述代码最大的问题在于, 我们如何利用异常来自动处理事务呢?
然而这和我们的异常中断 service 没有什么冲突. 也并不是一回事.
我们提倡在 业务处理 的时候, 如果发现无法处理直接抛出异常即可.
而并不是在 逻辑处理 的时候, 用异常来判断逻辑进行的状况.
改正后的逻辑
- /**
- * 处理业务消息
- * @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 ....
- }
最后俏皮一句: 微服务横行的今天, 我们在 action 里面直接写业务处理, 也无可厚非.
来源: https://juejin.im/entry/5b3c6f66f265da0f65236185