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<String, Object> 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<Map<String, Object>> error(HttpServletRequest request) {
        //...
        Map<String, Object> 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("<h1>Exception Detected!!!</h1>");
                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).<String>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<HandlerExceptionResolver> exceptionResolvers) {
    }

    protected void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers) {
    }
     
    @Bean
    public HandlerExceptionResolver handlerExceptionResolver(
            @Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager) {
        List<HandlerExceptionResolver> 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<HandlerExceptionResolver> exceptionResolvers) {
        exceptionResolvers.add(new HandlerExceptionToViewResolver());
    }
    // 追加自定义异常处理器
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> 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<String> handleException() {
        return ResponseEntity.status(500).<String>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<HandlerExceptionResolver> 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<Map<String, Object>> error(HttpServletRequest request) {
        Map<String, Object> 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<T> {
    // 自定义应用状态码(不是响应状态码)
    private int code;
    // 描述信息
    private String msg;
    // 附带数据
    private Collection<T> data;

    public static ResponseBean success() {
        ResponseBean bean = new ResponseBean();
        bean.code = 0;
        bean.msg = "success";
        return bean;
    }
    public static <V> ResponseBean success(Collection<V> 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<T> 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<ResponseBean> 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");
    }
}

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

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,802评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,109评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,683评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,458评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,452评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,505评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,901评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,550评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,763评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,556评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,629评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,330评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,898评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,897评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,140评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,807评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,339评论 2 342