Category: REST
本文辅助资源:
- github源码:https://github.com/baeldung-cn/exception-handling-for-rest-with-spring
- 同步视频:https://www.bilibili.com/video/BV1oT4y1L7Mt/
- 参考原文:https://www.baeldung.com/exception-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 自定义异常处理器
DefaultHandlerExceptionResolver与ResponseStatusExceptionResolver结合使用能够提供一个不错的异常处理机制。缺点我们前面也提过了:返回的主体信息的信息较空洞,客户端无法根据状态码以及较为空洞的主体信息来更精确的判断出错误产生的原因。
在单一后台多前台的情景中,我们应该根据用户请求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的内容。希望能你能有所帮助。
资源列表:
- GITHUB code demo:https://github.com/codedemo-club/entity-to-and-from-dto-for-a-java-spring-application
- B站同步视频:https://www.bilibili.com/video/BV1QC4y1b7GF/
- 参考原文:https://www.baeldung.com/entity-to-and-from-dto-for-a-java-spring-application
在正式开始前,可以点以下链接获取一份与本文相同的初始化代码。
1. 概述
在实际的开发中,直接将内部的实体返回给客户端并不是明智的选择。这不仅直接暴露了项目的数据结构,还需要使用JsonView定制返回的数据字段,最主要的一旦实体结构发生变更,将直接影响发布的API文档。
本文将阐述如何将Spring内部实体转换为外部数据传输对象(DTOs ---- Data Transfer Objects)的方法,使用该方法返回数据很好的规避上述问题。
2. Model Mapper
我们将使用一款名为ModelMapper的entity-DTO转换库来完成这一功能。
首先打开pom.xml并添加如下依赖:
<dependency> <groupId>org.modelmapper</groupId> <artifactId>modelmapper</artifactId> <version>2.3.5</version> </dependency>
如果你想用最新的版本的ModelMapper,请点击查看。
接下来在Spring配置类中如下定义ModelMapper bean。
@Bean public ModelMapper modelMapper() { return new ModelMapper(); }
3. DTO
接下来让我们建立第一个DTO类 -- Post DTO
public class PostDto { private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm"); private Long id; private String title; private String url; private String date; private UserDto user; public Date getSubmissionDateConverted(String timezone) throws ParseException { dateFormat.setTimeZone(TimeZone.getTimeZone(timezone)); return dateFormat.parse(this.date); } public void setSubmissionDate(Date date, String timezone) { dateFormat.setTimeZone(TimeZone.getTimeZone(timezone)); this.date = dateFormat.format(date); } // 以下省略标准的getters与setters }
在上述代码中我们使用了两个时间相关的方法来对Date日期类型进行转换(将Date类型的日期转换为String类型的日期),该方法为服务端的日期格式(Date类型的日期)与客户端的日期格式(String类的日期)搭起一做转换的桥梁。
- getSubmissionDateConverted()方法将String类型的日期根据时区转换为Date类型的日期。该方法将用于持久化Post实体时。
- setSubmissionDate()方法将Date类型的日期依据时区转换为String类型的日期。
4. 服务层
服务层(比如PostServiceImpl.java)的示例代码如下:
@Service public class PostServiceImpl implements PostService { @Autowired PostRepository postRepository; @Override public List<Post> findAll() { return (List<Post>) this.postRepository.findAll(); } @Override public Post save(Post post) { return this.postRepository.save(post); } @Override public Post findById(Long id) { return this.postRepository.findById(id).get(); } }
接下来,我们将重点放在调用服务层(service layer)的控制器层(controller layer),这是因为实际的数据转换将发生在控制器层。
5. 控制器层
下面,让我们看一个标准的控制器实现,为Post资源暴露一个简单的REST接口。
以下代码中将展示一些简单的CRUD操作:创建、更新、获取某条数据以及获取全部数据。CRUD是常规的操作,所以在阅读以下代码时,可以重点关注实体与数据传输对象的转换过程(Entity-DTO conversion):
@RestController public class PostRestController { @Autowired PostService postService; @Autowired ModelMapper modelMapper; @PostMapping public PostDto save(@RequestBody PostDto postDto) throws ParseException { Post post = this.convertToEntity(postDto); return this.convertToDto(this.postService.save(post)); } @GetMapping("/{id}") public PostDto getById(@PathVariable Long id) { return this.convertToDto(this.postService.findById(id)); } @GetMapping public List<PostDto> getAll() { return this.postService.findAll() .stream().map(this::convertToDto) .collect(Collectors.toList()); } }
以下代码展示了实体Post向数据传输对象PostDto的转换过程:
private PostDto convertToDto(Post post) { PostDto postDto = modelMapper.map(post, PostDto.class); postDto.setSubmissionDate(post.getSubmissionDate(), "GMT+8:00"); return postDto; }
以下代码展示了数据传输对象PostDto像实体Post的转换过程:
private Post convertToEntity(PostDto postDto) throws ParseException { Post post = modelMapper.map(postDto, Post.class); post.setSubmissionDate(postDto.getSubmissionDateConverted( "GMT+8:00")); return post; }
从以上代码不难看出,ModelMapper使得实体与数据传输对象(DTO)之间的转换变得即快又简单 ---- 我们使用了ModelMapper的map接口,无需编写任何转换的代码便实现了数据转换。
6. 单元测试
最后,让我们做个简单的测试以保障本文前面涉及代码的正确性:
public class PostDtoUnitTest { private ModelMapper modelMapper = new ModelMapper(); @Test public void whenConvertPostEntityToPostDto_thenCorrect() { Post post = new Post(); post.setId(1L); post.setTitle(randomAlphabetic(6)); post.setUrl("www.test.com"); PostDto postDto = modelMapper.map(post, PostDto.class); assertEquals(post.getId(), postDto.getId()); assertEquals(post.getTitle(), postDto.getTitle()); assertEquals(post.getUrl(), postDto.getUrl()); } @Test public void whenConvertPostDtoToPostEntity_thenCorrect() { PostDto postDto = new PostDto(); postDto.setId(1L); postDto.setTitle(randomAlphabetic(6)); postDto.setUrl("www.test.com"); Post post = modelMapper.map(postDto, Post.class); assertEquals(postDto.getId(), post.getId()); assertEquals(postDto.getTitle(), post.getTitle()); assertEquals(postDto.getUrl(), post.getUrl()); } }
7. 总结
本文展示了使用ModelMapper来完成实体与数据传输对象(DTO) 之间的快速转换方法,相较于传统的转换方法,在使用该方法进行Entity-DTO转换时可以大幅减少代码的书写量。
尺有所短、寸有所长,本文展示的方法并不能很好的适应实体字段名称、实体结构变更的情况,在使用本方法的过程中还应该结合良好的控制器单元测试来保障项目的开发质量。