在 Spring 应用中为 REST API 实现异常处理

1、概览

本文将地带你了解如何在 Spring 中为 REST API 实现异常处理。

在 Spring 3.2 之前,在 Spring MVC 应用中处理异常的两种主要方法是 HandlerExceptionResolver@ExceptionHandler 注解。这两种方法都有一些明显的缺点。

自 3.2 以来,可以使用 @ControllerAdvice 注解来解决前两种解决方案的局限性,并促进整个应用中统一的异常处理。

Spring 5 引入了 ResponseStatusException 类,一种在 REST API 中进行基本错误处理的快速方法。

所有这些都有一个共同点:它们都很好地处理了关注点的分离。应用通常可以抛出异常来表示某种失败,然后再单独进行处理。

2、解决方案 1:Controller 级的 @ExceptionHandler

第一种解决方案适用于 @Controller 层面。定义一个处理异常的方法,并用 @ExceptionHandler 进行注解:

public class FooController{
    
    //...
    @ExceptionHandler({ CustomException1.class, CustomException2.class })
    public void handleException() {
        //
    }
}

这种方法有一个很大的缺点:@ExceptionHandler 注解方法仅对特定 Controller 有效,而不是对整个应用全局有效。当然,可以将其添加到每个 Controller 中,但这并不适合作为通用的异常处理机制。

也可以通过让所有 Controller 都继承一个 Base Controller 类来绕过这一限制。

然而,对于某些原因无法实现上述方法的应用来说,这种解决方案可能会成为一个问题。例如,Controller 可能已经从另一个 Base 类继承而来,而该 Base 类可能在另一个 Jar 中或不可直接修改,或者 Controller 本身不可直接修改。

3、解决方案 2:HandlerExceptionResolver

第二种解决方案是定义一个 HandlerExceptionResolver。这将解析应用抛出的任何异常。它还允许在 REST API 中实现统一的异常处理机制。

在使用自定义解析器之前,先来了解一下现有的实现。

3.1、ExceptionHandlerExceptionResolver

该 Resolver 在 Spring 3.1 中引入,并在 DispatcherServlet 中默认启用。这实际上是前面介绍的 @ExceptionHandler 机制如何工作的核心组件。

3.2、DefaultHandlerExceptionResolver

该 Resolver 在 Spring 3.0 中引入,默认在 DispatcherServlet 中启用。

它用于将标准 Spring 异常解析为相应的 HTTP 状态码,即客户端错误 4xx 和服务器错误 5xx 状态码。以下是它所处理的 Spring 异常的完整列表,以及它映射到的 HTTP 状态码。

Exception HTTP Status Code
BindException 400 (Bad Request)
ConversionNotSupportedException 500 (Internal Server Error)
HttpMediaTypeNotAcceptableException 406 (Not Acceptable)
HttpMediaTypeNotSupportedException 415 (Unsupported Media Type)
HttpMessageNotReadableException 400 (Bad Request)
HttpMessageNotWritableException 500 (Internal Server Error)
HttpRequestMethodNotSupportedException 405 (Method Not Allowed)
MethodArgumentNotValidException 400 (Bad Request)
MissingServletRequestParameterException 400 (Bad Request)
MissingServletRequestPartException 400 (Bad Request)
NoSuchRequestHandlingMethodException 404 (Not Found)
TypeMismatchException 400 (Bad Request)

虽然它能正确设置响应的状态码,但有一个局限性,那就是它不能为响应体设置任何消息。对于 REST API 来说,状态代码确实不足以向客户端提供足够的信息,因此响应还必须有一个正文,以便应用提供有关故障的其他信息。

这虽然可以通过配置视图解析器和通过 ModelAndView 渲染错误内容来解决,但该解决方案显然不是最佳的。

3.3、ResponseStatusExceptionResolver

该 Resolver 也在 Spring 3.0 中引入,并在 DispatcherServlet 中默认启用。

它的主要职责是在自定义异常中使用 @ResponseStatus 注解,并将这些异常映射到 HTTP 状态码。

这样的自定义异常可能如下所示:

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class MyResourceNotFoundException extends RuntimeException {
    public MyResourceNotFoundException() {
        super();
    }
    public MyResourceNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
    public MyResourceNotFoundException(String message) {
        super(message);
    }
    public MyResourceNotFoundException(Throwable cause) {
        super(cause);
    }
}

DefaultHandlerExceptionResolver 相同,该 Resolver 处理响应体的方式受到限制 - 它确实会在响应上映射状态码,但 Body (响应体)仍为 null

