当前位置 : 主页 > 编程语言 > 其它开发 >

Java 异常没你想的那么简单

来源:互联网 收集:自由互联 发布时间:2022-06-15
本文摘录总结于极客时间——《Java业务开发常见错误 100 例》   应用程序避免不了出异常,捕获与处理异常是一个精细活。像是业务开发时不考虑如何处理异常,而在结尾时采用“流

本文摘录总结于极客时间——《Java业务开发常见错误 100 例》

  应用程序避免不了出异常,捕获与处理异常是一个精细活。像是业务开发时不考虑如何处理异常,而在结尾时采用“流水线”的方式进行异常处理,也就是统一的为所有方法打上 try..catch..捕获所有异常记录日志,有些技巧的同学可能会使用 AOP 来进行类似的“统一异常处理”。

  其实,这样是不可取的,今天我们就来聊一聊异常处理相关的坑。

捕获和处理异常容易犯的错
  • 不在业务代码层面考虑处理异常,仅在框架层面粗犷捕获和处理异常

  为了理解错在何处,我们先来看看大多数业务应用都采用的三层架构:

  1. Controller 层负责信息收集、参数校验、转换服务层处理的数据适配前端,轻业务逻辑
  2. Service 层负责核心业务逻辑,包括外部服务调用、访问数据库、缓存处理、消息处理等
  3. Repository 层负责数据访问逻辑,一般没有业务逻辑

  每层架构的工作性质不同,且从业务性质上来说异常可以分为业务异常和系统异常两大类,这就决定了很难进行统一的异常处理。

  1. Repository 层出现的异常或许可以忽略,或许可以降级,或许需要转换为一个友好的异常。如果一律捕获异常仅仅记录,可能业务逻辑已经出错,而用户和程序本身完全感知不到。
  2. Service 曾往往涉及数据库事务,出现异常同样不适合捕获,否则事务无法回滚。此外 Service 层涉及业务逻辑,有些业务逻辑执行中遇到业务异常,可能需要在异常后转入分支业务流程。如果业务异常都被框架捕获了,业务功能就会不正常。
  3. 如果下层异常上升到 Controller 层还是无法处理的话,Controller 层往往会给予用户友好提示,或是根据每一个 API 的异常表返回指定的异常类型,同样无法对所有异常一视同仁。

  有,不建议在框架层面进行异常的自动、统一处理,尤其不要随意捕获异常。但,框架可以做兜底工作。如果异常上升到最上层逻辑还是无法处理的话,可以以统一的方式进行异常转换,比如通过 @RestControllerAdvice + @ExceptionHandler,来捕获这些“未处理”异常:

  1. 对于自定义的业务异常,以 WARN 级别的日志记录异常以及当前 URL、执行方法等信息后,提取异常中的错误码和消息等信息,转换为合适的 API 包装体返回给 A对于无法处理的系统异常,以 Error 级别的日志记录异常和上下文信息(比如 URL、参数、用户 ID)后,转换为普适的“服务器忙,请稍后再试”异常信息,同样以 API 包装体返回给调用方。PI 调用方;
  2. 对于无法处理的系统异常,以 ERROR 级别的日志记录异常和上下文信息(比如 URL、参数、用户 ID)后,转换为普适的“服务器忙,请稍后再试”异常信息,同样以 API 包装体返回给调用方。

  就比如以下代码的做法:

@RestControllerAdvice
@Slf4j
public class RestControllerExceptionHandler {
    private static int GENERIC_SERVER_ERROR_CODE = 2000;
    private static String GENERIC_SERVER_ERROR_MESSAGE = "服务器忙,请稍后再试";

    @ExceptionHandler
    public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
        if (ex instanceof BusinessException) {
            BusinessException exception = (BusinessException) ex;
            log.warn(String.format("访问 %s -> %s 出现业务异常!", req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, exception.getCode(), exception.getMessage());
        } else {
            log.error(String.format("访问 %s -> %s 出现系统异常!", req.getRequestURI(), method.toString()), ex);
            return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
        }
    }
}
  • 第二个错,出了异常就直接生吞
      我们不能只捕获异常而什么事情都不做,生吞的目的可能只是希望自己的方法逃过受检异常,只是想把异常“处理掉”,也可能想当然的认为异常并不重要或者不可能产生。但不管是什么原因,都不能生吞任何一个代码,哪怕你加一个日志也行。

  • 第三个错,丢弃异常的原始信息
      这也是常见操作,比如有这么一个会抛出受检异常的方法 readFile:

