手记

《手册》详解 18 异常处理的建议的学员问题解答

1 背景

《阿里巴巴Java开发手册》详解 专栏, 18 节 一些异常处理的建议小节主要讲到了实际开发中容易趟坑的异常处理的方式,给出了异常处理的建议。

其中有一个学员的问题非常有点代表性,由于篇幅较长,通过一篇手记来回答。


@目科将

有3个问题: 1. 有些我们catch到的异常,通过getMessage()的信息其实不是根因,一般对定位没大帮助,而是需要rootcase的getMessage(),我们还是看堆栈信息才能知道根因;这个应该怎么处理; 2.service层的代码担心有问题,可能就会通过catch(Exception e),用来规避影响正常流程的处理;专栏中给的异步处理可以解决这个问题,但像这种 "担心有问题"的代码(比如远程调用,查询数据库) 我都需要加try catch或异步处理么,这块有没有好的解决办法; 3.异步线程中的异常是不是没法往上抛(主线程),那是不是说异步线程中的异常只能该线程自己处理掉


2 依次分析

2.1 异常日志打印哪些内容

19 节将以大量的篇幅来讲述日志相关的经验,相信大家看完下一节会多日志相关的问题有一个非常全面的认识。

先根据这个具体问题来聊下异常日志打印的问题,第一个问题如下:

有些我们catch到的异常,通过getMessage()的信息其实不是根因,一般对定位没大帮助,而是需要rootcase的getMessage(),我们还是看堆栈信息才能知道根因;这个应该怎么处理;


如果用一句话回答的话就是:排查问题本来就不是只根据 e.getMessage() 来处理,要分情况

实际开发中常处理的有两种异常,一种是自定义的业务异常,一种是其他异常。

通常业务异常的message带有对排查问题有帮助的信息,比如“该手机号已经注册”。

其他类型的异常,通常直接拿到 message 无法完全定位问题,还需要查看调用栈

在实际开发中 HTTP请求通常使用 Spring的 @RestControllerAdvice,RPC请求如果是dubbo 通常会返回之前包装一层


@Getter
public enum  ErrorEnum {
    // 省略具体定义
    ;

    private Integer code;

    private String desc;
}

包装成类似下面的结构

public class Result<T> {

    private Integer code;

    private String messge;

    private Boolean success;

    private T data;
}


如果是业务异常,则将捕获到的异常  e.getMessage() 值赋值给 message;

如果是其他异常,则直接返回一个字符串常量  如 “系统错误”。

不管是哪种异常类型,通常在统一处理的地方捕获以后都会将异常通过日志打印

log.error("某某业务异常了,囧...",e);

因此不管是业务异常还是其他异常,调用栈都会打印出来。

例如会有类似下面的代码逻辑,将异常进行封装:

@Slf4j
public class ResultUtil {

    public static <T> Result<T> generateResult(Callable<T> callable, ErrorEnum errorEnum) {
        Result<T> dataResult = new Result<>();
        try {
            // 构造返回值
            dataResult.setSuccess(true);
            dataResult.setData(callable.call());
            return dataResult;

        } catch (Exception e) {
            dataResult.setSuccess(false);
            if (e instanceof BizException) {
                log.warn(errorEnum.getDesc(), e);
                // 该异常特殊处理,抛出异常自带的状态码和消息
                int finalCode = errorEnum.getCode();
                String code = ((BizException) e).getCode();
                if (StringUtils.isNumeric(code)) {
                    finalCode = Integer.parseInt(code);
                }
                dataResult.setCode(finalCode);
                dataResult.setMessge(e.getMessage());
            } else {
                log.error(errorEnum.getDesc(), e);
                dataResult.setCode(-100);
                dataResult.setMessge("系统异常");
            }
            return dataResult;

        }
    }
}

注意这里只是一个参考形式,并不一定完全这么写。比如这里出现了魔法值,是不推荐的。


2.2  远程调用异常的处理

