Spring Boot(MVC)中的异常处理

前言

Spring社区里一篇讲解spring mvc中异常处理的文章,感觉写的很好,为了加深印象,决定翻译一下。
文章标题:Exception Handling in Spring MVC
原文链接:https://spring.io/blog/2013/11/01/exception-handling-in-spring-mvc
作者:Paul Chapman/Senior Consultant Trainer/Sydney

翻译正文

Spring MVC提供了几种值得称赞的异常处理实现,然而在教学过程中,我的学生总是对这些处理方式感到疑惑不解。

所以今天我将演示各种异常处理方法,我们的目标是:尽量不要在controller中显示地处理异常,因为异常处理代码是可交叉复用的,最好将其抽取到专用的模块。

异常处理范围可以划分为以下三种:

  • 单个exception
  • 单个controller
  • 全局异常

演示代码可在github上找到: http://github.com/paulc4/mvc-exceptions,文章中提到的点代码里均有演示。

注意:示例代码我在2018/04用springboot重写了一遍,使其更易于(希望如此)使用与理解,同时也修正了一些不可用的链接(谢谢大家的反馈,很抱歉让大家等了那么久才更新)。

Spring Boot

Spring Boot让你可以用最少的配置来开发spring项目,如果你的项目是最近几年开发的那你可能已经在使用spring boot了。

Spring MVC并没有提供默认的开箱即用的error page,设置默认error page最常用的方式就是使用SimpleMappingExceptionResolver(实际上自Spring V1版本开始就有了),稍后我们还会再提到它。

然而,Spring Boot默认提供了可用的错误处理页(a fallback error-handling page)。

刚开始时,spring boot会尝试匹配/error对应的view,按照惯例,我们将/error映射到一个同名的view上,在示例应用中我们将错误页面映射到error.html这个Thymeleaf template上(如果用的JSP则应该映射到error.jsp,这取决于你如何配置InternalResourceViewResolver),实际匹配到的view将取决于你或spring boot设置的ViewResolver(如果有的话)。

如果ViewResolver找不到/error对应的view,那么spring boot将定义其自己备用的error page,也就是我们常常见到的“Whitelabel Error Page” (这个错误页仅包含了http status code和异常message等基本错误信息)。在示例应用中,如果你将error.html重命名为error2.html,重启应用你就能看见“Whitelabel Error Page”了。

如果你开发的是RESTful请求(HTTP请求已指定了除HTML以外的其期望的返回类型),Spring Boot将把“Whitelabel”中的信息以JSON格式返回

$> curl -H "Accept: application/json" http://localhost:8080/no-such-page

{"timestamp":"2018-04-11T05:56:03.845+0000","status":404,"error":"Not Found","message":"No message available","path":"/no-such-page"}

Spring Boot同样也为servlet容器设置了默认的error-page,与web.xml中的标签一样(不过是以不同的方式实现的)。从Spring MVC框架外抛出的异常,如servlet filter,同样会在Spring Boot的error page展现。示例应用中也演示了这种情况。

我们将在文章后面深入讨论关于Spring Boot的异常处理。

不管你用的是spring还是spring boot,文章后面的内容都是适用的。

某些缺乏耐心的REST开发者可能会直接跳到关于REST异常处理的部分,但我建议还是把文章通读一遍,因为这些处理方式对于所有WEB应用都是有用的,不论是REST应用还是其他WEB应用。

使用HTTP状态码(Using HTTP Status Codes)

通常来说,任何未经处理的异常都将导致服务器返回HTTP 500错误。但是,我们可以使用@ResponseStatus注解来处理那些你自定义的异常(此注解支持返回所有HTTP规范中的状态码),当controller中的某个方法抛出一个被注解指定过的异常,且此异常未被处理,spring mvc将根据你指定的HTTP状态码返回一个合适的HTTP响应。

举个栗子,订单缺失时我们将返回一个异常

@ResponseStatus(value=HttpStatus.NOT_FOUND, reason="No such Order")  // 404
 public class OrderNotFoundException extends RuntimeException {
     // ...
 }

在controller中是这样调用的