private void readFile() throws IOException {
  Files.readAllLines(Paths.get("a_file"));
}

  像这样调用 readFile 方法,捕获异常后,完全不记录原始异常,直接抛出一个转换后异常,导致出了问题不知道 IOException 具体是哪里引起的:

@GetMapping("wrong1")
public void wrong1(){
    try {
        readFile();
    } catch (IOException e) {
        //原始异常信息丢失  
        throw new RuntimeException("系统忙请稍后再试");
    }
}

  或是这样只记录了异常信息,却丢失了异常的类型、栈帧等信息:

catch (IOException e) {
    //只保留了异常消息,栈没有记录
    log.error("文件读取错误, {}", e.getMessage());
    throw new RuntimeException("系统忙请稍后再试");
}

  这两种处理方式都不太合理,推荐改为以下两种方式:

// 1
catch (IOException e) {
    log.error("文件读取错误", e);
    throw new RuntimeException("系统忙请稍后再试");
}

// 2
catch (IOException e) {
    throw new RuntimeException("系统忙请稍后再试", e);
}
  • 第四个错误,抛出异常时不指定任何消息

  经常可以看到有偷懒的同学抛出异常时不指定任何消息

throw new RuntimeException();

  这样的写法被 ExceptionHandler 拦截到后输出了下面的日志信息:

[13:25:18.031] [http-nio-45678-exec-3] [ERROR] [c.e.d.RestControllerExceptionHandler:24  ] - 访问 /handleexception/wrong3 -> org.geekbang.time.commonmistakes.exception.demo1.HandleExceptionController#wrong3(String) 出现系统异常!
java.lang.RuntimeException: null
...

  这里的 null 很容易引起误会,但其实是异常的 message 为空。
  总之,如果你捕获了异常打算处理时,除了除了通过日志正确记录异常原始信息外,通常还有三种处理模式:

  1. 转换,即转换新的异常抛出。对于新抛出的异常,最好具有特定的分类和明确的异常消息,而不是随便抛一个无关或没有任何信息的异常,并最好通过 cause 关联老异常
  2. 重试,即重试之前的操作。比如远程调用服务端过载超时的情况,盲目重试会让问题更严重,需要考虑当前情况是否适合重试。
  3. 恢复,即尝试进行降级处理,或使用默认值来替代原始数据。

  以上,就是通过 catch 捕获处理异常的一些最佳实践。

千万别把异常定义为静态变量

  通常我们都是自定义一个业务异常类型,来包含更多的异常信息,比如异常错误码、友好的错误提示等(比如对于下单操作,用户不存在返回 2001,商品缺货返回 2002 等)。
  之前老大在救火排查某项目生产问题时,遇到了一件非常诡异的事情:发现异常堆信息显示的方法调用路径,在当前入参的情况下根本不可能产生,项目的业务逻辑又很复杂,就始终没往异常信息是错的这方面想,总觉得是因为某个分支流程导致业务没有按照期望的流程进行。
  经过艰难的排查之后,最终定位是把异常定义为了静态变量,导致异常栈信息错乱:

public class Exceptions {
    public static BusinessException ORDEREXISTS = new BusinessException("订单已经存在", 3001);
...
}

  把异常定义为静态变量会导致异常信息固化,这就和异常的栈一定是需要根据当前调用来动态获取相矛盾。
  我们写段代码来模拟下这个问题:定义两个方法 createOrderWrong 和 cancelOrderWrong 方法,它们内部都会通过 Exceptions 类来获得一个订单不存在的异常;先后调用两个方法,然后抛出。

