2021-08-08 21:15  阅读(603)
文章分类:死磕 Java 基础 文章标签:死磕 Java死磕 Java 基础
© 版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

前言

我敢说对于很多小伙伴来说,他们以为在 Java 中异常就是 try...catch,稍微有点儿意识的还会用下 throw new Exception,真的有这么简单吗?请宽恕小编直言,你对 Java 异常一知半解。以下是小编对 Java 异常的理解,如有错误之处,请原谅,资质有限。

引出异常

先问一个问题,在 Java 中,你是赞同使用返回码还是异常来规范错误呢?

我先来说说使用返回码的情况,比如登录逻辑,我们分为如下几种情况:

  • 0:登录成功
  • 1:用户名错误,没有该用户
  • 2:用户密码输入错误
  • 3:第一次登录,重置密码
  • 4:密码连续错 5 次,冻结账户

使用返回码一般都是如下处理:

        int code = userService.login(userName,password);
        if (code == 0) {
            return "登录成功";
        }else if (code == 1) {
            return "用户名错误,请重新输入";
        }else if (code == 2) {
            return "密码输入错误,请重新输入";
        } else if (code == 3) {
            return "您是第一次登录,请重置密码";
        } else if (code == 4) {
            return "您密码已连续输错 5 次,冻结一天";
        }

当然,您可能有更加优雅的处理方式,比如把 code 直接返回给前端,让前端来根据返回码来展示不同的异常。如:

return userService.login(userName,password);

这种后端处理方式确实是比较优雅,但是前端呢?如果你后端再增加 5、6 种返回码,你觉得你前端的同事不会打死你吗?

那怎么解决呢?

  1. 首先约定 code 和 message,例如:0000,表示成功,0001,表示第一次登录,跳转到重置页面,-0001,表示登录失败。然后 message 描述返回信息。
  2. 后端统一异常处理。

可能有小伙伴说,这种是前后端的交互,那纯后端模式呢?一样应该使用异常。

如果你使用返回码来规范接口,如:

  • 0:成功
  • 1:异常 1
  • 2:异常 2
  • 3:异常 3

那么别人在调用的写的方法时,势必需要区分这几种情况,比如 code = 0 怎么处理,code = 1 怎么处理,....,刚刚开始他确实也是这样处理的,而且处理地非常好,你们程序运行也非常好,但是某天,你增加了一个 code = 5的,然后你恰巧忘记告诉他了,嗯,上线后你就等着挨批吧。

其实这种单返回值还好处理,如果是多返回值,请问你怎么处理?如果返回的结果是一个 List 集合呢?你是不是得要构造一个 Map 或者对象,别说你返回 null 哈,否则我要敲你脑袋了。所以这种通过返回码的处理方式还是有一些问题的:

  1. 程序员不小心忘记返回值的检查,从而造成 BUG
  2. 方法接口变得非常不纯洁了,正常值和错误值混淆在一起,导致语义问题

大明哥,在真实项目里面看到过这样处理的逻辑,当时我一看,菊花瞬间就紧了,惊为天人啊。如下

public class ServiceA {
    
    public String method1() {
        doSomething1();
        
        doSomething2();
        
        if (a) {
            return "false@xxxx";
        } else if (b) {
            return "false@zzzzz";
        } else if (c) {
            return "false@ccccc";
        } else {
            return "true@vvvvv";
        }
    }
}

调用方:

public class ServiceB {
    
    public void method2() {
        String result = method1();
        String[] results = result.split("@");
        if ("false".equals(results[0])) {
            if (results[1].contains("xxxx")) {
                doSomething1();
            }
            if (results[1].contains("zzzzz")) {
                doSomething2();
            }
            if (results[1].contains("ccccc")) {
                doSomething3();
            }
        }
    }
}

我就问你怕不怕?

在我看来通过返回码来判断程序的运行结果是否正确,真的是一件吃力不讨好的事情。Java 提供了 try...catch 这么优雅的处理方式,他难道不香吗?

可能有小伙会问,你写的方法如果抛出了异常,没有告诉其他同事,是不是也会导致代码异常?我丢,如果这个异常需要你同事做额外处理,你不会抛出 checked Exception 么?

可能又有小伙伴说,抛出异常会降低程序性能,这个确实,但是有考虑过一个问题没有,我们系统大部分情况下都是正常运行,只会偶尔抛出异常,如果你写的程序一天到晚都在抛出异常,你是不是需要思考下,到底是你的问题还是程序的问题?同时,丢失掉这么一点点性能换来的是高可读、易维护、优雅的代码难道不值得吗?

最佳实践

既然 Exception 有诸多好处,那么我们应该怎么用好他呢?

我个人认为,程序中的错误可以分为三大类:

  • 系统错误。这类错误是程序运行环境的问题,一般我们都无法避免,对于这类错误,有些我们是可以处理的,比如请求网络异常,这个我们可以重试几次,而有些是我们无法处理的,比如内存耗尽 OOM 了、栈溢出等等,这种我们就只能停止运行,甚至退出整个程序。
  • 程序错误。这类错误一般都是我们程序的 bug,比如空指针,文件未创建,逻辑计算错误,对于这种错误,我们必须要记录下来,而且最好是触发监控系统告警。
  • 用户错误。比如用户输入非法参数,重复请求,一般这类的错误属于用户应用层错误,对于这类错误,我们只需要提示用户即可,没有必要记录日志,但是我们可以做一些必要的统计,比如某个用户频繁输入非法参数,不断进行错误请求,我们可以将这些用户纳入黑名单等,这样有利于我们改善系统和侦测是否有恶意的用户请求。