@RequestMapping(value="/orders/{id}", method=GET)
 public String showOrder(@PathVariable("id") long id, Model model) {
     Order order = orderRepository.findOrderById(id);

     if (order == null) throw new OrderNotFoundException(id);

     model.addAttribute(order);
     return "orderDetail";
 }

当我们调用此接口时,如果传入一个不存在的order id,服务器就会返回熟悉的404错误了。
(译者注:可能返回204更准确?无所谓了,反正是栗子?)

处理Controller中抛出的异常(Controller Based Exception Handling)

使用@ExceptionHandler

你可以在同一个controller中添加一些方法,上面加上@ExceptionHandler注解,这样就能指定处理方法(注解了@RequestMapping)中抛出的异常了,这些方法的作用有:

  1. 在不添加@ResponseStatus的情况下处理异常(通常来说是那些预定义好的异常)。
  2. 将用户请求重定向到指定的错误页面。
  3. 可以自定义返回的错误信息。

下面的controller演示了上面提到的三种情况:

@Controller
public class ExceptionHandlingController {

  // 处理请求的方法
  // ...
  
  // 处理异常的方法
  // 根据异常类型指定HTTP状态码
  @ResponseStatus(value=HttpStatus.CONFLICT,
                  reason="Data integrity violation")  // 409
  @ExceptionHandler(DataIntegrityViolationException.class)
  public void conflict() {
    // Nothing to do
  }
  
  // 指定展示error信息的view
  @ExceptionHandler({SQLException.class,DataAccessException.class})
  public String databaseError() {
    /**
    * 就像平常一样,直接返回error page的view name
    * 注意:view里无法获取抛出的异常(因为我们没有将其加到Model里)
    * 但我们可以通过继承 ExceptionHandlerExceptionResolver来控制更多东西
    * 具体实现在下面
    */
    return "databaseError";
  }

  /**
  * 通过继承ExceptionHandlerExceptionResolver来实现完全控制异常处理,你可以在
  * ModelAndView中添加你想要的Model和View
  */
  @ExceptionHandler(Exception.class)
  public ModelAndView handleError(HttpServletRequest req, Exception ex) {
    logger.error("Request: " + req.getRequestURL() + " raised " + ex);

    ModelAndView mav = new ModelAndView();
    mav.addObject("exception", ex);
    mav.addObject("url", req.getRequestURL());
    mav.setViewName("error");
    return mav;
  }
}

在这些异常处理方法中你可以做以下额外的处理操作——最常见的就是打印异常日志。

这些处理方法的入参是灵活可变的,你可以传入servlet相关的参数,如HttpServletRequest, HttpServletResponse, HttpSession 或是 Principle。

重要提示: Model可能无法当作入参传到一些由@ExceptionHandler注解的方法中,所以最好像上面的handleError()方法一样,将Model放到ModelAndView中。

异常与视图(Exceptions and Views)

当你把异常放入Model中时,请务必小心,因为用户绝不希望在网页上看到Java异常细节和异常堆栈(开发者也不希望暴露代码细节)。你可能会需要一些安全策略来确保异常信息不会直接显示到错误页面上,这也是为什么你需要重写Spring Boot white-label页面的原因。

请务必正确地打印异常日志,这样出错后开发人员能够很方便地根据日志来分析出错的原因。

请牢记:下面的代码可能看起来方便但生产环境中最好谨慎使用,因为这可能并不是最佳实践。

小技巧:将异常栈打印到页面的注释中可以帮助你调试项目,输出到一个隐藏的中(使用hidden属性隐藏标签)也不失为一个好办法。

  <h1>Error Pageh1>
  <p>Application has encountered an error. Please contact support on ...p>
    
  

使用Thymeleaf的话可以参考示例应用中的support.html页面,显示结果如下所示:
Spring Boot(MVC)中的异常处理_第1张图片

全局异常处理(Global Exception Handling)

使用@ControllerAdvice类(Using @ControllerAdvice Classes)

controller advice拥有像@ExceptionHandler一样的异常处理功能,区别在于它的作用范围是整个应用,而不仅仅只是单个controller。你可以把它当成一个注解驱动的拦截器。

任何一个注解了@ControllerAdvice的类都将变成一个controller-advice,且支持以下三种类型的方法:

  • 通过添加@ExceptionHandler处理指定异常。
  • 添加@ModelAttribute实现Model增强(可以添加额外的数据到Model里),注意,这些添加的数据在view中是无法获取到的。
  • 添加@InitBinder以绑定初始化方法(用于配置表单处理(form-handling?))。

