分类
REST spring

Spring REST异常处理(自定义错误信息)

Error Handling for REST with Spring

本文辅助资源:

在开始以前你可以点击此处以获取一份与本教程相同的初始化代码。

1. 概述

本文将给出几种不同的方法,实现在Spring REST中处理异常的目的。

在Spring 3.2以前,在Spring MVC应用中主要有两种处理异常的方法:使用HandlerExceptionResolver@ExceptionHandler注解

由于上述两个异常处理方法存在诸多缺点,所以在Spring 3.2版本及以后Spring提供了更为优秀的@ControllerAdvice

在当前Spring 5版本中,又引入了一种快速、简单的的异常处理方式:使用ResponseStatusException异常处理类。

上述几种方法均在分层上做的很优秀:我们可以在应用的任意位置抛出异常,该异常最终将被成功的捕获并处理。

下面,我们正式介绍几种解决异常处理的几种方案:

2. 方法一 ─ 作用于控制器的 @ExceptionHandler

第一种解决方案作用于 @Controller级别 ─ 如下代码展示如何@ExceptionHandler注解定义一个异常处理器:

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

该方法有一个显著的缺点:该异常处理器仅在当前的控制器中生效。当然了,我们可以将此代码依次添加到每个控制器中(这明显违反了不造同一个轮子的原则)。

或者我们可以建立一个根控制器,在此控制器中添加上述异常处理代码,然后让所有的控制器都继承此根控制器。但是一旦我们这么做了,由于java的单继承机制,将使得以后若要对控制器进行变更的时候变得很难受。

接下来,让我们学习另外一种方法,该方法可以全局处理异常并且不对控制器进行入侵。

3. 方法2: HandlerExceptionResolver

第二种处理异常的方法是定义一个可以处理应用所抛出的所有异常的HandlerExceptionResolver。其可以处理所有异常的特性,决定了使用该方法可以在REST API应用中实现统一的异常处理机制(uniform exception handling mechanism )。

在自定义异常处理器以前,让我们看看学习下已有的实现。

3.1 ExceptionHandlerExceptionResolver

该异常处理器在Spring 3.1中被引入并被DispatcherServlet默认启用。它也是方法一中的@ExceptionHandler处理异常的核心组件。

3.2 DefaultHandlerExceptionResolver

该异常处理器在Spring 3.0中被引入并被DispatcherServlet默认启用。Spring使用该处理器处理了常见的客户端错误(比如常见的400、404、405、406错误)和服务器错误(500)。点此查看Spring用当前异常处理器处理的异常以及各个异常对应返回的状态。

    @GetMapping("/notSupported")
    public void notSupported() throws HttpRequestMethodNotSupportedException {
        throw new HttpRequestMethodNotSupportedException("get", "message");
    }

此处理器的缺点在于:当我们使用此机制处理异常时,异常发生后仅仅是给客户端发送了对应的状态码,而在响应的主体信息中什么都没有。

GET http://localhost:8080/foo/notSupported

HTTP/1.1 405 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Sun, 26 Jul 2020 02:20:40 GMT
Keep-Alive: timeout=60
Connection: keep-alive

<Response body is empty>

Response code: 405; Time: 127ms; Content length: 0 bytes

在REST API中,大多时候仅仅查看状态码是远远不够的。我们希望能够在发生错误的时候可以获取到更多的信息以帮助我们排查发生错误的真实原因。

虽然此问题可以通过借助一些其它的方法的解决,但我们并不推荐这么做。这也是为什么Spring 3.2引入了一个我们在本文稍后介绍的更好解决方案。

3.3. ResponseStatusExceptionResolver

此异常处理器同样在Spring 3.0中被引入并且被由DispatcherServlet默认启用。它的使用方法是:在自定义的异常上加上@ResponseStatus注解,以达到将捕获的异常转换为HTTP状态码的止的。

示例代码如下:

/**
* 当发生MyResourceNotFoundException异常时,返回HttpStatus.NOT_FOUND对应的状态码:404
*/
@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);
    }
}
    @GetMapping("resourceNotFound")
    public void resourceNotFound() {
        throw new MyResourceNotFoundException("resourceNotFound exception message");
    }