对于这三种错误,我们需要进行区分,不同的异常分类有不同的处理级别。

  • 系统错误:尽可能的预见异常,在能处理的地方需要进行处理,不能处理的往外抛
  • 程序错误:我们需要尽可能杜绝,记录每一个程序处理异常,进行必要的告警。
  • 用户错误:参数校验必须,严谨将错误参数带入系统。我们无法避免,只能适当统计和侦测。

同时,我不建议系统中定义太多自定义 Exception,有些小伙伴自定义 Exception 好像着魔了一样,在系统中定义一大把 Exception,如 UserNotFoundExceptionUserPasswordErrorException等等。目前我负责的系统中就只有如下几个异常:

  1. BusinessException:业务异常,继承 RuntimeException
  2. NotFountException:业务异常,继承 RuntimeException
  3. ParamValidateException:业务异常,继承 RuntimeException
  4. SystemException:系统异常,继承 Exception

1、2、3 不需要显示处理,4 一定需要处理。然后再配合两个枚举:

  1. BusinessErrorCodeEnum
  2. SystemErrorCodeEnum

同时 BusinessException,构造函数中有一个 isPrintLog 参数,用来判断是否需要打印 Error 日志,对于一些不需要我们关注的 Exception,统一传 false 即可。监控系统会每天将我们系统中所有的 Exception 通过邮件的方式发送给我们团队的每一个人,我每天都会去关注前一天系统中的 Error 日志,根据实际情况去掉一些不关注的 Exception。而且监控系统会实时将系统的 Error 、Exception 通过邮件和企业微信的方式告知相关的开发人员,这样我们就能及时发现系统这个的错误,及时响应,缩小影响范围。

下面是耗子叔的异常最佳实践,总结的非常好:

  • 统一分类的错误字典。无论你是使用错误码还是异常捕捉,都需要认真并统一地做好错误的分类。最好是在一个地方定义相关的错误。比如,HTTP 的 4XX 表示客户端有问题,5XX 则表示服务端有问题。也就是说,你要建立一个错误字典。
  • 同类错误的定义最好是可以扩展的。这一点非常重要,而对于这一点,通过面向对象的继承或是像 Go 语言那样的接口多态可以很好地做到。这样可以方便地重用已有的代码。
  • 定义错误的严重程度。比如,Fatal 表示重大错误,Error 表示资源或需求得不到满足,Warning 表示并不一定是个错误但还是需要引起注意,Info 表示不是错误只是一个信息,Debug 表示这是给内部开发人员用于调试程序的。
  • 错误日志的输出最好使用错误码,而不是错误信息。打印错误日志的时候,应该使用统一的格式。但最好不要用错误信息,而应使用相应的错误码,错误码不一定是数字,也可以是一个能从错误字典里找到的一个唯一的可以让人读懂的关键字。这样,会非常有利于日志分析软件进行自动化监控,而不是要从错误信息中做语义分析。比如:HTTP 的日志中就会有 HTTP 的返回码,如:404。但我更推荐使用像PageNotFound这样的标识,这样人和机器都很容易处理。
  • 忽略错误最好有日志。不然会给维护带来很大的麻烦。
  • 对于同一个地方不停的报错,最好不要都打到日志里。不然这样会导致其它日志被淹没了,也会导致日志文件太大。最好的实践是,打出一个错误以及出现的次数。不要用错误处理逻辑来处理业务逻辑。也就是说,不要使用异常捕捉这样的方式来处理业务逻辑,而是应该用条件判断。如果一个逻辑控制可以用 if - else 清楚地表达,那就不建议使用异常方式处理。异常捕捉是用来处理不期望发生的事情,而错误码则用来处理可能会发生的事。
  • 对于同类的错误处理,用一样的模式。比如,对于null对象的错误,要么都用返回 null,加上条件检查的模式,要么都用抛 NullPointerException 的方式处理。不要混用,这样有助于代码规范。
  • 尽可能在错误发生的地方处理错误。因为这样会让调用者变得更简单。向上尽可能地返回原始的错误。如果一定要把错误返回到更高层去处理,那么,应该返回原始的错误,而不是重新发明一个错误。
  • 处理错误时,总是要清理已分配的资源。这点非常关键,使用 RAII 技术,或是 try-catch-finally,或是 Go 的 defer 都可以容易地做到。
  • 不推荐在循环体里处理错误。这里说的是 try-catch,绝大多数的情况你不需要这样做。
  • 最好把整个循环体外放在 try 语句块内,而在外面做 catch。不要把大量的代码都放在一个 try 语句块内。一个 try 语句块内的语句应该是完成一个简单单一的事情。
  • 为你的错误定义提供清楚的文档以及每种错误的代码示例。如果你是做 RESTful API 方面的,使用 Swagger 会帮你很容易搞定这个事。
  • 对于异步的方式,推荐使用 Promise 模式处理错误。对于这一点,JavaScript 中有很好的实践。
  • 对于分布式的系统,推荐使用 APM 相关的软件。尤其是使用 Zipkin 这样的服务调用跟踪的分析来关联错误。

最后用一句总结今天的文章:异常应该出现在它应该出现的地方

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 【死磕 Java 基础】 — 你以为异常就是 try…catch ?那你天真了
上一篇
【死磕 Java 基础】 — 谈谈那个写时拷贝技术(copy-on-write)
下一篇
【死磕 Java 基础】— 我同事一个 select 分页语句查出来了 3000W 条数据