这篇文章我们仅关注异常处理的部分-可以在线上文档中找到更多关于@ControllerAdvice的说明

上面提到的exception handlers都可以声明为controller-advice,但之后其作用范围就将包含所有controller,以下是一个栗子:

@ControllerAdvice
class GlobalControllerExceptionHandler {
    @ResponseStatus(HttpStatus.CONFLICT)  // 409
    @ExceptionHandler(DataIntegrityViolationException.class)
    public void handleConflict() {
        // Nothing to do
    }
}

如果你想为所有异常设置一个默认的处理方法,这里有个小技巧,同时你需要确保处理的异常能被框架处理(即已有handler处理这个异常了),代码如下:

@ControllerAdvice
class GlobalDefaultExceptionHandler {
  public static final String DEFAULT_ERROR_VIEW = "error";

  @ExceptionHandler(value = Exception.class)
  public ModelAndView
  defaultErrorHandler(HttpServletRequest req, Exception e) throws Exception {
    /**
    * 如果此异常已被@ResponseStatus注解处理过了
    * 就可以将其抛出,由框架来处理它,
    * 比如文章开头OrderNotFoundException那个例子。
    * AnnotationUtils是spring框架的一个工具类
    */
    if (AnnotationUtils.findAnnotation
                (e.getClass(), ResponseStatus.class) != null)
      throw e;

    // 否则就将用户导向默认的错误页面
    ModelAndView mav = new ModelAndView();
    mav.addObject("exception", e);
    mav.addObject("url", req.getRequestURL());
    mav.setViewName(DEFAULT_ERROR_VIEW);
    return mav;
  }
}

深入讨论(Going Deeper)

HandlerExceptionResolver

任何一个实现了HandlerExceptionResolver接口的类,如果将其注册到DispatcherServlet’s的应用上下文中,那么它将拦截并处理所有在MVC框架中抛出的异常,而不仅仅是controller中抛出的。这个接口如下所示:

public interface HandlerExceptionResolver {
    ModelAndView resolveException(HttpServletRequest request, 
            HttpServletResponse response, Object handler, Exception ex);
}