DefaultHandlerExceptionResolver,虽然该方法返回了主体信息,但主体可定义的空间有限,同样不能很发了的满足我们的需求。

GET http://localhost:8080/foo/resourceNotFound

HTTP/1.1 404 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 26 Jul 2020 02:26:10 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "timestamp": "2020-07-26T02:26:10.512+0000",
  "status": 404,
  "message": "resourceNotFound exception message",
  "path": "/foo/resourceNotFound",
  "locale": "en_CN"
}

Response code: 404; Time: 157ms; Content length: 152 bytes

3.4. SimpleMappingExceptionResolver 和 AnnotationMethodHandlerExceptionResolver

SimpleMappingExceptionResolver已步入老年,并不适合于REST服务。

AnnotationMethodHandlerExceptionResolver在Spring 3.0中被引用,通过@ExceptionHandler来处理异常,该方法在Spring 3.2中已被弃用,取而代之的是ExceptionHandlerExceptionResolver

在此不多做介绍。

3.5 自定义异常处理器

DefaultHandlerExceptionResolverResponseStatusExceptionResolver结合使用能够提供一个不错的异常处理机制。缺点我们前面也提过了:返回的主体信息的信息较空洞,客户端无法根据状态码以及较为空洞的主体信息来更精确的判断出错误产生的原因。

在单一后台多前台的情景中,我们应该根据用户请求Header信息中的Accept值来决定返回的数据格式:JSON或XML或HTML。

以下代码展示了如何创建一个自定义异常处理器并根据Header中的Accept来返回不同的错误信息:

 @Component
public class CustomExceptionHandlerExceptionResolver extends AbstractHandlerExceptionResolver {
    @Override
    protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                return handleIllegalArgument(
                        (IllegalArgumentException) ex, request, response);
            }
        } catch (Exception handlerException) {
            logger.warn("处理异常过程当中发生了异常");
        }
        return null;
    }

    private ModelAndView
    handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request, HttpServletResponse response)
            throws IOException {
        String accept = request.getHeader(MediaType.APPLICATION_JSON_VALUE);
        logger.info(accept);
        response.sendError(HttpServletResponse.SC_CONFLICT);
        return new ModelAndView();
    }
}

观察上述代码注释的部分不难看出,在异常的处理过程中我们获取了request请求头中的accept字段的值。有了这个值,便可以根据该值决定该异常是应该返回是JSON还是XML了。

比如我们获取到的accept值为 application/json,那么原则上(实际在Spring Boot中已经被统一处理)就可以返回JSON格式的数据。

上述代码中的返回值类型为ModelAndView,可以把它看成就是响应的主体,原则上(实际上Spring Boot忽略了返回值ModelAndView)自定义返回的 ModelAndView 便可以达到自定义返回主体内容的目的。

本方法不失为一种统一、简单的异常处理机制。但是它仍然有以下不同忽视的缺点:1. 该方法可以与低级的HtttpServletResponse交互(实际上除非真的有必要,我们应该尽量避免直接与HtttpServletResponse打交道)。2. ModelAndView已经成为了过去时,我们在新的项目中基本上已经看不到这种用法了。3. 在Spring Boot中,将忽略返值的ModelAndView。4. Spring自己使用的异常处理器并没有在此处理ModelAndView

4. 方法3 ---- @ControllerAdvice

Spring 3.2提供了一种使用 @ExceptionHandler @ControllerAdvice 注解进行全局异常处理的方法。该方法彻底弃用了过时的MVC模块,并且使用了ResponseEntity做为异常的返回值,该返回值兼顾了返回数据的灵活性及安全性。

@ControllerAdvice
public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
    @ExceptionHandler(value
            = {IllegalArgumentException.class, IllegalStateException.class, CustomException3.class})
    protected ResponseEntity<Object> handleConflict(
            IllegalArgumentException ex, WebRequest request) {
        // 可以调用handleExceptionInternal方法在主体中返回任意类型
        HashMap<String, String> hashMap = new HashMap<>(10);
        hashMap.put("key", "value");
        return handleExceptionInternal(ex, hashMap,
                new HttpHeaders(), HttpStatus.CONFLICT, request);
    }
}

