24.1 概述

在项目开发的过程中不可避免的需要处理各种异常。。面对可能出现的异常,常规的作法是使用大量的try {...} catch {...} finally {...} 代码块:

  1. try {
  2. //........
  3. } catch (Exception ex) {
  4. //........
  5. } finally {
  6. //........
  7. }

像这种每个异常都单独处理,不仅有大量的冗余代码,而且还影响代码的可读性和易维护性,程序之间的耦合增大了不少,并且一旦有遗漏就容易破坏系统的完整性,这些都是我们不希望看到的。

当发生异常时,如何更高效执行处理、友好提示用户、协助工程师快速定位,这些都是非常重要的需求。Spring Boot内置了一些机制来统一处理异常,但对于实际的项目开发需求还远远不够,我们既不希望业务代码显式地捕获和处理异常,也不希望把异常简单地抛给给用户,因此我们需要建立完善的 统一异常处理 机制来统一捕获并处理异常,对异常信息进行封装后传递给前端程序,由前端程序决定如何以友好的方式告知用户。

24.2 准备人为的异常

在讨论各种处理异常的方法之前,我们先准备几个人为产生异常的方法

  1. package com.longser.union.cloud.controller;
  2. import com.longser.union.cloud.data.model.UserEntry;
  3. import org.springframework.web.bind.annotation.GetMapping;
  4. import org.springframework.web.bind.annotation.RestController;
  5. @RestController
  6. public class ExceptionGenerator {
  7. @GetMapping("/test/div0")
  8. public String divBeZero() {
  9. int value = 1/0;
  10. return String.valueOf(value);
  11. }
  12. @GetMapping("/test/null")
  13. public void handleNullPointerException() {
  14. UserEntry u = null;
  15. u.getUserName();
  16. }
  17. }

有了上面的定义,当访问 localhost:8088/test/div0localhost:8088/test/null 两个地址会分别产生“被0除”和“空指针”的异常。

24.3 默认的异常处理机制

Spring有一个默认的异常处理机制,它用一个称为 Whitelabel 的页面来代替Tomcat的出错页面。

24.3.1 默认错误页面

在浏览器中访问一个不存在的页面,可以看到默认的 Whitelabel Error Page页面的样式。
image.png
访问我们前文定义的div0页面得到如下的结果
image.png
显然这个默认页面太单调,用户体验不好。既然这个页面告知我们可以通过映射 /error 来自己定义出错提示页面,那接下来就试验一下。

24.3.2 自定义的错误页面

尽管可以直接定义一个纯静态的出错页面,但用模板的方式设计提示页面会更灵活一些。为此,需要增加thymeleaf依赖:

  1. <dependency>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-thymeleaf</artifactId>
  4. </dependency>