handler参数是抛出异常的controller的引用(记住一点,@Controller实例仅是spring mvc中handler的一种,其他包括像HttpInvokerExporterWebFlow Executor都是handler

在此之下,MVC框架默认实现了三个resolver,这些resolver实现了我们在上面讨论的功能(拦截并处理所有在MVC框架中抛出的异常):

  • ExceptionHandlerExceptionResolver捕获所有未处理(uncaught)的异常,将其交给对应的@ExceptionHandler注解的方法来处理,这些方法可能在controller中也可能在controller-advice中。
  • ResponseStatusExceptionResolver负责匹配@ResponseStatus注解的异常(跟第一节描述的一样)。
  • DefaultHandlerExceptionResolver会根据标准的spring异常的类型来设置HTTP状态码(在上面我没有提到是因为这是spring mvc的内置实现)。

这三个resolver构成了一条处理链,且是有序排列的(都实现了Order接口),Spring内部通过专门的类来组合这三个resolver(HandlerExceptionResolverComposite)。

请注意resolveException方法的入参不包含Model。这就是为什么@ExceptionHandler注解无法注入Model的原因。

如果你想的话,你可以实现HandlerExceptionResolver接口来自定义异常处理。通常这些异常handler都实现了spring的Order接口,这样你就能定义handler被调用的顺序了。

SimpleMappingExceptionResolver

一直以来,spring都提供了一个简单但方便的HandlerExceptionResolver实现,你可能已经在应用中使用过了,那就是SimpleMappingExceptionResolver。其提供如下几个功能:

  • 将异常的class name 与view name映射起来,且只需要类名,不需要前面的包名。
  • 为未被处理的异常指定一个默认的error page。
  • 打印异常日志(默认未开启)。
  • 将异常信息以attribute的形式添加到Model中,这样你就能在view中取到这些信息并使用它。(拿JSP来说)默认的attribute名为exception,将name设为null将禁用此功能。需要记住的是:在@ExceptionHandler注解的方法返回的view中,你无法访问异常信息,但在SimpleMappingExceptionResolver返回view时,你是可以在view中获取异常信息的。

以下是使用Java代码配置SimpleMappingExceptionResolver的常规写法:

@Configuration
@EnableWebMvc  // 开启spring mvc 
               // 如果你用的不是spring boot你就需要显式声明
public class MvcConfiguration extends WebMvcConfigurerAdapter {
  @Bean(name="simpleMappingExceptionResolver")
  public SimpleMappingExceptionResolver
                  createSimpleMappingExceptionResolver() {
    SimpleMappingExceptionResolver r =
                new SimpleMappingExceptionResolver();

    Properties mappings = new Properties();
    mappings.setProperty("DatabaseException", "databaseError");
    mappings.setProperty("InvalidCreditCardException", "creditCardError");

    r.setExceptionMappings(mappings);  // None by default
    r.setDefaultErrorView("error");    // No default
    r.setExceptionAttribute("ex");     // Default is "exception"
    r.setWarnLogCategory("example.MvcLogger");     // No default
    return r;
  }
  ...
}

XML文件配置如下:

 
    <property name="exceptionMappings">
      <map>
         <entry key="DatabaseException" value="databaseError"/>
         <entry key="InvalidCreditCardException" value="creditCardError"/>
      map>
    property>

    
    <property name="defaultErrorView" value="error"/>
    <property name="exceptionAttribute" value="ex"/>
        
           
    <property name="warnLogCategory" value="example.MvcLogger"/>
  bean>

defaultErrorView属性是很有用的,因为它能确保任何未被处理的异常都能找到合适的错误页面进行展示(对大多数应用服务器来说,都会打印java异常堆栈——而这些东西永远不该展示给用户)。spring boot提供了“white-label”页面来帮你设置默认错误页面。

继承SimpleMappingExceptionResolver

一种常见的做法是继承SimpleMappingExceptionResolver,这么做有以下几个原因:

  • 你可以使用现有的构造方法来设置一些有用的属性——比如开启日志记录。
  • 重写buildLogMessage方法,默认的实现总是返回以下文本:Handler execution resulted in exception
  • 重写doResolveException方法,你可以添加一些额外的信息,这样就能方便你在error view里展示错误信息。

再举个栗子:

public class MyMappingExceptionResolver extends SimpleMappingExceptionResolver {
  public MyMappingExceptionResolver() {
    // 设置logger name以开启日志记录功能
    setWarnLogCategory(MyMappingExceptionResolver.class.getName());
  }

  @Override
  public String buildLogMessage(Exception e, HttpServletRequest req) {
    return "MVC exception: " + e.getLocalizedMessage();
  }
    
  @Override
  protected ModelAndView doResolveException(HttpServletRequest req,
        HttpServletResponse resp, Object handler, Exception ex) {
    // 调用父类方法获取ModelAndView
    ModelAndView mav = super.doResolveException(req, resp, handler, ex);
        
    /**
    * 将全路径url传递到view中
    * 注意:ModelAndView使用addObject()方法
    * 但Model使用addAttribute()方法
    * 但他们效果都是一样的
    */
    mav.addObject("url", request.getRequestURL());
    return mav;
  }
}

这个示例类在示例应用中叫做[ExampleSimpleMappingExceptionResolver]。(https://github.com/paulc4/mvc-exceptions/blob/master/src/main/java/demo/example/ExampleSimpleMappingExceptionResolver.java)

继承 ExceptionHandlerExceptionResolver

继承ExceptionHandlerExceptionResolver并重写doResolveHandlerMethodException方法同样能实现上述功能。方法入参基本是一样的(只不过把Handler换成了HandlerMethod)。

为了保证你自定义的handler被调用,请设置order值,使其小于MAX_INT,这样它就能在默认的ExceptionHandlerExceptionResolver实例前被调用(创建一个新的handler总比修改spring原有的handler要简单)。具体细节请参考示例项目中的代码[ExampleExceptionHandlerExceptionResolver]。(https://github.com/paulc4/mvc-exceptions/blob/master/src/main/java/demo/example/ExampleExceptionHandlerExceptionResolver.java)

Errors and REST

RESTful GET请求同样也会产生异常,在文章开始我们已经讲了如何返回对应HTTP状态码,但如果我们想返回一些错误信息呢?其实也很简单,首先定义一个错误信息类:

public class ErrorInfo {
    public final String url;
    public final String ex;

    public ErrorInfo(String url, Exception ex) {
        this.url = url;
        this.ex = ex.getLocalizedMessage();
    }
}

现在我们就能在handler方法里以@ResponseBody的形式返回错误类的实例,如下:

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MyBadDataException.class)
@ResponseBody ErrorInfo
handleBadRequest(HttpServletRequest req, Exception ex) {
    return new ErrorInfo(req.getRequestURL(), ex);
} 

怎么做?何时做?(What to Use When?)

同往常一样,相比帮你做决定,Spring更愿意为你提供多样的选择,由你自己决定,所以你会怎么做呢?以下是一些人生的经验。如果你更喜欢XML或者注解配置也完全没有问题。

  • 对于你的自定义异常,请考虑为其添加合适的@ResponseStatus
  • 对于其他异常最好定义一个@ControllerAdvice类,实现@ExceptionHandler方法来处理它。如果你已经在使用SimpleMappingExceptionResolver处理异常了,最好是将异常配置到SimpleMappingExceptionResolver的exception mapping中,毕竟配置以下总比新建一个@ControllerAdvice类方便(译者注:其实我更喜欢自定义exception handler,避免因为误解spring原有实现逻辑而导致偏差,就为了偷点懒,得不偿失啊)。
  • 针对某个controller抛出的特定异常,在此controller中添加@ExceptionHandler方法来处理它。
  • 警告: 不要在同一个应用中采用太多的异常处理方式。逻辑一旦分散,就可能出现你意料之外的情况。controller里的@ExceptionHandler方法调用顺序优先于在 @ControllerAdvice类的@ExceptionHandler方法,但同在@ControllerAdvice里的@ExceptionHandler方法,其调用顺序并未定义。

示例应用

示例项目的代码放到了github上,该项目使用spring boot + Thymeleaf搭建。

这个项目我修改过两次(2014/10,2018/04),使其更好也更易于(希望如此)理解。其基本原理是一样的。项目使用的是Spring Boot V2.0.1 和 Spring V5.0.5,但同样与Spring 3.x 和 4.x兼容。

Demo运行在Cloud Foundry上,http://mvc-exceptions-v2.cfapps.io/ 。

关于Demo的那点事

此应用有5个demo页面,分别展示了不同异常处理技巧:

  1. controller中添加一个带@ExceptionHandler注解的方法,可以处理此controller抛出来的特定异常。
  2. 使用@ControllerAdvice处理全局异常。
  3. 使用SimpleMappingExceptionResolver拦截处理异常。
  4. 与第3项一样,不过是把SimpleMappingExceptionResolver禁用做对比。
  5. 展示Spring Boot是如何创建错误页面的。

关于一些项目中比较重要的文件,大家可以在项目的README.md里面找到相关说明。

主页地址是index.html,里面包含了:

  • 各demo页的链接。
  • 页脚有Spring Boot项目的链接,感兴趣的可以去看看。

每个demo页面里都有几个链接,每个链接都会导致异常抛出,所以每次你都得用浏览器的返回按钮来返回上一页。

Spring Boot大法好啊,你可以像运行一个java类一样运行spring boot项目(内嵌了Tomcat)。只需使用以下两个命令中一个,项目就能运行起来了:

  • mvn exec:java
  • mvn spring-boot:run(感谢spring boot maven插件)

主页地址:http://localhost:8080 。

错误页的内容

同样,我也会演示如何创建一个“技术支持可读”的页面,我会将异常栈打印到这个HTML页面的注释中。讲道理来说,技术支持一般是通过日志信息排错,但人生不如意事常八九,有时候就是没有日志可看(译者注:还真是,?)。不管怎样,这个错误页面展示了:我们是如何用底层异常处理方法将异常信息包装到ModelAndView,并将其隐式地显示在页面上,代码如下可见:

  • ExceptionHandlingController.handleError() on github
  • GlobalControllerExceptionHandler.handleError() on github

Spring Boot错误处理

Spring Boot使你能以最少的配置运行开发spring项目。Spring Boot能根据具体的class来自动创建合理的配置。比如如果它检测到servlet环境就会自动装配spring mvc,包括常用的view-resolvers, hander mappings等等。如果检测到JSP或是Thymeleaf,它就会自动配置模版引擎。

备用的错误页面(Fallback Error Page)

Spring Boot是如何做到我们文章开头提到的支持默认错误页面功能的呢?

  1. 所有未经处理的错误,spring boot都会将其转发到统一路径/error
  2. spring boot实现了一个BasicErrorController来处理所有转向error的错误。这个comntroller会添加错误信息到内置Model中并返回一个名为error的逻辑视图。
  3. 如果配置有view-resolver,那么它将匹配一个响应view来显示错误页面。
  4. 否则,就将使用一个专用的view对象(请将此对象抽象出来以便独立于你使用的任何视图解析器)来显示错误页面。
  5. spring boot设置了BeanNameViewResolver,因此error路径将映射到同名的view上。
  6. 如果你看过ErrorMvcAutoConfiguration的源码,就会发现defaultErrorView的值是一个名为error的bean,由此BeanNameViewResolver就将解析映射错误页面

默认的“Whitelabel”页面确实有点简陋,你可以覆盖它:

  1. 定义一个error模版,在demo里我们用的模版引擎是Thymeleaf,所以模版页在src/main/resources/templates/error.html(这个地址是由spring boot的spring.thymeleaf.prefix属性配置的,用的模版引擎不同,这个key值也不同,如JSP、Mustache)。
  2. 如果你没有使用服务端的模版引擎渲染页面:
    2.1 将你自己的error view定义为一个名为error的bean。
    2.2 或者将server.error.whitelabel.enabled设为false以禁用spring boot默认的“Whitelabel”错误页。此时容器的默认错误页将启用。

方便起见,spring boot的属性定义通常在application.propertiesapplication.yml里。

集成SimpleMappingExceptionResolver

如果你已经在SimpleMappingExceptionResolver里设置了默认错误页面,那该怎么办呢?简单,在setDefaultErrorView()方法中将defaultErrorView设为与spring boot默认错误页一样的值:error

注意,在demo里我故意将SimpleMappingExceptionResolver中的defaultErrorView设成了defaultErrorPage,而不是error,因为这样你就能清晰地看到handler是怎样生成错误页,以及spring boot是怎样响应的。通常来讲,我们都应该将其设为error(Spring boot的错误页地址和SimpleMappingExceptionResolver中的defaultErrorView)。

容器范围的异常处理(Container-Wide Exception Handling)

在spring框架外同样也会抛出异常,比如servlet filter,这些异常同样也会展示到spring boot的错误页面。

为了实现这个功能,spring boot在容器中注册了一个默认的error page。在Servlet 2中,你可以在web.xml中的里设置默认错误页。不幸的是Servlet 3并没有提供实现同样功能的Java API,因此spring boot通过以下操作来实现此功能:

  • 对于打包成Jar的应用,其内嵌了容器,spring boot使用了容器特定的API注册默认error page。
  • 对于打包成WAR的应用,spring boot使用servlet filter来捕获并处理异常。

总结

这篇文章大概讲述了spring boot(mvc)中的异常处理方式:

  • 关键接口是HandlerExceptionResolver
  • 其中有框架自带的实现主要有ExceptionHandlerExceptionResolverSimpleMappingExceptionResolver等。
  • 实际开发中,@ControllerAdvice+@ExceptionHandler可以处理绝大部分异常。
  • 一些Controller范围外,比如在Filter中抛出的异常我们可以实现HandlerExceptionResolver自定义处理逻辑。
  • 同样也可以继承spring的已有实现,如继承SimpleMappingExceptionResolver,做一些自己的配置和改动。
  • 实现HandlerExceptionResolver时记得实现Order接口,合适的order能使你的自定义handler优先被调用。

翻译这篇文章差不多用了我一整天的时间,水平还是有限啊,翻译有错的地方还请海涵。

想反馈错误或者交流技术的可以邮件沟通[email protected]

你可能感兴趣的:(Spring,Boot,Spring,Spring,MVC)