Spring Boot REST API 最佳实践 - 第四章

在 “Spring Boot REST API 最佳实践” 系列教程的前几章中,我们已经学习了如何实现 CRUD 操作。在本章节教程中,我们将了解如何为 API 实现异常处理。

你可以在此 GitHub 仓库中找到本教程的示例代码。

第三章节 所述,如果 Controller 中的请求处理方法抛出异常,Spring Boot 将使用默认的异常处理机制进行处理并返回响应。

如果你只关心在抛出异常时返回正确的 HTTP 状态码,你可以异常类上使用 @ResponseStatus 注解来指定使用哪个 HTTP 状态码,而不是默认的 INTERNAL_SERVER_ERROR - 500

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class BookmarkNotFoundException extends RuntimeException {
    
}

// --------------------------------------

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public class InvalidBookmarkUrlException extends RuntimeException {

}

但大多数情况下,我们需要返回一个自定义的错误响应体,并使用适当的 HTTP 状态码。让我们来看看处理异常和返回错误响应的几种方法。

处理异常的不同方法

在 Spring Boot 中有多种方式来处理异常,你可以按需使用:

  • 在 Controller Handler 方法中处理异常。
  • 在 Controller 层使用 @ExceptionHandler
  • 使用 @RestControllerAdvice 处理全局异常。
    • Spring 的 HTTP API ProblemDetailsRFC 7807)。

在 Controller Handler 方法中处理异常

如果你只想控制特定 API 端点的异常处理逻辑,这是最好的方法。

例如,在 POST /api/bookmarks API 端点实现中,如果书签 URL 已经存在,BookmarkService 可能会抛出 DuplicateBookmarkException(重复书签异常)。如果书签 title 包含某些被屏蔽的文字,那么 BookmarkService 可能会抛出 BookmarkTitleNotAllowedException。因此,如果你想在 controller handler 方法中处理所有这些不同的异常,那么你可以采用这种方法。

BookmarkController.java

@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
    private final BookmarkService bookmarkService;
    //...
    //...

    @PostMapping
    ResponseEntity<BookmarkDTO> create(@RequestBody @Validated CreateBookmarkRequest request) {
        CreateBookmarkCommand cmd = new CreateBookmarkCommand(
                request.title(),
                request.url()
        );
        try {
            BookmarkDTO bookmark = bookmarkService.create(cmd);
            URI location = ServletUriComponentsBuilder
                    .fromCurrentRequest()
                    .path("/api/bookmarks/{id}")
                    .buildAndExpand(bookmark.id()).toUri();
            return ResponseEntity.created(location).body(bookmark);
        } catch(DuplicateBookmarkException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        } catch(BookmarkTitleNotAllowedException e) {
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
        }
    }
}

在 Controller 层使用 @ExceptionHandler

有时候,我们可能会在 controller 的多个 API handler 方法中以相同的方式处理相同类型的异常。例如,重复的 url 检查和 title 验证逻辑适用于创建和更新 API 端点。在这种情况下,我们可以在 controller 层使用 @ExceptionHandler,而不是在多个地方重复编写 try-catch 逻辑,具体如下:

