异常处理方式的发展

web.xml

在还没有Spring的时候,开发使用的是原生的Servlet + tomcat容器。那个时候就提供了通用的异常的处理配置方式的:在web.xml里进行如下配置:

1
2
3
4
5
6
7
8
9
10
11
<!-- 根据状态码 -->
<error-page>
<error-code>500</error-code>
<location>/500.jsp</location>
</error-page>

<!-- 根据异常类型 -->
<error-page>
<exception-type>java.lang.RuntimeException</exception-type>
<location>/500.jsp</location>
</error-page>

SpringMVC

Spring MVC提供处理异常的方式主要分为两种:

  1. 实现HandlerExceptionResolver方式
  2. @ExceptionHandler注解方式。注解方式也有两种用法:
    1. 使用在Controller内部
    2. 配置@ControllerAdvice一起使用实现全局处理

这几种方式的优先级递减,被一个捕获处理了就不会去执行其他的了。

异常处理方式 说明
@Controller+@ExceptionHandler 单个 Controller 异常处理方法
@ControllerAdvice + @ExceptionHandler 全局 Controller 异常处理类
HandlerExceptionResolver 异常处理器实现类(内部有优先级规则)
error-page Tomcat 错误页

第一种方式:HandlerExceptionResolver

HandlerExceptionResolver是Spring提供的一种异常处理机制,它允许我们在应用程序中以统一的方式处理控制器方法引发的异常。

要使用HandlerExceptionResolver,我们需要创建一个实现该接口的类,并在其中定义如何处理异常。例如:

1
2
3
4
5
6
7
8
9
10
/**
* 全局异常处理
*/
@Order(-100) //优先级,越低优先级越高
public class ForumExceptionHandler implements HandlerExceptionResolver {

@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
}
}

其中:

  • @Order注解用于指定Spring 中组件的加载顺序。它接受一个整数值,数值越小,组件的优先级越高,加载顺序越靠前。
  • resolveException方法中,我们可以自定义异常处理逻辑,根据异常类型返回不同的ModelAndView。

可以看到,方法resolveException接收的参数是Exception类型,说明它能接收所有类型的异常,具体的处理逻辑由我们自己实现,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
//判断ex是什么异常,不同异常不同处理策略
if (ex instanceof ForumException) {
//自定义异常
} else if (ex instanceof AsyncRequestTimeoutException) {
//AsyncRequestTimeoutException异常
} else if (ex instanceof HttpMediaTypeNotAcceptableException) {
//HttpMediaTypeNotAcceptableException异常
} else {
//...
}
return null;
}

HandlerExceptionResolver 的工作原理主要基于Spring MVC的异常处理流程。当一个请求进入SpringMVC后,它会根据请求信息找到对应的处理器(handler,也就是Controller)。在Controller执行过程中,如果抛出了异常,Spring MVC就会启动异常处理流程。

  1. 异常发生:当Controller执行过程中抛出异常,Spring MVC捕获到这个异常后,会进入异常处理流程。
  2. 查找异常解析器: Spring MVC会遍历所有已注册的 HandlerExceptionResolver实现。比如说我们自定义的ForumExceptionHandler,Spring MVC本身也提供了一些默认的实现,比如
    DefaultHandlerExceptionResolver、ExceptionHandlerExceptionResolver。

8cea7214-417b-4557-bdeb-b3fe9de6a8e8

  1. 执行异常解析器:对于每个HandlerExceptionResolver实现,Spring MVC 会调用它的
    resolveException方法,并传入请求、响应、处理器和异常对象。如果解析器能处理这个异常,它会返回一个非空的ModelAndView对象。这个对象封装了异常处理后的视图和模型数据。
  2. 处理返回结果:当resolveException方法返回一个非空的ModelAndView对象时,Spring MVC会将这个对象用于生成最终的响应。可能渲染一个错误视图、设置响应状态码等。如果所有的
    HandlerExceptionResolver都无法处理这个异常(即都返回了空的ModelAndView对象),那么Spring MVC 会将异常重新抛出,以便其他异常处理器(如Servlet容器)进行处理。

通过这个流程,HandlerExceptionResolver能够在Spring MVC中统一管理和处理异常。最后记得在SpringBoot的启动类中将自定义的 HandlerExceptionResolver添加到Spring配置中。

1
2
3
4
5
6
public class QuickForumApplication implements WebMvcConfigurer, ApplicationRunner {
@Override
public void configureHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(0, new ForumExceptionHandler());
}
}

但是,HandlerExceptionResolver其实比较古老了,使用起来有一个很难受的地方。提个问题:

1
如何给前端返回一个json字符串呢?

观察方法resolveException的返回类型,发现是ModelAndView,这在前后端分离的项目中就有点难办了。大概有两种解决思路:

  1. response直接输出json:response.getWriter().print(jsonStr);
  2. 借助MappingJackson2JsonView

不过总归不是很舒服的,下面的第二种方式则没有这个问题了。

第二种方式:@ControllerAdvice + @ExceptionHandler

除了HandlerExceptionResolver,全局异常还可以采用@ControllerAdvice注解的方式。它可以将通用的操作和逻辑抽离出来,避免在每个控制器中重复相同的操作。

