Spring Boot - 全局异常捕获

[TOC]

前言

在 Spring Boot 中,当用户访问一个不存在的页面,或者我们的应用(确切地说是Controller)抛出异常时,会默认返回如下内容:

$ curl http://localhost:8080

{
    "timestamp": "2020-05-28T09:43:43.820+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "",
    "path": "/"
}

这里的实现机制是 Spring Boot 默认创建一个自动配置类ErrorMvcAutoConfiguration,该类会创建一个全局异常控制器BasicErrorController,它被映射到路由/error,该路由会处理所有的错误信息,并返回响应。具体如下:

package org.springframework.boot.autoconfigure.web.servlet.error;

//...

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {

    //...
    // 浏览器访问,返回 text/html 页面
    @RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
    public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
        HttpStatus status = getStatus(request);
        Map model = Collections
                .unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
        response.setStatus(status.value());
        ModelAndView modelAndView = resolveErrorView(request, response, status, model);
        return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
    }

    // 客户端访问,返回 JSON 数据
    @RequestMapping
    public ResponseEntity> error(HttpServletRequest request) {
        //...
        Map body = getErrorAttributes(request,
                isIncludeStackTrace(request, MediaType.ALL));
        HttpStatus status = getStatus(request);
        return new ResponseEntity<>(body, status);
    }

如果我们不想要上述默认输出信息,那么我们就需要对 Spring Boot 进行全局异常捕获处理。

内置异常捕获指令

Spring 发展到现在,内置了多种异常捕获指令,常见的有如下几种:

  • @ExceptionHandler:只能用于捕获特定Controller抛出的异常,无法进行全局(所有Controller)捕获。
@Controller
public class FooController{
    //...
    @ExceptionHandler({ CustomException1.class, CustomException2.class })
    public void handleException() {
       //
    }
}

:我们可以通过定义一个父类Controller关联该@ExceptionHandler,这样应用中其他继承该父类Controller抛出的异常就都能被捕获到,然而,这种做法还是存在缺陷,其对于第三方库中的异常无法进行捕获。

  • @ResponseStatus:可以在类或方法上使用该注解,当与其绑定的异常发生时,会被拦截到并按照它的注解配置转换为响应。比如:
@ResponseStatus(value=HttpStatus.NOT_FOUND, reason="No such Order")  // 404
public class OrderNotFoundException extends RuntimeException {
    // ...
}

上述代码会对OrderNotFoundException进行捕获,并将响应行内容设置为404 not found。但@ResponseStatus的一个弊端就是它与特定异常类是紧耦合关系,每个响应内容只能应用于一个异常类中,其他的异常类需要重新配置另外的@ResponseStatus注解。

  • ResponseStatusExceptionResponseStatusException是 Spring 5 才引入进来的,是一个很新的 api,本质上它是一个RuntimeExceptionResponseStatusException相对@ResponseStatus注解来说更加方便,因为其可以直接创建出不同的响应状态码,其构造函数第一个参数为HttpStatus,该枚举类内置了很多常见的状态码及其描述,开箱即用。比如:
