浅谈web项目中如何优雅的进行异常处理二:Spring MVC中的异常处理

分类: WEB开发 0人评论 selfly 5月前发布

前言

在上一篇异常处理回顾中我们谈到了几种异常的处理方式,这一篇我们来学习一下Spring MVC中的异常处理,最终要达到的目标是尽量不在Controller中显示的处理异常。

Spring MVC提供了几种异常处理的方式,但是很多人并不明白该如何使用。

Spring Boot

Spring Boot是一个可以快速开发Spring应用的框架,以下的代码都是在基于Spring Boot上进行。当然,如果只使用了Spring MVC 也都是可以实现的,差别并不大。

Spring MVC本身并没有提供一个默认的错误页面,想要设置错误页面常用的方法是使用ExceptionResolver,例如SimpleMappingExceptionResolver

但是Spring Boot提供了默认的错误页面。

在项目启动时,Spring Boot会尝试查找/error的RequestMapping,根据该mapping返回到指定的错误页面,该页面文件的具体格式由你的ViewResolver决定,例如jsp、ftl、html等。如果没有则使用它自己的备用页面“Whitelabel Error Page”。

当使用RESTful方式访问时,同样的Spring Boot会返回与“Whitelabel Error Page”对应的JSON数据信息,例如:

selflydeMacBook-Pro:Library liyd$ curl -H "Accept:application/json" http://localhost:8080/login
{"timestamp":1544603309175,"status":404,"error":"Not Found","exception":"com.sonsure.ex.UserNotFoundException","message":"User not found !","path":"/login"}

Spring Boot还为容器设置了一个默认的错误页面,相当于web.xml中的标签(尽管实现方式不同)。 Spring Boot的错误页面仍会报告Spring MVC框架之外抛出的异常,例如来自servlet Filter。

使用HTTP状态码

通常,处理Web请求时抛出的任何未处理的异常都会导致服务器返回HTTP状态码为500的响应。但是,我们编写的任何异常都可以使用@ResponseStatus注解来指定,它支持HTTP规范定义的所有HTTP状态码。

当从Controller方法抛出指定了@ResponseStatus注解的异常,不在其他地方处理时,它将自动使用指定的状态代码返回相应的HTTP响应。

以下是一个简单的示例。

定义一个异常,使用注解指定了HTTP结果码和一个reason信息:

@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "User not found !")
public class UserNotFoundException extends RuntimeException {

}

在Controller中直接进行抛出:

@GetMapping("login")
public String login(String username, String password, Model model) {

    //这里只演示异常,不考虑业务逻辑
    if (StringUtils.length(username) < 5) {
        throw new UserNotFoundException();
    }

    return "login";
}

这时将返回HTTP为404的状态码和简单的reason信息。

基于Controller的异常处理

使用@ExceptionHandler

我们可以在Controller中除了@RequestMapping方法之外添加额外的方法并添加@ExceptionHandler注解,以专门处理同一Controller中@RequestMapping方法抛出的异常。

这样做可以:

  • 在没有使用@ResponseStatus注解的情况下处理异常,通常是未预定义的异常
  • 将用户重定向到专用的错误视图页面
  • 构建完全自定义的错误响应

下面的Controller演示了这三种处理方式:

@Controller
public class ExceptionHandlingController {

    //处理一个异常到HTTP状态码
    @ResponseStatus(value = HttpStatus.CONFLICT,
            reason = "Data integrity violation")  // 409
    @ExceptionHandler(DataIntegrityViolationException.class)
    public void conflict() {
        // Nothing to do
    }

    // 指定异常到特定的错误视图
    @ExceptionHandler({SQLException.class, DataAccessException.class})
    public String databaseError() {
        // 这里不用作任何操作,会使用默认的view-resolver解析
        // 注意并不会将异常信息添加到Model,但是可以扩展ExceptionHandlerExceptionResolver来实现,下面有讲解
        return "databaseError";
    }

    // 全部异常拦截并返回到指定视图
    @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;
    }
}