3.4、自定义 HandlerExceptionResolver

DefaultHandlerExceptionResolverResponseStatusExceptionResolver 的组合可为 Spring RESTful 服务提供良好的错误处理机制。缺点是,如前所述,无法控制响应体。

理想情况下,我们希望能够响应 JSONXML,具体取决于客户期望的格式(通过 Accept Header)。

创建一个新的自定义异常解析器。

@Component
public class RestResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver {

    @Override
    protected ModelAndView doResolveException(
      HttpServletRequest request, 
      HttpServletResponse response, 
      Object handler, 
      Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                return handleIllegalArgument(
                  (IllegalArgumentException) ex, response, handler);
            }
            ...
        } catch (Exception handlerException) {
            logger.warn("Handling of [" + ex.getClass().getName() + "] 
              resulted in Exception", handlerException);
        }
        return null;
    }

    private ModelAndView 
      handleIllegalArgument(IllegalArgumentException ex, HttpServletResponse response) 
      throws IOException {
        response.sendError(HttpServletResponse.SC_CONFLICT);
        String accept = request.getHeader(HttpHeaders.ACCEPT);
        ...
        return new ModelAndView();
    }
}

这里需要注意的一个细节是,可以访问 request 本身,因此可以考虑客户端发送的 Accept Header 的值。

例如,如果客户端要求使用 application/json,那么在出现错误的情况下,就需要确保返回一个用 application/json 编码的响应体。

另一个重要的实现细节是返回了一个 ModelAndView 对象,这是响应 Body,它允许我们对其进行必要的设置。

这种方法为 Spring REST 服务的错误处理提供了一致且易于配置的机制。

然而,它也有一些限制:它与底层的 HttpServletResponse 进行交互,并适用于使用 ModelAndView 的旧 MVC 模型,因此仍有改进的空间。

4、解决方案 3:@ControllerAdvice

Spring 3.2 为带有 @ControllerAdvice 注解的全局 @ExceptionHandler 提供了支持。

这使得可以摆脱旧的 MVC 模型,使用 ResponseEntity 以及 @ExceptionHandler 的类型安全性和灵活性来实现一种机制。

@ControllerAdvice
public class RestResponseEntityExceptionHandler 
  extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value 
      = { IllegalArgumentException.class, IllegalStateException.class })
    protected ResponseEntity<Object> handleConflict(
      RuntimeException ex, WebRequest request) {
        String bodyOfResponse = "This should be application specific";
        return handleExceptionInternal(ex, bodyOfResponse, 
          new HttpHeaders(), HttpStatus.CONFLICT, request);
    }
}

@ControllerAdvice 注解允许将之前多个分散的 @ExceptionHandler 整合为一个单一的全局错误处理组件。

实际机制非常简单,但也非常灵活:

  • 它能够完全控制响应体和状态码。
  • 它将多个异常映射到同一个方法中,以便一起处理。
  • 它充分利用了较新的 RESTful ResposeEntity 响应。

这里要注意的一点是,用 @ExceptionHandler 声明的异常要与作为方法参数的异常相匹配。

如果它们不匹配,编译不会异常,Spring 启动也不会异常。但是,当异常在运行时实际抛出时,异常解析机制将失败,并显示如下错误消息:

java.lang.IllegalStateException: No suitable resolver for argument [0] [type=...]
HandlerMethod details: ...

5、解决方案 4:ResponseStatusException(Spring 5 及其以上)

Spring 5 引入了 ResponseStatusException 类。

我们可以创建一个实例,提供 HttpStatus 以及可选的 reasoncause

@GetMapping(value = "/{id}")
public Foo findById(@PathVariable("id") Long id, HttpServletResponse response) {
    try {
        Foo resourceById = RestPreconditions.checkFound(service.findOne(id));

        eventPublisher.publishEvent(new SingleResourceRetrievedEvent(this, response));
        return resourceById;
     }
    catch (MyResourceNotFoundException exc) {
         throw new ResponseStatusException(
           HttpStatus.NOT_FOUND, "Foo Not Found", exc);
    }
}

使用 ResponseStatusException 有什么好处?

  • 非常适合原型开发:可以快速实现一个基本的解决方案。
  • 一种类型,多种状态代码: 一种异常类型可导致多种不同的响应。与 @ExceptionHandler 相比,这减少了紧密耦合。
  • 不必创建那么多自定义异常类
  • 由于可以通过编程式创建异常,因此对异常处理的控制能力更强。