此注解是Spring 3.0后提供的处理异常的注解,整个Spring3.0+中新增了大量的能力来对REST应用提供支持,此注解便是其中之一。 它(只能)标注在方法上,可以使得这个方法成为一个异常处理器,处理指定的异常类型

1
2
3
4
5
6
7
8
// @since 3.0
@Target(ElementType.METHOD) // 只能标注在方法上
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ExceptionHandler {
// 指定异常类型,可以多个
Class<? extends Throwable>[] value() default {};
}

具体做法:新建一个全局异常控制器GlobalExceptionHandler,内容如下所示.

1
2
3
4
5
6
7
8
9
10
@RestControllerAdvice //捕获全局Controller层的异常,返回json格式字符串
public class GlobalExceptionHandler {

//@ExceptionHandler表示这个方法处理ForumAdviceException类型的异常
@ExceptionHandler(value = ForumAdviceException.class)
public ResVo<String> handleForumAdviceException(ForumAdviceException e) {
return ResVo.fail(e.getStatus());
}
//其他类型的异常...
}

相关的注解解释如下:

  • @ControllerAdvice 捕获 Controller 层抛出的异常,如果添加 @ResponseBody 返回信息则为JSON格式。

  • @RestControllerAdvice 相当于 @ControllerAdvice@ResponseBody 的结合体。

  • @ExceptionHandler 统一处理一种类的异常,减少代码重复率,降低复杂度。

OK,完事了,是不是很简单?

另外,要注意一点,ExceptionHandler也可以单独使用,这种情况下只对Controller本类生效。

  1. @ExceptionHandler只能标注在方法上。既能标注在Controller本类内的方法上(只对本类生效),也可配合@ControllerAdvice一起使用(对全局生效)
  2. 执行时的匹配顺序如下:优先匹配本类(本Controller),再匹配全局的。
  3. @ExceptionHandler的处理和执行是由本类完成的,同一个Class上的所有@ExceptionHandler方法对应着同一个ExceptionHandlerExceptionResolver,不同Class上的对应着不同的~

具体的源码解析可以参考:https://cloud.tencent.com/developer/article/1525171。

在项目中,通常的做法就是新建一个GlobalExceptionHandler(名字大同小异)的类,它的作用一般被标注上了@ControllerAdvice/@RestControllerAdvice用于处理全局异常。

@ControllerAdvice注解的其他作用

@ControllerAdvice,是Spring3.2提供的新注解,它是一个Controller增强器,可对controller中被 @RequestMapping注解的方法加一些逻辑处理。主要作用有一下三种

  • 通过@ControllerAdvice注解可以将对于控制器的全局配置放在同一个位置。
  • 注解了@ControllerAdvice的类的方法可以使用@ExceptionHandler、@InitBinder、@ModelAttribute注解到方法上。
    • @ExceptionHandler:用于全局处理控制器里的异常,进行全局异常处理
    • @InitBinder:用来设置WebDataBinder,用于自动绑定前台请求参数到Model中,全局数据预处理。
    • @ModelAttribute:本来作用是绑定键值对到Model中,此处让全局的@RequestMapping都能获得在此处设置的键值对 ,全局数据绑定。
  • @ControllerAdvice注解将作用在所有注解了@RequestMapping的控制器的方法上。

实现一个全局异常处理类

首先,自定义业务异常,该类中记录一个Status字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import lombok.Getter;

/**
* 业务异常
*/
public class ForumAdviceException extends RuntimeException {
@Getter
private Status status;

public ForumAdviceException(Status status) {
this.status = status;
}

public ForumAdviceException(int code, String msg) {
this.status = Status.newStatus(code, msg);
}

public ForumAdviceException(StatusEnum statusEnum, Object... args) {
this.status = Status.newStatus(statusEnum, args);
}
}

Status类包含两个变量codemsg,分别是业务状态码和描述信息。业务状态码可以自己传入时指定,也有一些规定,在StatusEnum枚举变量中定义,格式是业务 - 状态 - code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Status {

/**
* 业务状态码
*/
@ApiModelProperty(value = "状态码, 0表示成功返回,其他异常返回", required = true, example = "0")
private int code;

/**
* 描述信息
*/
@ApiModelProperty(value = "正确返回时为ok,异常时为描述文案", required = true, example = "ok")
private String msg;

public static Status newStatus(int code, String msg) {
return new Status(code, msg);
}

public static Status newStatus(StatusEnum status, Object... msgs) {
String msg;
if (msgs.length > 0) {
msg = String.format(status.getMsg(), msgs);
} else {
msg = status.getMsg();
}
return newStatus(status.getCode(), msg);
}

public static void main(String[] args) {
Status status = Status.newStatus(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "报错了", "666");
System.out.println(status);
}
}

StatusEnum类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import lombok.Getter;