在这些方法中可以写入其它任何需要执行的业务代码,最典型的就是记录异常信息。

这些方法和@RequestMapping方法一样灵活,可以传入HttpServletRequest、HttpServletResponse、HttpSession、Principle等参数,但是不能传入Model,如果需要,可以像上面的handleError那样处理。

异常展示

在向Model添加异常信息时要小心,用户不会希望看到包含Java异常堆栈信息的Web页面。

项目中也可能有明确禁止在错误页面中放置任何异常信息的安全策略。

但是需要确保有效的对异常进行记录,以便开发人员能够对其进行分析。

以下方式是一个参考,可能很方便,但是在生产环境中这不是一个好的做法。

将异常信息隐藏为注释,页面不会显示,通过查看网页源代码的方式来查看具体的异常信息:

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

<!--
Failed URL: ${url}
Exception:  ${exception.message}
    <c:forEach items="${exception.stackTrace}" var="ste">    ${ste} 
</c:forEach>
-->

看起来像下面这样:

异常显示

全局异常处理

使用@ControllerAdvice

@ControllerAdvice允许你使用相同的方法来处理异常,区别在于它作用于全局而不是单个Controller。

任何使用了@ControllerAdvice注解的类都将被看作是一个全局的controller-advice,它支持以下三种处理方法:

  • @ExceptionHandler 异常处理
  • @ModelAttribute Model处理
  • @InitBinder 初始化绑定

这里我们只关注异常处理@ExceptionHandler

下面是一个简单的示例,拦截了UserNotFoundException异常并返回HTTP结果码为404加一个简单的提示信息:

@ControllerAdvice
public class GlobalExceptionAdvice {

    @ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "User not found !")
    @ExceptionHandler(UserNotFoundException.class)
    public void handleUserNotFound() {
        // Nothing to do
    }
}

如果你想要处理全部的异常,但是又想让框架来帮你处理带了@ResponseStatus注解的异常,那么可以像下面这样:

@ControllerAdvice
public class GlobalDefaultExceptionHandler {

  public static final String DEFAULT_ERROR_VIEW = "error";

  @ExceptionHandler(value = Exception.class)
  public ModelAndView defaultErrorHandler(HttpServletRequest req, Exception e) throws Exception {
    //如果异常类使用@ResponseStatus注解则重新抛出它,交给框架来处理
    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;
  }
}

深入了解

HandlerExceptionResolver

任何实现了HandlerExceptionResolver接口的类,只要在Spring上下文中被声明,那么Spring就会将它用于拦截和处理MVC中出现的异常。

接口定义如下:

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

上面参数中的handler是具体抛出异常的Controller方法,Controller实例只是Spring MVC支持的一种handler类型,像HttpInvokerExporter和WebFlow Executor是它支持的另外两种handler类型。

Spring MVC默认创建了三个这样的Resolver,正是这三个Resolver实现了我们上面讲的这些操作。

  • ExceptionHandlerExceptionResolver 将任务未捕获的异常与controller-advices中注解了@ExceptionHandler的方法进行匹配。
  • ResponseStatusExceptionResolver 处理由@ResponseStatus注解了的异常,设定HTTP状态码
  • DefaultHandlerExceptionResolver 转换标准的Spring异常并将它们转换为HTTP状态码

它们按上面列出的顺序来进行处理,Spring通过类HandlerExceptionResolverComposite进行初始化。

注意resolveException方法的参数中并没有Model,这也是@ExceptionHandler方法中无法传入Model参数的原因。

如果有必要,你可以实现自己的HandlerExceptionResolver来设置自己的自定义异常处理系统。

还可以通过实现Spring的Ordered接口,定义Resolver的运行顺序。

SimpleMappingExceptionResolver