弊端呢?

  • 没有统一的异常处理方式:相比之下,@ControllerAdvice 提供了一种全局的方法,更难以强制执行一些应用范围的约定。
  • 代码重复:可能会在多个 Controller 中重复编写代码。

注意,在一个应用中可以结合不同的方法。

例如,可以在全局范围内实现 @ControllerAdvice,但也可以在本地范围内实现 ResponseStatusException

不过,需要小心谨慎: 如果可以用多种方式处理同一异常,可能会发现一些意外的行为。一种可能的约定俗成的做法是,总是以一种方式处理一种特定的异常

6、处理 Spring Security 中的 “Access Denied”(拒绝访问)

当经过身份认证的用户试图访问他没有足够权限访问的资源时,就会发生拒绝访问的情况。

6.1、REST 和方法级 Security

最后,来看看如何处理方法级 Security 注解 @PreAuthorize@PostAuthorize@Secure 抛出的 “Access Denied” 异常。

当然,还是使用之前讨介绍的全局异常处理机制来处理 AccessDeniedException

@ControllerAdvice
public class RestResponseEntityExceptionHandler 
  extends ResponseEntityExceptionHandler {

    @ExceptionHandler({ AccessDeniedException.class })
    public ResponseEntity<Object> handleAccessDeniedException(
      Exception ex, WebRequest request) {
        return new ResponseEntity<Object>(
          "Access denied message here", new HttpHeaders(), HttpStatus.FORBIDDEN);
    }
    
    ...
}

7、Spring Boot 中的支持

Spring Boot 提供了一个 ErrorController 实现,以合理的方式处理错误。

简而言之,它为浏览器提供一个基础的错误页面(又称 “Whitelabel Error Page”),并为 RESTful、非 HTML 请求提供一个 JSON 响应:

{
    "timestamp": "2019-01-17T16:12:45.977+0000",
    "status": 500,
    "error": "Internal Server Error",
    "message": "Error processing the request!",
    "path": "/my-endpoint-with-exceptions"
}

Spring Boot 允许使用属性配置这些功能:

  • server.error.whitelabel.enabled:可用于禁用 “Whitelabel Error Page”,并依靠 servlet 容器提供 HTML 错误消息。
  • server.error.include-stacktrace:设置为 always 时,在 HTML 和 JSON 默认响应中包含栈跟踪信息。
  • server.error.include-message:自 2.3 版本起,Spring Boot 隐藏响应中的 message 字段,以避免泄露敏感信息;可以使用该属性并将其设置为 always 来启用该功能。

除了这些属性外,还可以为 /error 提供自己的视图解析映射,覆盖 “Whitelabel Page”。

还可以通过在 Context 中包含一个 ErrorAttributes Bean 来自定义要在响应中显示的属性。可以继承 Spring Boot 提供的 DefaultErrorAttributes 类来简化操作:

@Component
public class MyCustomErrorAttributes extends DefaultErrorAttributes {

    @Override
    public Map<String, Object> getErrorAttributes(
      WebRequest webRequest, ErrorAttributeOptions options) {
        Map<String, Object> errorAttributes = 
          super.getErrorAttributes(webRequest, options);
        errorAttributes.put("locale", webRequest.getLocale()
            .toString());
        errorAttributes.remove("error");

        //...

        return errorAttributes;
    }
}

如果想进一步定义(或重写)应用如何处理特定内容类型的错误,可以注册 ErrorController Bean。

同样,可以继承 Spring Boot 提供的默认 BasicErrorController 来帮助我们处理异常。

例如,假设想自定义应用如何处理 XML 端点中触发的错误。所要做的就是使用 @RequestMapping 定义一个 public 方法,并指定它生成的是 application/xml 媒体类型(Media Type):

@Component
public class MyErrorController extends BasicErrorController {

    public MyErrorController(
      ErrorAttributes errorAttributes, ServerProperties serverProperties) {
        super(errorAttributes, serverProperties.getError());
    }

    @RequestMapping(produces = MediaType.APPLICATION_XML_VALUE) // application/xml
    public ResponseEntity<Map<String, Object>> xmlError(HttpServletRequest request) {
        
    // ...

    }
}

注意:这里仍然依赖于可能在 application.properties 中定义的 server.error. 属性,这些属性绑定在 ServerProperties Bean 上。

8、总结

本文介绍了在 Spring 应用中为 REST API 实现异常处理的几种方法。


Ref:https://www.baeldung.com/exception-handling-for-rest-with-spring