@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
    private final BookmarkService bookmarkService;
    //...
    //...

    @PostMapping
    ResponseEntity<BookmarkDTO> create(@RequestBody @Validated CreateBookmarkRequest request) {
        CreateBookmarkCommand cmd = new CreateBookmarkCommand(request.title(), request.url());
        BookmarkDTO bookmark = bookmarkService.create(cmd);
        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest()
                .path("/api/bookmarks/{id}")
                .buildAndExpand(bookmark.id()).toUri();
        return ResponseEntity.created(location).body(bookmark);
    }

    @PutMapping("/{id}")
    void update(@PathVariable(name = "id") Long id,
                @RequestBody @Validated UpdateBookmarkRequest request) {
        UpdateBookmarkCommand cmd = new UpdateBookmarkCommand(id, request.title(), request.url());
        bookmarkService.update(cmd);
    }

    @ExceptionHandler(DuplicateBookmarkException.class)
    public ResponseEntity<ApiError> handleDuplicateBookmarkException(DuplicateBookmarkException e) {
        ApiError error = new ApiError(e.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    @ExceptionHandler(BookmarkTitleNotAllowedException.class)
    public ResponseEntity<ApiError> handleBookmarkTitleNotAllowedException(BookmarkTitleNotAllowedException e) {
        ApiError error = new ApiError(e.getMessage());
        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
    }
}

采用这种方法,就不必在 controller 的多个 handler 方法中重复异常处理逻辑。如果 create(...)update(...) 方法抛出 BookmarkTitleNotAllowedExceptionDuplicateBookmarkException,它们将由相应的 @ExceptionHandler 方法处理。

你也可以使用 @ExceptionHandler({DuplicateBookmarkException.class, BookmarkTitleNotAllowedException.class}) 在同一个 ExceptionHandler 方法中处理多种类型的异常。在这种情况下,ExceptionHandler 方法应使用 DuplicateBookmarkExceptionBookmarkTitleNotAllowedException 的通用基本异常类 Exception 作为方法参数。

使用 @RestControllerAdvice 处理全局异常

在上一节中,我们了解了如何在 controller 层使用 @ExceptionHandler。如果同一类型的异常可能发生在不同的 controller 中,而我们希望以相同的方式处理这些异常,该怎么办?在这种情况下,我们可以使用 @RestControllerAdvice 来处理全局异常。

创建 GlobalExceptionHandler 类,如下:

package com.sivalabs.bookmarks.api;

@RestControllerAdvice // 使用 @RestControllerAdvice 注解
public class GlobalExceptionHandler {
    
    @ExceptionHandler(DuplicateBookmarkException.class)
    public ResponseEntity<ApiError> handleDuplicateBookmarkException(DuplicateBookmarkException e) {
      ApiError error = new ApiError(e.getMessage());
      return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
  
    @ExceptionHandler(BookmarkTitleNotAllowedException.class)
    public ResponseEntity<ApiError> handleBookmarkTitleNotAllowedException(BookmarkTitleNotAllowedException e) {
      ApiError error = new ApiError(e.getMessage());
      return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
    }
}

通过这种方式,我们不必在多个 controller 中重复相同的 @ExceptionHandler 逻辑。

注意:

如果在 Controller 和 GlobalExceptionHandler 中都有一个 @ExceptionHandler 来处理同一类型异常,那么 Controller 级别的 @ExceptionHandler 方法将优先处理。

使用 Problem Details 响应错误

Spring 6 实现了 HTTP API 的 Problem Details 规范(RFC 7807)。

你可以阅读《Spring Boot 3:使用 HTTP API 的 Problem Details 进行错误响应》一文,了解如何使用 ProblemDetails API 处理异常。

我们可以通过添加 spring.mvc.problemdetails.enabled=true 属性或通过继承 ResponseEntityExceptionHandler 使用 @ControllerAdvice 创建全局异常 handler 来启用 RFC 7807 响应。

以下是如何使用 ProblemDetails API 以 RFC 7807 格式返回错误响应的快速演示。

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(BookmarkNotFoundException.class)
    ProblemDetail handleBookmarkNotFoundException(BookmarkNotFoundException e) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
        problemDetail.setTitle("Bookmark Not Found");
        problemDetail.setType(URI.create("https://api.bookmarks.com/errors/not-found"));
        problemDetail.setProperty("errorCategory", "Generic");
        problemDetail.setProperty("timestamp", Instant.now());
        return problemDetail;
    }
}

现在,当抛出未处理的 BookmarkNotFoundException 时,将返回以下响应:

{
  "type": "https://api.bookmarks.com/errors/not-found",
  "title": "Bookmark Not Found",
  "status": 404,
  "detail": "Bookmark with id=111 not found",
  "instance": "/api/bookmarks/111",
  "errorCategory": "Generic",
  "timestamp": "2023-08-30T05:21:59.828411Z"
}

通过继承 ResponseEntityExceptionHandler,你可以利用 Spring 的默认异常处理功能来处理各种常见异常,如 MethodArgumentNotValidExceptionBindExceptionMissingServletRequestParameterException 等。如果你想自定义其中任何一种异常的处理方法,那么你可以覆写这些方法并实现自己的逻辑。

你可以在此 GitHub 仓库 中找到本教程的示例代码。

总结

在 “Spring Boot REST API 最佳实践系列” 的最后一章中,我们了解了如何使用不同的方法实现异常处理。

希望本系列教程能够帮助到你。


参考:https://www.sivalabs.in/spring-boot-rest-api-best-practices-part-4/