Spring Boot 优雅捕捉异常的几种姿势!

01、背景介绍

在上文中,我们介绍了在 Spring Boot 中实现接口数据格式的统一返回处理实现,其中就包括程序运行时的异常处理,通过全局异常处理器,可以简化代码逻辑,统一响应格式。

其实在 Spring Boot 中,针对controller层的异常处理有很多种办法。今天通过这篇文章,我们就一起来总结一下相关异常处理的实现方式。

02、方案实践

在 Spring Boot 中针对controller层的异常处理,有两种常用实现方式,都可以达到简化代码逻辑的效果。

  • 方式一:通过@ControllerAdvice@ExceptionHandler注解实现全局异常的处理
  • 方式二:通过实现HandlerExceptionResolver接口来完成全局异常的处理

下面我们一起来看看具体实现。

2.1、全局异常处理方式一

通过@ControllerAdvice@ExceptionHandler注解实现全局异常拦截,在之前的文章中我们有多次介绍过,它可以拦截controller层请求方法抛出的异常信息,同时外加@ ResponseBody注解,可以实现响应类型为json格式。

例如,现在有两种异常类型NullPointerExceptionException,分别对其进行捕捉,具体实现如下!

@ControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    /**
     * 优先处理空指针异常
     * @param e
     * @return
     */
    @ExceptionHandler(value = {NullPointerException.class})
    @ResponseBody
    public Object nullPointerExceptionHandler(HttpServletRequest request, NullPointerException e){
        LOGGER.error("发生空指针异常,请求地址:{}, 错误信息:{}", request.getRequestURI(), e.getMessage());
        return ResultMsg.fail(500, e.getMessage());
    }
    /**
     * 兜底处理其它异常
     * @param e
     * @return
     */
    @ExceptionHandler(value = {Exception.class})
    @ResponseBody
    public Object exceptionHandler(HttpServletRequest request, Exception e){
        LOGGER.error("未知异常,请求地址:{}, 错误信息:{}", request.getRequestURI(), e.getMessage());
        return ResultMsg.fail(999, e.getMessage());
    }
}

测试代码,如下:

@RestController
public class HelloController {
    @GetMapping(value = "/add")
    public String hello(){
        if(1 ==1){
            throw new NullPointerException("空指针测试");
        }
        return "hello world";
    }
    @GetMapping(value = "/delete")
    public String delete(){
        if(1 ==1){
            throw new RuntimeException("其它测试");
        }
        return "hello world";
    }
}

启动服务后,在浏览器中请求http://localhost:8080/add,结果如下:

请求http://localhost:8080/delete,结果如下:

结果与预期一致。

2.1.1、自定义异常类实现

很多场景下,我们希望通过自定义异常类来返回相关错误信息,如何实现呢?

首先自定义一个异常类CustomerException

public class CustomerException extends RuntimeException {
    private Integer code;
    public Integer getCode() {
        return code;
    }
    public CustomerException(String message) {
        super(message);
        this.code = 500;
    }
    public CustomerException(Integer code, String message) {
        super(message);
        this.code = code;
    }
}

然后,在全局异常处理器中增加相关的捕捉方法。

/**
 * 处理自定义的异常
 * @param e
 * @return
 */
@ExceptionHandler(value = {CustomerException.class})
@ResponseBody
public Object customerExceptionHandler(HttpServletRequest request, CustomerException e){
    LOGGER.error("发生业务异常,请求地址:{}, 错误信息:{}", request.getRequestURI(), e.getMessage());
    return ResultMsg.fail(e.getCode(), e.getMessage());
}

测试代码,如下:

@GetMapping(value = "/update")
public String update(){
    if(1 ==1){
        throw new CustomerException(4003, "请求ID不能为空");
    }
    return "hello world";
}

启动服务后,在浏览器中请求http://localhost:8080/update,结果如下:

结果与预期一致!

2.1.2、404 异常特殊处理

默认情况下,@ExceptionHandler注解无法捕捉到 404 异常,比如请求一个无效的地址,返回信息如下:

如果想要捕捉到这种异常,可以在application.properties文件中添加如下配置来实现。

# 如果没有找到请求地址,抛异常
spring.mvc.throw-exception-if-no-handler-found=true
# 关闭默认的静态资源路径映射
spring.resources.add-mappings=false

启动服务后,再次发起地址请求,结果如下:

对于前后端分离开发的情况,这种方式非常实用;但是如果前后端不分离的项目,比如访问项目/static目录下的静态资源,可能前端无法正常访问。