/**
* 异常码规范:
* xxx - xxx - xxx
* 业务 - 状态 - code
* <p>
* 业务取值
* - 100 全局
* - 200 文章相关
* - 300 评论相关
* - 400 用户相关
* <p>
* 状态:基于http status的含义
* - 4xx 调用方使用姿势问题
* - 5xx 服务内部问题
* <p>
* code: 具体的业务code
*
*/
@Getter
public enum StatusEnum {
SUCCESS(0, "OK"),

// -------------------------------- 通用

// 全局传参异常
ILLEGAL_ARGUMENTS(100_400_001, "参数异常"),
ILLEGAL_ARGUMENTS_MIXED(100_400_002, "参数异常:%s"),

// 全局权限相关
FORBID_ERROR(100_403_001, "无权限"),

FORBID_ERROR_MIXED(100_403_002, "无权限:%s"),
FORBID_NOTLOGIN(100_403_003, "未登录"),

// 全局,数据不存在
RECORDS_NOT_EXISTS(100_404_001, "记录不存在:%s"),

// 系统异常
UNEXPECT_ERROR(100_500_001, "非预期异常:%s"),

// 图片相关异常类型
UPLOAD_PIC_FAILED(100_500_002, "图片上传失败!"),

// --------------------------------

// 文章相关异常类型,前缀为200
ARTICLE_NOT_EXISTS(200_404_001, "文章不存在:%s"),
COLUMN_NOT_EXISTS(200_404_002, "教程不存在:%s"),
COLUMN_QUERY_ERROR(200_500_003, "教程查询异常:%s"),
// 教程文章已存在
COLUMN_ARTICLE_EXISTS(200_500_004, "专栏教程已存在:%s"),
ARTICLE_RELATION_TUTORIAL(200_500_006, "文章已被添加为教程:%s"),

// --------------------------------

// 评论相关异常类型
COMMENT_NOT_EXISTS(300_404_001, "评论不存在:%s"),


// --------------------------------

// 用户相关异常
LOGIN_FAILED_MIXED(400_403_001, "登录失败:%s"),
USER_NOT_EXISTS(400_404_001, "用户不存在:%s"),
USER_EXISTS(400_404_002, "用户已存在:%s"),
// 用户登录名重复
USER_LOGIN_NAME_REPEAT(400_404_003, "用户登录名重复:%s"),
USER_PWD_ERROR(400_500_002, "用户名or密码错误");

private int code;

private String msg;

StatusEnum(int code, String msg) {
this.code = code;
this.msg = msg;
}

public static boolean is5xx(int code) {
return code % 1000_000 / 1000 >= 500;
}

public static boolean is403(int code) {
return code % 1000_000 / 1000 == 403;
}

public static boolean is4xx(int code) {
return code % 1000_000 / 1000 < 500;
}
}

返回给前端的结果类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.io.Serializable;

@Data
public class ResVo<T> implements Serializable {
private static final long serialVersionUID = -510306209659393854L;
@ApiModelProperty(value = "返回结果说明", required = true)
private Status status;

@ApiModelProperty(value = "返回的实体结果", required = true)
private T result;


public ResVo() {
}

public ResVo(Status status) {
this.status = status;
}

public ResVo(T t) {
status = Status.newStatus(StatusEnum.SUCCESS);
this.result = t;
}

public static <T> ResVo<T> ok(T t) {
return new ResVo<T>(t);
}

@SuppressWarnings("unchecked")
public static <T> ResVo<T> fail(StatusEnum status, Object... args) {
return new ResVo<>(Status.newStatus(status, args));
}

public static <T> ResVo<T> fail(Status status) {
return new ResVo<>(status);
}
}

有了上面的准备,我们就可以开始使用@ControllerAdvice写异常处理相关的逻辑了,新建GlobalExceptionHandler类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestControllerAdvice
public class GlobalExceptionHandler {

// 处理所有不可知的异常,作为全局的兜底
@ExceptionHandler(Exception.class)
public ResVo<String> handleException(Exception e) {
return ResVo.fail(StatusEnum.UNEXPECT_ERROR);
}

//处理所有业务异常(一般为手动抛出)
@ExceptionHandler(value = ForumAdviceException.class)
public ResVo<String> handleForumAdviceException(ForumAdviceException e) {
return ResVo.fail(e.getStatus());
}

//404 https://blog.csdn.net/z69183787/article/details/124470495
@ExceptionHandler(value = NoHandlerFoundException.class)
public ResVo<String> handleNoHandlerFoundException(NoHandlerFoundException e) {
return ResVo.fail(StatusEnum.RECORDS_NOT_EXISTS, e.getRequestURL());
}
}

最后,新建一个Controller,做个测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
@RequestMapping("test")
public class testControllerAdvice {

@RequestMapping("111")
public String testControllerAdvice(Model model) {
throw new ForumAdviceException(StatusEnum.ILLEGAL_ARGUMENTS_MIXED, "非法的邮箱接收人");
}

@RequestMapping("222")
public String testControllerAdvice2(Model model) {
throw new ForumAdviceException(StatusEnum.UNEXPECT_ERROR, "超时未登录");
}

@RequestMapping("333")
public String testControllerAdvice3(Model model) {
throw new ForumAdviceException(StatusEnum.COMMENT_NOT_EXISTS, "评论ID=1001");
}
}

浏览器访问http://localhost:8080/111111111111,结果如下,看起来表现良好,结束!

image-20240403202728015

参考