Spring 中 REST 的错误处理

转自Error Handling for REST with Spring

1. 概述

本教程将阐述如何在Spring中为REST API实现异常处理。我们还将回顾一些历史概述,并查看不同版本引入的新选项。

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

自从3.2以来,我们有了@ControllerAdvice注解,以解决之前两种解决方案的局限性,并在整个应用程序中推广统一的异常处理。

现在,Spring 5引入了ResponseStatusException——这是在我们的REST API中进行基本错误处理的快速方法。

所有这些方法都有一个共同点:它们非常好地处理了关注点分离。应用程序可以正常地抛出异常来指示某种失败,然后这些异常将被单独处理。

最后,我们将看看Spring Boot提供了哪些功能,并了解如何根据需要进行配置。

2. 解决方案1:基于Controller的@ExceptionHandler

第一种解决方案适用于@Controller级别。我们将定义一个方法来处理异常,并用@ExceptionHandler注解进行注释:

1
2
3
4
5
6
7
8
public class FooController {

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

这种方法有一个主要缺点:@ExceptionHandler注解的方法仅对该特定的Controller有效,而不是全局适用于整个应用程序。当然,将其添加到每个Controller中使得它不适合作为通用的异常处理机制。

我们可以通过让所有Controller扩展一个基类Controller来解决这个限制。

然而,对于某些应用程序来说,这种解决方案可能有问题,因为某种原因,可能无法让所有Controller都继承自一个基类。例如,Controller可能已经继承自另一个基类,该基类可能在另一个jar中或者不可直接修改,或者Controller本身可能不可直接修改。

接下来,我们将看另一种解决异常处理问题的方法,这种方法是全局的,并且不需要对现有的Controller进行任何更改。

3. 解决方案2:HandlerExceptionResolver

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

在采用自定义解析器之前,让我们先了解现有的实现。

3.1. ExceptionHandlerExceptionResolver

这个解析器是在Spring 3.1中引入的,并且在DispatcherServlet中默认启用。这实际上是前面介绍的@ExceptionHandler机制的核心组件。

3.2. DefaultHandlerExceptionResolver

这个解析器是在Spring 3.0中引入的,并且在DispatcherServlet中默认启用。

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

虽然它可以正确设置响应的状态码,但它不会设置响应的body。对于REST API来说,仅仅返回状态码是不够的,还需要响应体,以允许应用程序提供有关失败的其他信息。

这可以通过配置视图解析并通过ModelAndView来呈现错误内容来解决,但这个解决方案显然并不理想。因此,Spring 3.2引入了一个更好的选项,我们将在后面的部分中讨论。

3.3. ResponseStatusExceptionResolver

这个解析器也是在Spring 3.0中引入的,并且在DispatcherServlet中默认启用。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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一样,这个解析器在处理响应体时有限制,它会将状态码映射到响应上,但body仍然为null。

3.4. Custom HandlerExceptionResolver

DefaultHandlerExceptionResolverResponseStatusExceptionResolver的组合在为Spring RESTful服务提供了良好的错误处理机制方面做得很好。然而,正如前面提到的,对于响应体没有控制权。

理想情况下,我们希望能够根据客户端请求的格式(通过Accept头)输出JSON或XML。

这就有了创建新的自定义异常解析器的理由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@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();
}
}

这里需要注意的一点是,我们可以访问请求本身,因此我们可以考虑客户端发送的Accept头的值。

例如,如果客户端要求application/json,那么在出现错误时,我们希望确保返回一个用application/json编码的响应体。

另一个重要的实现细节是我们返回一个ModelAndView——这是响应的主体,它将允许我们设置所需的任何内容。

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

它的局限性在于它与低级别的HtttpServletResponse交互,并适用于使用ModelAndView的旧MVC模型,因此仍有改进的空间。

4. 解决方案3:@ControllerAdvice

Spring 3.2引入了@ControllerAdvice注解,支持全局@ExceptionHandler

这使得我们可以将之前分散在各处的@ExceptionHandler集中到一个单一的全局错误处理组件中。

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

  • 它让我们完全控制响应的body以及状态码。
  • 它提供了将多个异常映射到同一个方法并进行统一处理的功能。
  • 它充分利用了较新的RESTful ResposeEntity响应。

需要注意的一点是,将在@ExceptionHandler中声明的异常与方法参数中使用的异常匹配。

如果它们不匹配,编译器不会报错,Spring也不会报错。

然而,当在运行时实际抛出异常时,异常解析机制将失败,抛出java.lang.IllegalStateException异常。

5. 解决方案4:ResponseStatusException(Spring 5及以上版本)

Spring 5引入了ResponseStatusException类。

我们可以创建一个ResponseStatusException的实例,提供一个HttpStatus和可选的reason和cause:

1
2
3
4
5
6
7
8
9
10
11
@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相比,很难强制实施一些全局应用程序约定。
  • 代码重复:我们可能会发现在多个控制器中复制代码。

我们还应该注意到,可以在一个应用程序中组合不同的方法。

例如,我们可以全局实现@ControllerAdvice,但也在本地实现ResponseStatusException。 但是,我们需要小心:如果相同的异常可以以多种方式处理,可能会遇到一些令人惊讶的行为。一个可能的约定是始终以一种方式处理一种特定类型的异常。

有关更多详细信息和示例,请参阅我们关于ResponseStatusException的教程。

6. 处理Spring Security中的访问被拒绝

访问被拒绝发生在经过身份验证的用户尝试访问他没有足够权限访问的资源时。

6.1. REST和方法级安全

最后,让我们看看如何处理方法级安全注解(@PreAuthorize,@PostAuthorize和@Secure)引发的访问被拒绝异常。

当然,我们将使用前面讨论过的全局异常处理机制来处理AccessDeniedException:

1
2
3
4
5
6
7
8
9
10
11
@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错误页面),并为RESTful的非HTML请求提供了JSON响应:

1
2
3
4
5
6
7
{
"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错误页面,并依赖于servlet容器提供HTML错误消息。
  • server.error.include-stacktrace:使用always值;在HTML和JSON默认响应中包含堆栈跟踪。
  • server.error.include-message:从2.3

版本开始,Spring Boot隐藏响应中的message字段,以避免泄漏敏感信息;我们可以使用always值启用它。

除了这些属性外,我们可以在上下文中提供一个ErrorAttributes bean,以定制我们希望在响应中显示的属性。我们可以扩展Spring Boot提供的DefaultErrorAttributes类来简化操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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注解的公共方法,并声明它产生application/xml媒体类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class MyErrorController extends BasicErrorController {

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

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

// ...

}
}

注意:在这里,我们仍然依赖于我们可能已经在项目中定义的server.error.* Boot属性,这些属性绑定到ServerProperties bean。

8. 结论

本文讨论了在Spring中为REST API实现异常处理的几种方法,从较早的机制开始,然后继续到Spring 3.2的支持以及4.x和5.x。

与往常一样,本文中的代码可在GitHub上找到。

有关与Spring Security相关的代码,请查看spring-security-rest模块。