此时,我们可以手动添加资源映射,比如如下操作,前端就能正常访问静态资源了。

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // 允许访问localhost:8080/static/目录下的静态资源
        registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
    }
}
2.1.3、自定义异常页面实现

某些场景下,当发生异常时希望跳转到自定义的异常页面,如何实现呢?

首先,这里基于thymeleaf模板引擎来开发页面,在templates目录下创建一个异常页面error.html

<!DOCTYPE html>
<html lang="en" >
<head>
    <meta charset="UTF-8">
    <title>错误页面</title>
</head>
<body>
出错啦,请与管理员联系<br>
</body>
</html>

然后,修改异常捕捉方法,这里无需添加@ResponseBody注解,示例如下。

@ControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    /**
     * 跳转到异常页面
     * @param e
     * @return
     */
    @ExceptionHandler(value = {Exception.class})
    public ModelAndView exceptionHandler(HttpServletRequest request, Exception e){
        LOGGER.error("未知异常,请求地址:{}, 错误信息:{}", request.getRequestURI(), e.getMessage());
        ModelAndView mv = new ModelAndView();
        // 添加错误信息对象
        mv.addObject("message", e.getMessage());
        // 要跳转的页面视图
        mv.setViewName("error");
        return mv;
    }
}

启动服务后,在浏览器中再次请求http://localhost:8080/update,结果如下:

结果与预期一致!

2.1.4、RestControllerAdvice和ControllerAdvice的区别

很多同学喜欢用@RestControllerAdvice来代替@ControllerAdvice@ResponseBody

例如如下示例!

@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    /**
     * 处理所有异常,以json方式响应
     * @param e
     * @return
     */
    @ExceptionHandler(value = {Exception.class})
    public Object exceptionHandler(HttpServletRequest request, Exception e){
        LOGGER.error("未知异常,请求地址:{}, 错误信息:{}", request.getRequestURI(), e.getMessage());
        return ResultMsg.fail(999, e.getMessage());
    }
}

实现效果,与上文等价。

打开@RestControllerAdvice的源码,你会发现它将@ControllerAdvice@ResponseBody注解组合在一起了,因此同时具备两者效果,部分源码如下:

package org.springframework.web.bind.annotation;
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
    @AliasFor("basePackages")
    String[] value() default {};
    @AliasFor("value")
    String[] basePackages() default {};
    Class<?>[] basePackageClasses() default {};
    Class<?>[] assignableTypes() default {};
    Class<? extends Annotation>[] annotations() default {};
}

2.2、全局异常处理方式二

在 Spring Boot 中,除了通过@ControllerAdvice@ExceptionHandler注解实现全局异常处理外,还有一种通过实现HandlerExceptionResolver接口来完成全局异常的处理。

具体实现示例如下:

@Component
public class CustomExceptionResolver implements HandlerExceptionResolver {
    private static final Logger LOGGER = LoggerFactory.getLogger(CustomExceptionResolver.class);
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception e) {
        LOGGER.error("接口请求出现异常,请求地址:{},错误信息:{}", request.getRequestURI(), e.getMessage());
        if(e instanceof RuntimeException){
            // 设置响应类型为json格式
            ModelAndView mv = new ModelAndView(new MappingJackson2JsonView());
            mv.addObject("code", 500);
            mv.addObject("msg", e.getMessage());
            return mv;
        } else {
            // 设置响应类型为错误页面
            ModelAndView mv = new ModelAndView();
            mv.addObject("message", e.getMessage());
            mv.setViewName("error");
            return mv;
        }
    }
}

当出现异常的时候,结果会以json格式响应给客户端。

启动服务后,发起地址请求,结果如下:

这种思路的实现原理,主要是通过 SpringMVC 的异常处理链路器来完成异常的全局处理。

SpringMVC 支持用户自定义异常处理类(需要实现HandlerExceptionResolver),当发生异常时,默认异常处理类无法处理时,就会交给自定义异常处理类来完成。实现方面比较灵活,即可以实现以json格式响应,也可以以页面视图的方式响应。

虽然这种方式能够处理全局异常,但是 Spring 官方不推荐使用它;同时实测过程中发现它无法拦截 404 错误,当请求错误地址时,会优先被DefaultHandlerExceptionResolver默认异常处理类拦截,自定义的异常处理类无法捕捉。

03、小结

最后总结一下,虽然方式一和方式二都可以实现controller层接口请求异常的全局处理,但是在实际使用中,推荐方式一,简单好维护。

示例代码地址:

https://gitee.com/pzblogs/spring-boot-example-demo
3