24.1 概述
在项目开发的过程中不可避免的需要处理各种异常。。面对可能出现的异常,常规的作法是使用大量的try {...} catch {...} finally {...}
代码块:
try {
//........
} catch (Exception ex) {
//........
} finally {
//........
}
像这种每个异常都单独处理,不仅有大量的冗余代码,而且还影响代码的可读性和易维护性,程序之间的耦合增大了不少,并且一旦有遗漏就容易破坏系统的完整性,这些都是我们不希望看到的。
当发生异常时,如何更高效执行处理、友好提示用户、协助工程师快速定位,这些都是非常重要的需求。Spring Boot内置了一些机制来统一处理异常,但对于实际的项目开发需求还远远不够,我们既不希望业务代码显式地捕获和处理异常,也不希望把异常简单地抛给给用户,因此我们需要建立完善的 统一异常处理 机制来统一捕获并处理异常,对异常信息进行封装后传递给前端程序,由前端程序决定如何以友好的方式告知用户。
24.2 准备人为的异常
在讨论各种处理异常的方法之前,我们先准备几个人为产生异常的方法
package com.longser.union.cloud.controller;
import com.longser.union.cloud.data.model.UserEntry;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ExceptionGenerator {
@GetMapping("/test/div0")
public String divBeZero() {
int value = 1/0;
return String.valueOf(value);
}
@GetMapping("/test/null")
public void handleNullPointerException() {
UserEntry u = null;
u.getUserName();
}
}
有了上面的定义,当访问 localhost:8088/test/div0
和 localhost:8088/test/null
两个地址会分别产生“被0除”和“空指针”的异常。
24.3 默认的异常处理机制
Spring有一个默认的异常处理机制,它用一个称为 Whitelabel 的页面来代替Tomcat的出错页面。
24.3.1 默认错误页面
在浏览器中访问一个不存在的页面,可以看到默认的 Whitelabel Error Page页面的样式。
访问我们前文定义的div0页面得到如下的结果
显然这个默认页面太单调,用户体验不好。既然这个页面告知我们可以通过映射 /error
来自己定义出错提示页面,那接下来就试验一下。
24.3.2 自定义的错误页面
尽管可以直接定义一个纯静态的出错页面,但用模板的方式设计提示页面会更灵活一些。为此,需要增加thymeleaf依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</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将自动把所有的异常统一跳转到自定义的错误页面
更复杂的,我们还可以为不同的响应代码定义专属出错页面,比如在 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.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时传递到前台的数据以及后台日志的输出:
下面是用 Postman 访问 http://localhost:8088/test/null 时传递到前台的数据以及后台日志的输出:
用Post方式发出请求,返回的状态是405 Method Not Allowed
24.5.5 没有找到页面的特殊处理
访问一个不存在页面,会发现全局异常处理并没有工作
出现这种结果的原因在于默认的情况下,Sping自己接管了 404 Not Found 异常的处理权限,或者说把自己的异常处理器权限设置为最高级别。为了实现我们统一管理异常的目标,需要关闭默认的处理规则,在application.yaml中定义如下的属性:
spring:
mvc:
throw-exception-if-no-handler-found: true
web:
resources:
add-mappings: false
重启应用后再次访问不存在的页面,此时得到的就是我们期望的结果了
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,可以看到得到的信息来自局部处理器
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,它的格式是后面紧跟着用冒号分开的逻辑表达式和字符串:
当逻辑表达式不成立(断言失败)时,会抛出携带该字符串的AssertionError异常。assert user : "用户不存在。"
需要说明的是,不管在常规代码还是单元测试中,我们都 绝对不要 使用这个 assert 关键字语句,因为它在抛出AssertionError之后会让整个程序结束并退出。因为这个它的危害比好处要大很多,因此JVM默认是关闭该断言语句的(即遇到assert语句会自动忽略了而不执行)。要执行让他生效,必须给Java虚拟机传递 -enableassertions参数(简写为-ea)启用。
本来不想讨论这个,但为了防止有人不明所以乱用这个语句,所以还是决定正式的解释一下。总之,不要用!
版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。