该同学会问题一个远程调用相关异常的处理问题。描述如下:

service层的代码担心有问题,可能就会通过catch(Exception e),用来规避影响正常流程的处理;专栏中给的异步处理可以解决这个问题,但像这种 "担心有问题"的代码(比如远程调用,查询数据库) 我都需要加try catch或异步处理么,这块有没有好的解决办法;

如果封装其他服务的调用(RPC),捕捉到异常时,打印日志要带上参数。

类似如下格式:

log.error("某某业务异常了,囧..,参数:{},返回值:{}.",param,result,e);

打印完日志以后通常有多种处理方式。

比如风控,底层挂掉了,应该降级,默认通过,可以构造一个通过的结果。

比如有些业务挂掉了,查询不到没啥影响,可以返回 null。

比如有的接口影响较大,直接打印下业务出现异常之后将异常扔出去。

try{

// 省略
}catch (Exception e) {
        log.error("某某业务异常了,囧..,参数:{},返回值:{}.",param,result,e);
        throw e;
    }

本质上还是顶层有统一的处理,所以不需要每一层都要处理异常,更不要轻易吞掉异常。


2.3 异步线程的异常处理

最后问了一个异步线程的异常处理问题,非常有代表性。可以看出该同学有一定的实际开发经历。

问题如下:

异步线程中的异常是不是没法往上抛(主线程),那是不是说异步线程中的异常只能该线程自己处理掉


先讲一下JVM 中异常处理的原理:

当前函数发生异常,如果有捕获对应有有处理函数,则直接调用处理函数处理。

如果没有捕获,则抛给父函数,如果有执行对应的处理函数,否则继续扔出。

如果最外层函数没有异常处理的代码,则当前线程就挂了。

所谓的往上抛是指抛给调用该函数的函数,而不是线程,对于当前线程来说如果最顶层没有处理异常的代码,该线程就挂了。


实际业务开发中,使用异步线程的主要方式有:线程池ExecutorService,通过CompletableFuture(本质也是线程池)或者异步注解(本质也是线程池)等方式。

异步任务通常不太需要将异常给父线程(不应该称为主线程,或者不一定是主线程)。

因为异步任务主要处理耗时的任务或者不重要的任务(允许失败),通常是可以忽略,重试等方案解决的,没必要让父线程感知。

通常异步任务的开始和结束位置会打印日志,输出任务的执行情况和重要信息


下面给出一些异步线程异常处理的方式,实际使用中用的不太多。


2.3.1 设置子线程异常处理方式(不是父线程处理异常)

父线程创建子线程时可以注册回调,参考代码如下:

public static void main(String[] args) throws InterruptedException {

    Thread thread = new Thread(() -> {
        throw new RuntimeException();
    });
    thread.setName("子线程");
    thread.setUncaughtExceptionHandler((t, e) -> {

        System.out.println(Thread.currentThread().getName() + "异常了,囧..");
    });

    thread.start();

    thread.join();
}

此时子线程发生异常后,可以通过上述方式捕捉并处理,其实处理线程依然是子线程,而不是主线程。


这种效果类似于我们下面的做法:

编写回调接口:

public interface CallBack {

    void callBack(Exception e);
}

编写子线程

public class ThreadWithCallable extends Thread {

    private CallBack callBack;

    public ThreadWithCallable(CallBack callBack) {
        this.callBack = callBack;
    }

    @Override
    public void run() {

        try {
            some();
        }catch (Exception e){
            callBack.callBack(e);
        }
    }

    private void some() {
        throw new RuntimeException("【异常内容】");
    }

}


编写调用函数

public class ThreadWithCallableDemo {

    public static void main(String[] args) throws InterruptedException {


        // 编写捕获异常的代码
        CallBack callBack = (Exception e) -> {
            System.out.println(Thread.currentThread().getName() + "捕捉到了异常" + e.toString());
        };

        // 将调用传入子线程
        Thread thread = new ThreadWithCallable(callBack);
        thread.setName("{子线程}");
        thread.start();

        thread.join();

    }
}