然后在src/main/resources/templates 目录下创建 error.html 页面

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>出错提示</title>
</head>
<body>
    <h2>:-(</h2>
    <h3>服务器遇到一些麻烦</h3>
    <p th:text="${error}">Error Info</p>
    <p th:text="${status}">Status</p>
    <a href="/">回到软件首页</a>
</body>
</html>

重启应用之后,Spring将自动把所有的异常统一跳转到自定义的错误页面
image.png image.png
更复杂的,我们还可以为不同的响应代码定义专属出错页面,比如在 templates/error 目录下创建一个404.html,这个页面的优先级会更高一些,它将接管对“页面没有找到”的异常处理

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>出错提示</title>
</head>
<body>
    <h2>:-(</h2>
    <h3>你要访问的页面不见了</h3>
</body>
</html>

24.3.3 默认返回的JSON数据

如果用Postman这样的工具访问上面两个会出错的地址,可以看到服务器返回了JSON格式的数据

{
    "timestamp": "2021-08-20 13:05:30",
    "status": 404,
    "error": "Not Found",
    "path": "/no"
}

{
    "timestamp": "2021-08-20 13:05:58",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/div0"
}

尽管Spring 的默认错误页面能够实现全局的错误提示,但后端定义提示页面的方法不符合前后端分开的软件架构要求,固定化的JSON格式也我们的需求不一致,因此我们需要建立统一异常处理机制。

尽管Whitelabel可以通过设置 server.error.whitelabel.enabled=false 来关闭,不过这个不是非常必要。

24.4 Spring统一异常处理的原理

Spring在3.2版本增加了一个注解 @ControllerAdvice,可以与 @ExceptionHandler@InitBinder@ModelAttribute 等注解注解配套使用,协助我们实现统一异常处理。

不过跟异常处理相关的只有注解@ExceptionHandler,从字面上看,它就是 异常处理器 的意思,其实际作用也是这样:若在某个Controller类定义一个异常处理方法,并在方法上添加该注解,那么当出现指定的异常时,会执行该方法处理异常,这个方法可以使用Spring MVC提供的数据绑定,比如注入HttpServletRequest等,还可以接受一个当前抛出的Throwable对象。

但是,这样一来,就必须在每一个Controller类都定义一套这样的异常处理方法,因为异常可以是各种各样。这样一来,就会造成大量的冗余代码,而且若需要新增一种异常的处理逻辑,就必须修改所有Controller类了,很不优雅。

那就定义个类似BaseController基类是否可行呢?

这种做法虽然没错,但仍不够理想。因为这样的代码有一定的侵入性耦合性。原本简简单单的Controller,却要非得继承这样一个类。万一已经继承其他基类的话就更加麻烦,因为Java只能继承基类。

因此我们需要这样一种方案,既不需要跟Controller耦合,也可以将定义的 异常处理器 应用到所有控制器。

注解@ControllerAdvice就是为了解决这个问题而存在的。简单说来,该注解可以把异常处理器应用到所有控制器而非单个。使用了该注解的类可以统一对不同阶段的、不同异常类型进行影响处理。这就是统一异常处理的原理。

24.5 建立统一异常处理机制

24.5.1 目标

我们建立统一异常处理机制的目标是统一处理各种异常,消灭绝大多数的的 try catch 代码块,以优雅的 Assert(断言) 方式来校验业务的异常情况,重点关注业务逻辑,不在冗余的 try catch 代码块上花费大量精力。

24.5.2 异常事件分类

如果对异常事件按发生时所在阶段进行分类,大体可以分成进入Controller前的异常和 Service 层异常两大类,具体可以参考下图:
24. 建立统一异常处理机制 - 图6

24.5.3 辅助工作

1. 出错代码枚举类

首先我们定义一个与前端程序统一的出错代码枚举类

package com.longser.restful.result;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

public enum ResultCode {
    //枚举值与数字定义
    UNKNOWN(-1),
    FILE_TOO_LARGE(101),
    BAD_REQUEST(400),
    UNAUTHORIZED(401),
    FORBIDDEN(403),
    NOT_FOUND(404),
    METHOD_NOT_ALLOWED(405),
    NOT_ACCEPTABLE(406),
    UNSUPPORTED_MEDIA_TYPE(415),
    INTERNAL_SERVER_ERROR(500),
    SERVICE_UNAVAILABLE(503),
    ;

    private final int code;

    ResultCode(int code) {
        this.code = code;
    }

    @JsonValue
    public Integer getCode() {
        return this.code;
    }

    @JsonCreator
    public static ResultCode fromCode(int code) {
        ResultCode[] values = ResultCode.values();
        for (ResultCode value : values) {
            if (value.code == code) {
                return value;
            }
        }
        return UNKNOWN;
    }
}

然后给 com.longser.restful.result.RestfulResult 类增加下面的方法

    public static <T> RestfulResult<T> fail(ResultCode code, String message) {
        return new RestfulResult<>(code.getCode(), message);
    }

2. 异常与状态对应关系类

创建一个类管理异常和HTPP状态的对应关系

package com.longser.restful.exception;

import org.springframework.http.HttpStatus;

import java.util.HashMap;
import java.util.Map;

/**
 * 自定义的异常与 HTTP STATUS 对应关系
 * @author David Jia
 */
public class ExceptionStatus {
    private static final Map<String, HttpStatus> RESSULT_STATUS = new HashMap<>(15);

    private static void put(String key, HttpStatus status) {
        RESSULT_STATUS.put(key, status);
    }

    public static HttpStatus statusOf(Exception ex) {
        HttpStatus httpStatus = RESSULT_STATUS.get(ex.getClass().getSimpleName());

        if(null == httpStatus) {
            httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
        }

        return httpStatus;
    }

    static {
        put("MaxUploadSizeExceededException",HttpStatus.SEE_OTHER);
        put("AsyncRequestTimeoutException", HttpStatus.SERVICE_UNAVAILABLE);
        put("HttpRequestMethodNotSupportedException", HttpStatus.METHOD_NOT_ALLOWED);
        put("HttpMediaTypeNotSupportedException", HttpStatus.UNSUPPORTED_MEDIA_TYPE);
        put("HttpMediaTypeNotAcceptableException", HttpStatus.NOT_ACCEPTABLE);
        put("MissingPathVariableException", HttpStatus.INTERNAL_SERVER_ERROR);
        put("MissingServletRequestParameterException", HttpStatus.BAD_REQUEST);
        put("ServletRequestBindingException", HttpStatus.BAD_REQUEST);
        put("ConversionNotSupportedException", HttpStatus.INTERNAL_SERVER_ERROR);
        put("TypeMismatchException", HttpStatus.BAD_REQUEST);
        put("HttpMessageNotReadableException", HttpStatus.BAD_REQUEST);
        put("HttpMessageNotWritableException", HttpStatus.INTERNAL_SERVER_ERROR);
        put("MethodArgumentNotValidException", HttpStatus.BAD_REQUEST);
        put("MissingServletRequestPartException", HttpStatus.BAD_REQUEST);
        put("ConstraintViolationException", HttpStatus.BAD_REQUEST);
        put("BindException", HttpStatus.BAD_REQUEST);
        put("NoHandlerFoundException", HttpStatus.NOT_FOUND);
        put("AccessDeniedException", HttpStatus.FORBIDDEN);
    }
}

3. 异常与消息对应关系类

再创建一个类管理异常和提示信息的对应

package com.longser.restful.exception;

import java.util.HashMap;
import java.util.Map;

/**
 * 自定义的异常说明
 * @author David Jia
 */
public class ExceptionMessage {
    private static final Map<String, String> DESCRIPTION   = new HashMap<>(15);
    private static final char LINE_FEED = '\n';

    private static void put(String key, String message) {
        DESCRIPTION.put(key, message);
    }

    public static String messageOf(Exception ex) {

        String message = DESCRIPTION.get(ex.getClass().getSimpleName());

        // 如果没有特别指定,则使用当前 exception 中的信息
        if(null == message) {
            message = getOriginalMessage(ex);
        }

        return message;
    }

    public static String getOriginalMessage(Exception ex) {
        String message = ex.getLocalizedMessage();

        if(message == null) {
            message = ex.toString();
        }
        if(message.indexOf(LINE_FEED) > 0) {
            message = message.substring(message.indexOf(LINE_FEED) + 1).trim();
        } else {
            message = message.substring(message.indexOf(':') + 1).trim();
        }

        return message;
    }

    static {
        put("MaxUploadSizeExceededException", "上传的文件太大");
        put("ArrayIndexOutOfBoundsException", "数组下标越界异常");
        put("IndexOutOfBoundsException", "数组下标越界");
        put("NegativeArraySizeException", "数组负下标异常");
        put("IllegalAccessException", "没有访问权限");
        put("NullPointerException","遇到了空指针");
        put("StackOverflowError", "堆栈溢出错误");
        put("OutOfMemoryError", "内存不足");
        put("IOException", "输入输出异常");
        put("SQLException", "操作数据库异常");
        put("SQLTimeoutException", "SQL执行超时");
        put("AccessDeniedException", "没有访问权限");
    }
}

4. 异常与代码对应关系类

再创建一个类管理异常和出错代码的对应

package com.longser.restful.exception;

import com.longser.restful.result.ResultCode;

import java.util.HashMap;
import java.util.Map;

/**
 * 自定义的异常与 ResultCode 对应关系,也就是
 * @author David Jia
 */
public class ExceptionCode {
    private static final Map<String, ResultCode> RESULT_CODE = new HashMap<>(5);

    private static void put(String key, ResultCode code) {
        RESULT_CODE.put(key, code);
    }

    /**
     * 如果为当前的 Exception 定义了对应关系,则用预定义的 ResultCode,
     * 否则尝试使用这个 Exception 对应的 HttpStatus
     *
     * @param ex 要查询的 Exception
     * @return 对应的 ResultCode,会被放到 RestfulResult.errorCode
     */
    public static ResultCode codeOf(Exception ex) {

        ResultCode resultCode = RESULT_CODE.get(ex.getClass().getSimpleName());

        if(null == resultCode) {
            resultCode = ResultCode.fromCode(ExceptionStatus.statusOf(ex).value());
        }

        return resultCode;
    }

    static {
        put("MaxUploadSizeExceededException", ResultCode.FILE_TOO_LARGE);
    }
}

24.5.4 统一异常处理器

其实我们在讨论管理上传的文件时就用 @ControllerAdvice 和 @ExceptionHandler 两个注解组合定义了一个全局的异常处理器:

@ControllerAdvice
public class FileUploadExceptionAdvice extends ResponseEntityExceptionHandler {

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResponseEntity<QueryResult> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e){
        return ResponseEntity.badRequest().body(new FailResult("Upload file too large."));
    }
}

现在我们删除这个类定义,然后定义一个更加正式、通用的统一异常处理器类:

package com.longser.restful.exception;

import com.longser.restful.result.RestfulResult;
import com.longser.restful.result.ResultCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import java.util.Arrays;

/**
 * 全局统一的异常处理
 * @author David Jia
 */
@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final Log LOGGER =
            LogFactory.getLog(GlobalExceptionHandler.class);

    private static final String[] SIMPLE_EXCEPTIONS = {
            "HttpRequestMethodNotSupportedException",
            "MaxUploadSizeExceededException",
            "AccessDeniedException",
    };

    // 当可能频繁使用contains()方法时,要使用HashSet而不是List,因为它们的效率差很多
    private static final Set<String> simpleExceptionSet = new HashSet<>(Arrays.asList(SIMPLE_EXCEPTIONS));

    private static boolean isSimpleException(Exception ex) {
        return simpleExceptionSet.contains(ex.getClass().getSimpleName());
    }

    private ResponseEntity<RestfulResult<String>> exceptionResult(Exception ex) {
        // 这里的逻辑:如果没有指定message,那么先查表,表里没有的时候就用 exception 携带的
        return exceptionResult(ex, ExceptionMessage.messageOf(ex));
    }

    private ResponseEntity<RestfulResult<String>> exceptionResult(Exception ex, String message) {
        return exceptionResult(ex,
                ExceptionStatus.statusOf(ex),
                message,
                ExceptionCode.codeOf(ex)
        );
    }

    private ResponseEntity<RestfulResult<String>> exceptionResult(
            Exception ex,
            HttpStatus status,
            String message,
            ResultCode code
    ) {
        LOGGER.warn("[Exception] " + ex.getClass().getSimpleName() + " :" +
                ExceptionMessage.getOriginalMessage(ex));

        if(!isSimpleException(ex)) {
            ex.printStackTrace();
        }

        return new ResponseEntity<>(RestfulResult.fail(code, message), status);
    }

    /**
     * 未具体分类的异常
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<RestfulResult<String>> handleOtherException(Exception ex) {
        return exceptionResult(ex);
    }
}

通过 @ExceptionHandler 注解的参数可以指定需要捕获什么样的异常。这里原则是按照从小到大异常进行依次执行。通俗来讲就是当小的异常没有指定捕获时,大的异常包含了此异常就会被执行。尤其Exception 异常包含了所有异常类,是所有异常超级父类,当出现没有指定异常时此时对应捕获了Exception异常的方法会执行。

下面是用 Postman 访问 http://localhost:8088/test/div0时传递到前台的数据以及后台日志的输出:
image.png
image.png

下面是用 Postman 访问 http://localhost:8088/test/null 时传递到前台的数据以及后台日志的输出:
image.png
image.png
Post方式发出请求,返回的状态是405 Method Not Allowed
image.png

24.5.5 没有找到页面的特殊处理

访问一个不存在页面,会发现全局异常处理并没有工作
image.png
出现这种结果的原因在于默认的情况下,Sping自己接管了 404 Not Found 异常的处理权限,或者说把自己的异常处理器权限设置为最高级别。为了实现我们统一管理异常的目标,需要关闭默认的处理规则,在application.yaml中定义如下的属性:

spring:
  mvc:
    throw-exception-if-no-handler-found: true
  web:
    resources:
      add-mappings: false

重启应用后再次访问不存在的页面,此时得到的就是我们期望的结果了
image.png

24.5.6 定义局部异常处理特例

在定义全局的统一异常处理机制之后,我们仍旧可以定义仅局部有效的异常处理器。局部处理器的优先级高于全局的。

    @GetMapping("/test/div0")
    public String divBeZero() {
        int value = 1/0;

        return String.valueOf(value);
    }

    @ExceptionHandler(value = { java.lang.ArithmeticException.class })
    public <T> RestfulResult<T> arithmeticExceptionHandler(Exception e) {
        return RestfulResult.fail(ResultCode.INTERNAL_SERVER_ERROR, "这个是局部的出错提示");
    }

上面 arithmeticExceptionHandler 方法前使用了 @ExceptionHandler 注解,但是他自身和所在类都没有 @RestControllerAdvice 注解,因此他是一个局部的异常处理器,仅处理所在类各方法产生的异常。

此时用再浏览器访问产生 被0除 的 API,可以看到得到的信息来自局部处理器

image.png
24.6 优雅地抛出异常

应用程序产生的异常通常有两种来源:一种是在程序的运行过程中中由调用的库代码抛出,另外一种是由我们自己的代码抛出。

当我们在自己的代码中需要抛出异常的时候,尽管使用 throw 可以完成这个任务,但这种基础的方式会让代码变得比较繁琐,因此我们再本节讨论一下让抛异常这件事更加优雅。

24.6.1 用 Assert 代替 throw

下面是一种非常常见的根据逻辑校验结果抛出异常的作法:

    UserEntry user = userDao.selectById(userId);
    if (user == null) {
        throw new IllegalArgumentException("用户不存在。");
    }
    if (user.isDeleted) {
        throw new IllegalArgumentException("用户已经被逻辑删除。");
    }

尽管这种方式没有本质上的问题,但他使得代码过于复杂和冗长,我们很有必要把这个逻辑判断、抛出异常的过程封装起来。

感谢 Assert 类,他使得我们不必自己从头做这个封装工作。

我们在编写单元测试程序的时候已经用过 org.junit.Assert 类。 在常规开发中,我们应该使用 Spring 家族的Assert类,即 org.springframework.util.Assert。使用这些类有助于我们更加优雅地执行逻辑判断和抛出异常。

下面是使用 org.springframework.util.Assert 之后的代码:

    UserEntry user = userDao.selectById(userId);
    Assert.notNull(user, "用户不存在。");
    Assert.isTrue(!user.isDeleted, "用户已经被逻辑删除。");


这个Assert.notNull() 背后到底做了什么呢?下面是 Assert 的部分源码:

    public static void notNull(@Nullable Object object, String message) {
        if (object == null) {
            throw new IllegalArgumentException(message);
        }
    }

    public static void isTrue(boolean expression, String message) {
        if (!expression) {
            throw new IllegalArgumentException(message);
        }
    }

可以看到,Assert 其实就是帮我们把逻辑判断和抛出异常封装了一下,虽然很简单,但不可否认的是编码效率和质量提升了一个档次。

24.6.2 Assert类简要说明

org.springframework.util.Assert类中方法支持三种重载

  • isTrue(boolean expression) :单一布尔,方法内置断言信息(官方已经不再推荐使用此类方法)
  • isTrue(boolean expression, String message) :布尔类型与字符串类型的断言信息
  • isTrue(boolean expression, Supplier messageSupplier) :布尔类型与容器类型的断言信息

我们最常用的是第二种,下面是此种类型的方法:

// 断言表达式为真
void isTrue(boolean expression, String message)

// 断言对象为空
void isNull(@Nullable Object object, String message)

// 断言对象不为空
void notNull(@Nullable Object object, String message)

// 断言字符串有长度
void hasLength(@Nullable String text, String message)

// 断言字符串有文本内容
void hasText(@Nullable String text, String message)

// 断言给定的文本不包含给定的子字符
void doesNotContain(@Nullable String textToSearch, String substring, String message)

// 断言不为空(至少包含一个元素)
void notEmpty(@Nullable Object[] array, String message)
void notEmpty(@Nullable Collection<?> collection, String message) 
void notEmpty(@Nullable Map<?, ?> map, String message) 

// 断言所有元素都不是null
void noNullElements(@Nullable Object[] array, String message) 
void noNullElements(@Nullable Collection<?> collection, String message)

// 断言所提供的对象是所提供类的实例
void isInstanceOf(Class<?> type, @Nullable Object obj, String message)

// 断言subType 可以按类型匹配于superType
void isAssignable(Class<?> superType, @Nullable Class<?> subType, String message)

Spring Assert的API特点:

  • 均为public static方法
  • 抛出IllegalArgumentException 或 IllegalStateException异常

    24.6.3 不要用assert语句

    Java1.4后新引入了一个新的关键字语句 assert,它的格式是后面紧跟着用冒号分开的逻辑表达式和字符串:
    assert user : "用户不存在。"
    
    当逻辑表达式不成立(断言失败)时,会抛出携带该字符串的AssertionError异常。

需要说明的是,不管在常规代码还是单元测试中,我们都 绝对不要 使用这个 assert 关键字语句,因为它在抛出AssertionError之后会让整个程序结束并退出。因为这个它的危害比好处要大很多,因此JVM默认是关闭该断言语句的(即遇到assert语句会自动忽略了而不执行)。要执行让他生效,必须给Java虚拟机传递 -enableassertions参数(简写为-ea)启用。

本来不想讨论这个,但为了防止有人不明所以乱用这个语句,所以还是决定正式的解释一下。总之,不要用!

版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。