@ControllerAdvice 将过去多个 @ExceptionHandlers 集中到一起,这使得我们可以在当前一个类中统一处理所有的异常。

它提从了一种即简单又灵活的机制:

  • 即可以设置返回的HTTP状态码,又可以设置返回的主体内容。
  • 在一个方法中可以同时处理多个异常,并且
  • 返回了安全、灵活的ResposeEntity响应。

值得注意的是 @ExceptionHandler 注解设置的异常将做为其注解对应方法的参数传入。如果某个异常被设置于 @ExceptionHandler 但却与对应方法中参数的类型不相匹配,在程序运行时就会报错。但是此错误不能被编辑译发现,因为编译器没有必要也没有理由发现此类错误。

比如上述代码中的方法变更为:

@ControllerAdvice
public class RestResponseEntityExceptionHandler 
  extends ResponseEntityExceptionHandler {
 
    @ExceptionHandler(value 
      = { IllegalArgumentException.class, IllegalStateException.class, CustomException3.class })
    // 参数的第一个类型由RuntimeException变更为IllegalArgumentException
    protected ResponseEntity<Object> handleConflict(
      IllegalArgumentException ex, WebRequest request) {
        HashMap<String, String> hashMap = new HashMap<>(10);
        hashMap.put("key", "value");
        return handleExceptionInternal(ex, hashMap,
                new HttpHeaders(), HttpStatus.CONFLICT, request);
    }
}

此时在运行过程中当发生CustomException3异常时,当会发生如下错误:

java.lang.IllegalStateException: Could not resolve parameter [0] in protected org.springframework.http.ResponseEntity<java.lang.Object> cn.baeldung.demo.RestResponseEntityExceptionHandler.handleConflict(java.lang.IllegalArgumentException,org.springframework.web.context.request.WebRequest): No suitable resolver

5. 方法4 ---- ResponseStatusException(Spring 5及以上版本)

Spring 5引入了ResponseStatusException异常类。我们可以利用该类很轻松的抛出带有状态码、错误原因的异常:

    @Autowired
    FooService fooService;

    @GetMapping(value = "/{id}")
    public Foo findById(@PathVariable Long id) {
        try {
            return this.fooService.findById(id);
        } catch (FooNotFoundException exc) {
            throw new ResponseStatusException(
                    HttpStatus.NOT_FOUND, "Foo Not Found", exc);
        }
    }

使用ResponseStatusException的优点可以简单的归纳为以下几点:

  • 快速生成原型:可以很快的完成基本的解决方案,这对生成 demo时比较有用。
  • 可以应用单一的异常类型,返回不同的状态码。可以不必为不同的状态的定义不同的异常类型了。
  • 由于可以应用单一异常,所以省去了创建过多的自定义异常的过程。
  • 在控制器中有效的控制该方法异常的返回值,使得控制器在对异常返回拥有更多的控制权。

它的缺点是:

  • 由于没有进行统一的异常处理,各个控制器直接返回异常的规范很难相同,程序越大统一规范就越难。随着项目的增大,对异常的统一处理将会变得越来越头疼。
  • 在控制器的每个方法中都增加try catch语句,而且大多数数的语句也将一模一样,这造成的过多的语句冗余。

当然了,我们完全可以根据项止的实际需求,将上述的异常处理方法结合来使用。

比如说我们可以实现一个全局的 @ControllerAdvice ,同时也可以在某些控制器中抛出 ResponseStatusException

如果在项目中我们使用了不止一种方法来处理异常,那么需要特别注意的是:由于某种特定的异常最终仅会被一个方法来处理,所以在进行异常处理的时需要保证某种异常仅仅被定义在一个方法之中。

如果你想获取有关ResponseStatusException的更多信息,或许这篇ResponseStatusException入门教程能够帮到你。

6. 在Spring Security处理禁止访问(Access Denied)

当用户访问其没有权限的资源时会发生禁止访问(Access Denied)错误。

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>

6.1 MVC ---- 自定义错误页

首页让我们看看Sring MVC是怎么处理禁止访问的错误页的:

使用XML进行配置如下:

<http>
    <intercept-url pattern="/admin/*" access="hasAnyRole('ROLE_ADMIN')"/>   
    ... 
    <access-denied-handler error-page="/my-error-page" />
</http>

或者使用JAVA配置如下:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/admin/*").hasAnyRole("ADMIN")
            ...
            .exceptionHandling().accessDeniedPage("/my-error-page");
    }

此时,如果用户尝试访问其没有权限的资源时,则会被重定向至 "/my-error-page".

6.2 自定义禁止访问处理器

接下来,让我们看看如何定义一个自定义禁止访问处理器:

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
 
    @Override
    public void handle
      (HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) 
      throws IOException, ServletException {
        response.sendRedirect("/my-error-page");
    }
}

使用XML配置如下:

<http>
    <intercept-url pattern="/admin/*" access="hasAnyRole('ROLE_ADMIN')"/> 
    ...
    <access-denied-handler ref="customAccessDeniedHandler" />
</http>

或者使用JAVA配置如下:

@Autowired
private CustomAccessDeniedHandler accessDeniedHandler;
 
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .antMatchers("/admin/*").hasAnyRole("ROLE_ADMIN")
        ...
        .and()
        .exceptionHandling().accessDeniedHandler(accessDeniedHandler)
}

此时,我们便可以在CustomAccessDeniedHandler按自己的想法来设置错误响应信息或是如上代码一样将用户引导至重定向的页面了。

@RestController
public class ErrorController {

    @GetMapping("/my-error-page")
    public String error() {
        return "this is my-error-page";
    }
}

6.3. REST以及方法(Method)级别的异常控制

最后,让我们看看如何处理Spring Security中的 @PreAuthorize@PostAuthorize, and @Secure三个权限注解。

由于上述三个注解在验证失败时将对应抛出了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请求,则返回返回json格式的错误信息:

{
  "timestamp": "2020-07-25T01:25:52.192+0000",
  "status": 404,
  "error": "Not Found",
  "message": "No message available",
  "path": "/SpringSecurity/preAuthorize1212312"
}

同时,可以通过配置选择来改变上述配置:

  • server.error.whitelabel.enabled 此值设置为false将禁用空白错误页面,而是返回servlet(比如tomcat)的html错误信息。
  • 同时将server.error.include-stacktrace 此值设置为always,将在报错的信息中显示异常的详细信息。

我们还可以自定义一个错误界面以替代Whitelabel页面。也可以通过继承DefaultErrorAttributes来非常轻松地自定义返回的异常信息:

@Component
public class MyCustomErrorAttributes extends DefaultErrorAttributes {
 
    @Override
    public Map<String, Object> getErrorAttributes(
      WebRequest webRequest, boolean includeStackTrace) {
        Map<String, Object> errorAttributes = 
          super.getErrorAttributes(webRequest, includeStackTrace);
        // 添加local信息
        errorAttributes.put("locale", webRequest.getLocale()
            .toString());
        // 移除原error信息
        errorAttributes.remove("error");
 
        //...
 
        return errorAttributes;
    }
}

如果我们想进一步的定义(或是覆盖)应用程序在上下文中处理错误的方法,我们还可以注册一个ErrorController bean。

同样的,我们可以使用Spring Boot提供的BasicErrorController来快速达到这一目标。比如我们想自定义XML格式的错误返回内容,则可以使用 @RequestMapping 并指向 application/xml :

@Component
public class MyErrorController extends BasicErrorController {
    public MyErrorController(ErrorAttributes errorAttributes) {
        super(errorAttributes, new ErrorProperties());
    }

    @RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public ResponseEntity<Map<String, Object>> jsonError(HttpServletRequest request) {
        // 以下开始自定义返回内容:在返回值中添加test字段
        Map<String, Object> map = super.getErrorAttributes(request, isIncludeStackTrace(request, MediaType.ALL));
        map.put("test", "test value");
        return new ResponseEntity<Map<String, Object>>(
                map, getStatus(request)
        );
    }
}

8. 总结

本文讨论了几种Spring REST API处理异常的几种方法。其中涵盖了Spring 3.0、3.1、3.2、4.X、5.X的内容。希望能你能有所帮助。