// Controller
@GetMapping("/actor/{id}")
public String getActorName(@PathVariable("id") int id) {
    throw new ResponseStatusException( HttpStatus.NOT_FOUND, "Actor Not Found", ex);
}
  • HandlerExceptionResolver:其是一个接口,具备全局异常捕获能力。通过实现该接口,我们就可以维护一个统一的异常处理机制。Spring 内置了多个实现该接口的实现类,比如:

    • ExceptionHandlerExceptionResolver:SpringMVC 中的DispatcherServlet默认使能该接口,该实现类会捕获被@ExceptionHandler注解的方法抛出的异常。

    • ResponseStatusExceptionResolver:SpringMVC 中的DispatcherServlet同样默认使能该接口,该实现类能够捕获ResponseStatusException和被@ResponseStatus注解的异常,同样转换为对应的 HTTP 状态码。比如:

    @ResponseStatus(value = HttpStatus.NOT_FOUND)
    public class MyResourceNotFoundException extends RuntimeException {
        public MyResourceNotFoundException() {
            super();
        }
        ...
    }
    

    上述代码由于自定义异常MyResourceNotFoundException@ResponseStatus注解了,因此ResponseStatusExceptionResolver可以捕获该异常,并将其转换为状态码404。同样,该实现类也无法对响应体内容进行设置。

    • DefaultHandlerExceptionResolver:SpringMVC 中的DispatcherServlet默认使能该接口,但该实现类只会捕获 SpringMVC 抛出的异常并将其转换为对应的 HTTP 状态码(具体支持的异常请查看:mvc-ann-rest-spring-mvc-exceptions)。由于该实现类只能转换为状态码,不能自定义响应体内容,因此也存在不足。
      ...

    • 自定义类实现HandlerExceptionResolver:由于 Spring 内置的实现类功能都有所缺陷,因此我们可以自己实现该接口,打造出一个符合我们需求的实现类。比如:

    // HandlerExceptionResolver
    @Component
    public class HandlerExceptionToViewResolver implements HandlerExceptionResolver {
    
        @Override
        public ModelAndView resolveException(HttpServletRequest request,
                                             HttpServletResponse response,
                                             Object handler,
                                             Exception ex) {
            return new ModelAndView((map, httpServletRequest, httpServletResponse) -> {
                httpServletResponse.setContentType("text/html;charset=UTF-8");
                PrintWriter writer = httpServletResponse.getWriter();
                writer.print("

    Exception Detected!!!

    "); writer.flush(); }); } } // Application.java @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } // 注入自定义 HandlerExceptionResolver @Bean HandlerExceptionResolver customExceptionResolver () { return new HandlerExceptionToViewResolver(); } } // Controller @RestController public class ExceptionController { @GetMapping("/exception") public String exception(){ throw new RuntimeException("this exception will be captured by HandlerExceptionToViewResolver!!"); } }

    上述代码在异常捕获时,Spring 会将requestresponse传入,因此我们可以知道异常请求详情和对response进行输出设置,完全可控。

    但是,虽然自定义HandlerExceptionResolver已能较完美满足我们对全局异常捕获及统一异常处理,但还是存在缺陷,一个方面是该方法需要手动控制HttpServletResponse,操作相对较底层,比较繁琐,另一个方面是HandlerExceptionResolver是对ModelAndView进行交互,返回的是一个视图对象,这与当前流行的 RESTful 风格数据类型不一致...

    也因此,为了提供一个更好的全局异常捕获机制,Spring 3.2 版本引进了一个全局异常处理注解@ControllerAdvice

  • @ControllerAdvice:对于注解了@ControllerAdvice的类,Spring 会将其作为全局异常捕获类。当应用任一Controller发生异常时,会被@ControllerAdvice注解类捕获到,然后根据其内部@ExceptionHandler会指定想要进行捕获的异常,满足时,异常即被捕获,比如:

@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler({ Exception.class})
    @ResponseBody
    public ResponseEntity handleException() {
        return ResponseEntity.status(500).body("exception occured!!");
    }
}

上述代码在任意Controller抛出Exception异常时,都会被捕获得到。

@ControllerAdvice对应的 RESTful 风格的 api 为:@RestControllerAdvice

Spring Boot 异常捕获机制

SpringMVC 中的异常处理的标准配置是由WebMvcConfigurationSupport提供的,该配置由@EnableWebMvc注解进行开启。

WebMvcConfigurationSupport会向 Spring 容器中注入多个Bean实例,其中就包括负责异常处理的Bean实例:HandlerExceptionResolverComposite

HandlerExceptionResolverComposite捕获到异常后,会依次委托给其内维护的一系列异常处理器,当其中一个异常处理器返回结果不为空的时候,就说明异常被处理完成,处理结果作为最终响应。这部分内容对应的部分源码如下所示:


package org.springframework.web.servlet.config.annotation;
//...

public class WebMvcConfigurationSupport implements ApplicationContextAware, ServletContextAware {
    //...
    protected void configureHandlerExceptionResolvers(List exceptionResolvers) {
    }

    protected void extendHandlerExceptionResolvers(List exceptionResolvers) {
    }
     
    @Bean
    public HandlerExceptionResolver handlerExceptionResolver(
            @Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager) {
        List exceptionResolvers = new ArrayList<>();
        // 预留接口
        configureHandlerExceptionResolvers(exceptionResolvers);
        if (exceptionResolvers.isEmpty()) {
            // 依次添加 ExceptionHandlerExceptionResolver,ResponseStatusExceptionResolver,DefaultHandlerExceptionResolver 3 个默认异常处理器
            addDefaultHandlerExceptionResolvers(exceptionResolvers, contentNegotiationManager);
        }
        // 预留接口
        extendHandlerExceptionResolvers(exceptionResolvers);
        HandlerExceptionResolverComposite composite = new HandlerExceptionResolverComposite();
        composite.setOrder(0);
        composite.setExceptionResolvers(exceptionResolvers);
        return composite;
    }
    //...
}

:在源码中,我们还可以看到,handlerExceptionResolver方法内部预留了两个 Hook 接口,其中:

  • configureHandlerExceptionResolvers:可以添加我们自定义的HandlerExceptionResolver,但此时就不会再添加默认的异常处理器。
  • extendHandlerExceptionResolvers:该接口在添加完 3 个默认异常处理器后才触发,因此其作用是在异常处理器列表中追加自定义HandlerExceptionResolver
@EnableWebMvc
@EnableAsync
@Configuration
public class WebConfig implements WebMvcConfigurer {
    // 不添加默认异常处理器
    @Override
    public void configureHandlerExceptionResolvers(List exceptionResolvers) {
        exceptionResolvers.add(new HandlerExceptionToViewResolver());
    }
    // 追加自定义异常处理器
    @Override
    public void extendHandlerExceptionResolvers(List exceptionResolvers) {
        exceptionResolvers.add(new HandlerExceptionToViewResolver());
    }
}

简而言之,WebMvcConfigurationSupport是以按以下顺序注入异常处理器给到HandlerExceptionResolverComposite

  1. ExceptionHandlerExceptionResolver:捕获处理被@ExceptionHandler注解的异常
  2. ResponseStatusExceptionResolver:捕获被ResponseStatusException@ResponseStatus注解的异常
  3. DefaultHandlerExceptionResolver:捕获 Spring 内置的异常(比如:HttpRequestMethodNotSupportedException

如果以上异常处理器都捕获失败,那么该异常就会传递到内置容器(比如:Tomcat)中。内置容器就会将该异常重定向到/error页面,此时就由 Spring Boot 默认提供的BasicErrorController进行捕获处理,也就是我们最前面讲述的内容。整个流程如下图所示:

网络来源于网上,侵删

综上,在 Spring Boot 应用中,对于 SpringMVC 异常处理,其执行逻辑大概如下:

  1. 当异常发生时,Spring 首先会在被@ControllerAdvice注解的类中查找@ExceptionHandler注解的方法。这步由ExceptionHandlerExceptionResolver进行实现。

  2. 然后会检测看异常是否被@ResponseStatus注解,或者是来自ResponseStatusException,如果是的话就交由ResponseStatusExceptionResolver进程处理。

  3. 最后由 SpringMVC 的默认异常DefaultHandlerExceptionResolver进行处理。

  4. 如果到最后还是找不到对该异常进行处理的 Handler,那么就交由定义了异常页面(error view page)的全局错误处理。
    :如果该异常是由该全局异常处理器(比如:BasicErrorController)内部抛出的,那么第 4 步不会执行(个人理解,这里更确切的说法应该是当BasicErrorController内部的某个页面发送异常时,内置容器(比如:Tomcat)会将该异常重定向到错误页面/error,而如果/error页面内部发生异常,则由于其也是一个Controller,所以会优先走@ControllerAdvice全局捕获,捕获不成功才最终交由 Tomcat 进行处理).

  5. 如果找不到错误视图(比如:全局错误处理器被禁止)或者跳过第 4 步骤,那么异常就只能交给到容器进行处理。

自定义全局异常捕获

从前面的内容分析可以知道,在 Spring Boot 应用中,系统中共存在以下 3 种异常情况:

  • Controller抛出的异常:可以采用@ControllerAdvice进行全局捕获
  • 其他异常:会被全局异常处理器(比如:BasicErrorController)进行捕获
  • 全局异常处理器抛出的异常:对于非/error页面的异常,会被内置容器转发到/error进行捕获处理,而对于/error页面的异常,会先由@ControllerAdvice进行捕获,捕获不成功则交由 Spring Boot 内置的 Web 应用容器(比如:Tomcat)进行捕获。

因此,我们自定义一个全局异常捕获,就要考虑以上 3 方面异常:

  • Controller抛出的异常:比如可以像如下代码所示,简单粗暴拦截所有Controller异常,而对于ResponseStatusException异常,进行特殊处理。
@RestControllerAdvice
public class GlobalExceptionHandler {
    // 捕获所有 Exception
    @ExceptionHandler(Exception.class)
    public ResponseEntity handleException() {
        return ResponseEntity.status(500).body("exception detected!!!");
    }

    @ExceptionHandler
    @ResponseStatus(code= HttpStatus.INTERNAL_SERVER_ERROR)
    public String handleSpecialException(ResponseStatusException e){
        return "ResponseStatusException detected!!!";
    }
}

:根据我们前面的分析,由于ExceptionHandlerExceptionResolver的捕获优先于ResponseStatusExceptionResolver,而上面代码对所有异常Exception都进行了捕获处理,因此,ResponseStatusExceptionResolverDefaultHandlerExceptionResolver永远不会被执行到。
当然,如果没有对异常基类Exception进行捕获,系统遇到其他异常,还是会走正常流程的,如果想打破这个流程,可以采用自定义HandlerExceptionResolver,并且切断默认处理器添加(即覆写configureHandlerExceptionResolvers),这样做的话,系统就只会走我们自定义的HandlerExceptionResolver,若不能处理该异常,则直接转到全局异常处理。

@EnableWebMvc
@EnableAsync
@Configuration
public class SingleHandlerExceptionResolver implements WebMvcConfigurer {

    @Autowired
    private CustomHandlerExceptionResolver customResolver;

    @Override
    public void configureHandlerExceptionResolvers(List resolvers) {
        resolvers.add(this.customResolver);
    }

    @Component
    static class CustomHandlerExceptionResolver implements HandlerExceptionResolver {

        @Override
        public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
            System.out.println("---------hahaha-------------");
            // 只捕获 ResponsStatusException
            if (ex instanceof ResponseStatusException) {
                return new ModelAndView((model, req, res) -> {
                    res.setStatus(500);
                    res.setContentType("application/json");
                    PrintWriter writer = res.getWriter();
                    writer.print(String.format("{code: %d,msg: %s}", -1,
                            "server detected ResposeStatusException!!"));
                    writer.flush();
                });
            }
            return null;
        }
    }
}
  • 其他异常:对于不是由Controller抛出的异常,就会交到 Spring Boot 提供的默认异常处理器(即BasicErrorController)进行处理,这里有多种方法可以修改或覆盖 Spring Boot 提供的默认全局异常处理:

    • 自定义一个Bean,实现接口ErrorController,则此时默认的异常处理将不再生效
    • 自定义一个Bean,继承BasicErrorController,可重用现有功能,并进行针对性修改。也可以扩展新方法,使用@RequestMappingproduces映射新地址。
    • 自定义一个类型为ErrorAttributeBean实例,BasicErrorController会自动采用该实现并进行渲染
    • 自定义一个Bean,继承AbstractErrorController

这里我们采用实现接口ErrorController来实现全局异常处理:

@Controller
@RequestMapping("/error")
public class CustomizedErrorController implements ErrorController {
    @Override
    public String getErrorPath() {
        return null;
    }

    @RequestMapping
    public ResponseEntity> error(HttpServletRequest request) {
        Map result = new HashMap<>();
        result.put("code", "-1");
        result.put("msg", "sever detected exception!!!");
        return ResponseEntity.status(500).body(result);
    }
}

现在当遇到未知异常时,就会被CustomizedErrorController捕获得到。

  • 全局异常处理器抛出的异常:对于全局异常处理器抛出的异常,存在一定的可能无法被 Spring Boot 应用捕获处理,则最终会被 Spring Boot 内置的 Web 应用容器(比如:Tomcat)进行捕获。此时可根据自己应用内部使用的具体容器提供的相关 api 进行设置,这里就展开了。

综上,定义全局异常捕获主要就是使用到@ControllerAdvice和全局异常处理器。

@ControllerAdvice可以对所有Controller抛出的异常进行捕获,为了尽可能让我们自定义的@ControllerAdvice捕获异常,因罗列足够多,设置于使用基类Exception进行全部捕获。

而对于网址不存在404 not found等异常,则会交给到全局异常处理器进行处理,此处唯一要注意的是尽量让/error路由内部不出现异常,否则很可能会交给内置容器进行处理(其实如果@ControllerAdvice使用了基类Exception,那么/error抛出的任意异常其实都会被@ControllerAdvice进行捕获)。

下面实现一个相对全面稳健的全局异常捕获器(RESTful 风格)。

全局异常捕获器

  1. 首先定义响应体Bean类,统一数据下发格式:
public final class ResponseBean {
    // 自定义应用状态码(不是响应状态码)
    private int code;
    // 描述信息
    private String msg;
    // 附带数据
    private Collection data;

    public static ResponseBean success() {
        ResponseBean bean = new ResponseBean();
        bean.code = 0;
        bean.msg = "success";
        return bean;
    }
    public static  ResponseBean success(Collection data) {
        ResponseBean bean = new ResponseBean();
        bean.code = 0;
        bean.msg = "success";
        bean.data = data;
        return bean;
    }
    public static ResponseBean error(final int code,final String msg) {
        ResponseBean bean = new ResponseBean();
        bean.code = code;
        bean.msg = msg;
        return bean;
    }

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }

    public Collection getData() {
        return data;
    }

    @Override
    public String toString() {
        return String.format("{code: %d,msg: %s,data: %s}", this.code, this.msg, this.data);
    }
}
  1. 定义一个全局Controller异常捕获:
@RestControllerAdvice
public class GlobalExceptionHandler {
    // 捕获所有 Exception,一定要加上,阻断默认异常处理器传递
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ResponseBean handleException() {
        return ResponseBean.error(3, "internal server error");
    }

    @ExceptionHandler
    @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
    public ResponseBean handleServletException(ServletException ex) {
        return ResponseBean.error(1, "servlet exception");
    }

    @ExceptionHandler
    public ResponseEntity handleSpecialException(ResponseStatusException e) {
        return ResponseEntity.status(e.getStatus()).body(ResponseBean.error(2, e.getReason()));
    }
}
  1. 自定义一个全局异常处理器,取代默认的BasicErrorController,对剩余未捕获的异常进行捕获处理:
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class CustomizedErrorController implements ErrorController {

    @Override
    public String getErrorPath() {
        return null;
    }

    @RequestMapping
    @ResponseBody
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ResponseBean error(HttpServletRequest request) {
        return ResponseBean.error(4, "not found");
    }
}

在上述代码中添加自己需要进行捕获的异常,则基本上能完成全局异常捕获,并统一数据格式下发。

参考

  • Spring Boot and Spring MVC: how to handle controller exceptions properly
  • 浅谈springboot异常处理机制
  • Error Handling for REST with Spring
  • Spring Boot干货系列:(十三)Spring Boot全局异常处理整理

你可能感兴趣的:(Spring Boot - 全局异常捕获)