@GetMapping("wrong")
public void wrong() {
    try {
        createOrderWrong();
    } catch (Exception ex) {
        log.error("createOrder got error", ex);
    }
    try {
        cancelOrderWrong();
    } catch (Exception ex) {
        log.error("cancelOrder got error", ex);
    }
}

private void createOrderWrong() {
    //这里有问题
    throw Exceptions.ORDEREXISTS;
}

private void cancelOrderWrong() {
    //这里有问题
    throw Exceptions.ORDEREXISTS;
}

  运行程序后看到如下日志,cancelOrder got error 的提示对应了 createOrderWrong 方法。显然,cancelOrderWrong 方法在出错后抛出的异常,其实是 createOrderWrong 方法出错的异常:

[14:05:25.782] [http-nio-45678-exec-1] [ERROR] [.c.e.d.PredefinedExceptionController:25  ] - cancelOrder got error
org.geekbang.time.commonmistakes.exception.demo2.BusinessException: 订单已经存在
  at org.geekbang.time.commonmistakes.exception.demo2.Exceptions.<clinit>(Exceptions.java:5)
  at org.geekbang.time.commonmistakes.exception.demo2.PredefinedExceptionController.createOrderWrong(PredefinedExceptionController.java:50)
  at org.geekbang.time.commonmistakes.exception.demo2.PredefinedExceptionController.wrong(PredefinedExceptionController.java:18)

  修复方式很简单,改一下 Exceptions 类的实现,通过不同的方法把每一个异常都 new 出来:

public class Exceptions {
    public static BusinessException orderExists(){
        return new BusinessException("订单已经存在", 3001);
    }
}
提交线程池的任务出了异常会怎么样?

  我们来看一个例子:提交 10 个任务到线程池异步处理,第 5 个任务抛出一个 RuntimeException,每个任务完成后都会输出一行日志:

@GetMapping("execute")
public void execute() throws InterruptedException {

    String prefix = "test";
    ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder().setNameFormat(prefix+"%d").get());
    //提交10个任务到线程池处理,第5个任务会抛出运行时异常
    IntStream.rangeClosed(1, 10).forEach(i -> threadPool.execute(() -> {
        if (i == 5) throw new RuntimeException("error");
        log.info("I'm done : {}", i);
    }));

    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
}

  观察日志可以发现两点:

...
[14:33:55.990] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:26  ] - I'm done : 4
Exception in thread "test0" java.lang.RuntimeException: error
  at org.geekbang.time.commonmistakes.exception.demo3.ThreadPoolAndExceptionController.lambda$null$0(ThreadPoolAndExceptionController.java:25)
  at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
  at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
  at java.lang.Thread.run(Thread.java:748)
[14:33:55.990] [test1] [INFO ] [e.d.ThreadPoolAndExceptionController:26  ] - I'm done : 6
...
  1. 任务 1 到 4 所在的线程是 test0,任务 6 开始运行在线程 test1。由于我的线程池通过线程工厂为线程使用统一的前缀 test 加上计数器进行命名,因此从线程名的改变可以知道因为异常的抛出老线程退出了,线程池只能重新创建一个线程。如果每个异步任务都以异常结束,那么线程池可能完全起不到线程重用的作用。
  2. 因为没有手动捕获异常进行处理,ThreadGroup 帮我们进行了未捕获异常的默认处理,向标准错误输出打印了出现异常的线程名称和异常信息。显然,这种没有以统一的错误日志格式记录错误信息打印出来的形式,对生产级代码是不合适的.

&esmp; 修复方式有 2 步:

  1. 以 execute 方法提交到线程池的异步任务,最好在任务内部做好异常处理
  2. 设置自定义的异常处理程序作为保底,比如在声明线程池时自定义线程池的未捕获异常处理程序:
new ThreadFactoryBuilder()
  .setNameFormat(prefix+"%d")
  .setUncaughtExceptionHandler((thread, throwable)-> log.error("ThreadPool {} got exception", thread, throwable))
  .get()

  或者设置全局的默认未捕获异常处理程序:

static {
    Thread.setDefaultUncaughtExceptionHandler((thread, throwable)-> log.error("Thread {} got exception", thread, throwable));
}
网友评论