输出结果:

{子线程}捕捉到了异常java.lang.RuntimeException: 【异常内容】


2.3.2 CompletableFuture 中异常处理

CompletableFuture 可以参考下面的异常处理方式:

异步方式 :

public static void main(String[] args) {
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
        throw new RuntimeException("第一个异步任务出错了,囧..");
    })
            .exceptionally(ex -> {
                // 第一个任务的异常处理
                System.out.println(ex.getMessage());
                return "";
            })
            .thenApply(resultA -> resultA + " resultB")
            .thenApply(resultB -> resultB + " resultC")
            .thenApply(resultC -> resultC + " resultD");

    System.out.println(future.join());
}

第一个任务失败,直接返回空串。注意这里只是演示方便采用打印语句的方式,实际开发请采用日志框架。

输出结果:

java.lang.RuntimeException: 第一个异步任务出错了,囧..resultB resultC resultD

这里可以处理线程池中子线程的异常(注意这里一个exceptionally 只能捕捉到上一次异常,而不是之前所有)。


另外一种方式:

    CompletableFuture<String> future = CompletableFuture
            .supplyAsync(() -> "resultA")
            .thenApply(resultA -> resultA + " resultB")
            // 任务 C 抛出异常
            .thenApply(resultB -> {throw new RuntimeException();})
            // 处理任务 C 的返回值或异常
            .handle((re, throwable) -> {
                // 发生异常返回空串,否则返回原本的结果
                if (throwable != null) {
                    return "";
                }
                return re;
            })
            .thenApply(resultC -> resultC + " resultD");
System.out.println(future.join());


任务C 出现异常,处理方式是返回空串,后续任务继续组装。

输出结果:

resultD

还可以通过whenComplet 来注册回调函数

public static void main(String[] args) {
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
        System.out.println(Thread.currentThread().getName());
        // long running task
        return "task result";
    });

    future.whenComplete((result, exception) -> {
        System.out.println(Thread.currentThread().getName());
        if (null == exception) {
            System.out.println("result from previous task: " + result);
        }
    });
}

练习时,上面的例子大家可以通过  System.out.println(Thread.currentThread().getName()); 打印当前线程,也可通过断点调试机制,查看左下角的调用栈观察当前线程。

更多例子,可参考这里:

https://www.logicbig.com/tutorials/core-java-tutorial/java-multi-threading/completion-stages-exception-handling.html

CompletableFuture 通过一些手段处理将异常信息暴露到父线程中。

总之如果使用 CompletableFuture  请使用其自带的异常处理机制。


另外线程池可以重写 java.util.concurrent.ThreadPoolExecutor#afterExecute函数来处理异常。


希望大家平时学习时,能够主动动手写小DEMO,可以自主调试代码,养成这种学习和研究问题的习惯

希望本专栏带给大家的绝不仅仅是掌握了某个知识点,而更是某种学习习惯的养成。

3 总结

本文主要针对异常处理小节的学员疑问做出对应的解答,希望对大家学习和实际工作有些帮助。


如果本文对你有帮助,欢迎评论、点赞、转发,你的支持鼓励是我创作的最大动力。


另外想学习,更多开发和避坑技巧,少走弯路,请关注《阿里巴巴Java 开发手册》详解专栏

此专栏不是简单对手册的解答,更多地是Java的学习方法和开发经验的介绍。

如果你发现专栏对你帮助很大,欢迎推荐给身边的同学,朋友。

优惠期到2019年11月29日截止,12月1日恢复原价。






3人推荐
随时随地看视频
慕课网APP

热门评论

想问问老师,什么时候应该抛出业务异常,一般顶层都会有处理业务异常的方法,这样的话,在判空的时候,是不是可以直接抛义务异常呢?还有就是顶层既然有处理异常的方法,在service中调用rpc服务的时候为什么还要try catch呢

查看全部评论