Spring长期以来都提供了一个简单但方便的HandlerExceptionResolver实现,或许你可能已经使用过它 - SimpleMappingExceptionResolver, 它提供以下选项:

  • 映射异常类名到视图名称 - 只需指定类名,不需要包。
  • 为任何其他地方未处理的异常指定默认错误页面
  • 记录消息(默认情况下不启用)。
  • 设置要添加到Model的exception属性的名称,以便可以在View中使用它

默认情况下,添加到Model的属性名为exception,可以设置为null以禁用。

需要记住,从@ExceptionHandler方法返回的视图页面不能访问异常信息,但是通过SimpleMappingExceptionResolver却可以做到。

以下是使用Java Configuration的配置方式:

@Configuration
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;
  }
  ...
}

defaultErrorView属性特别有用,因为它可确保任何未捕获的异常生成合适的应用程序定义的错误页面。大多数应用程序服务器的默认设置是显示Java堆栈跟踪 - 用户不想看到的内容。 Spring Boot使用了“Whitelabel Error Page”错误页面来做同样的事情。

扩展SimpleMappingExceptionResolver

扩展SimpleMappingExceptionResolver是很常见的,通常出于以下几个原因:

  • 可以使用构造函数直接设置属性,例如,启用异常日志记录并设置要使用的记录器
  • 通过覆盖buildLogMessage覆盖默认日志消息。 默认实现始终返回此固定文本: Handler execution resulted in exception
  • 通过重写doResolveException使错误视图页可用其他信息

简单的一个示例:

public class MyMappingExceptionResolver extends SimpleMappingExceptionResolver {
  public MyMappingExceptionResolver() {
    // Enable logging by providing the name of the logger to use
    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) {
    // Call super method to get the ModelAndView
    ModelAndView mav = super.doResolveException(req, resp, handler, ex);

    // Make the full URL available to the view - note ModelAndView uses
    // addObject() but Model uses addAttribute(). They work the same. 
    mav.addObject("url", request.getRequestURL());
    return mav;
  }
}

扩展ExceptionHandlerExceptionResolver

也可以扩展ExceptionHandlerExceptionResolver,通过重写它的doResolveHandlerMethodException方法,方法定义上差别不大,只不过它的参数是更具体的HandlerMethod而不是Handler。

为了确保你扩展的ExceptionHandlerExceptionResolver被使用,别忘了设置继承的order属性(例如在类的构造函数中)设置为小于MAX_INT的值,以便它在默认的ExceptionHandlerExceptionResolver实例之前运行。

REST处理

RESTful请求也可能出现异常,我们已经看到了如何返回标准HTTP错误响应代码和视图,但是如果要返回有关错误的信息,该怎么办?

首先定义一个错误信息结果类:

public class ErrorResult {

    private String url;
    private String code;
    private String message;

    public ErrorResult(String url,String code,String message){
    this.url = url;
    this.code = code;
    this.message = message;
    }
    //......
}

现在我们可以像处理@ResponseBody那样返回一个实例:

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BizException.class)
@ResponseBody
public ErrorResult handleBadRequest(HttpServletRequest req, BizException ex) {
    return new ErrorResult(req.getRequestURI(), ex.getErrorCode(), ex.getErrorMessage());
}

如何使用

Spring提供了几种方式供选择,那我们具体该怎么做呢?以下是一些经验法则:

  • 对于编写的自定义异常,考虑是否需要添加@ResponseStatus注解
  • 对于所有其他异常,在@ControllerAdvice类上实现@ExceptionHandler方法或使用SimpleMappingExceptionResolver来进行拦截。你可能已经为应用程序配置了SimpleMappingExceptionResolver,在这种情况下,添加新的异常类可能比实现@ControllerAdvice更容易。
  • 对于Controller内需要特定处理的异常,将@ExceptionHandler方法添加到该Controller内。
  • 避免在同一应用程序中混合使用这些选项,如果一个异常可以被多种方式处理,则可能无法获得你想要的结果。
  • Controller内的@ExceptionHandler方法执行顺序始终在@ControllerAdvice之前。
  • Spring并没有提供设置order或其它的方式来指定controller-advices的执行顺序。