Spring Boot 3:使用 HTTP API 的 Problem Details 进行错误响应

Spring Framework 6 实现了 “Problem Details for HTTP APIs(HTTP API 的问题细节规范)”(RFC 7807)。在本文中,我们将学习如何在 SpringBoot 3 REST API(使用 Spring Framework 6)中处理异常,并使用 ProblemDetails API 提供错误响应。

假设我们有以下 REST API 端点,用于创建书签(bookmark)和按 ID 获取书签。

@RestController
@RequestMapping("/api/bookmarks")
@RequiredArgsConstructor
public class BookmarkController {
    private final BookmarkService service;

    @PostMapping
    public ResponseEntity<Bookmark> save(@Valid @RequestBody Bookmark payload) {
        Bookmark bookmark = new Bookmark(null, payload.title(), payload.url(), Instant.now());
        return ResponseEntity.status(HttpStatus.CREATED).body(service.save(bookmark));
    }
  
    @GetMapping("/{id}")
    public ResponseEntity<Bookmark> getBookmarkById(@PathVariable Long id) {
        return service.getBookmarkById(id)
                .map(ResponseEntity::ok)
                .orElseThrow(() -> new BookmarkNotFoundException(id));
    }
}

而且,BookmarkNotFoundException 是一种典型的 RuntimeException,具体如下:

public class BookmarkNotFoundException extends RuntimeException {

    public BookmarkNotFoundException(Long bookmarkId) {
        super("Bookmark with id: "+ bookmarkId+" not found");
    }
}

现在,当你使用以下 cURL 调用 API 来创建书签时,数据无效(title 和 url 是必须的)

curl --location --request POST 'http://localhost:8080/api/bookmarks' \
--header 'Content-Type: application/json' \
--data-raw '{ "title":"", "url":"" }'

你会得到如下默认的 SpringBoot 错误响应:

{
    "timestamp": "2022-11-30T04:42:14.282+00:00",
    "status": 400,
    "error": "Bad Request",
    "trace": "org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<com.sivalabs.bookmarks.domain.Bookmark> com.sivalabs.bookmarks.web.BookmarkController.save(com.sivalabs.bookmarks.domain.Bookmark) with 2 errors: [Field error in object 'bookmark' on field 'url': rejected value []; codes [NotEmpty.bookmark.url,NotEmpty.url,NotEmpty.java.lang.String,NotEmpty]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [bookmark.url,url]; arguments []; default message [url]]; default message [Url is mandatory]] [Field error in object 'bookmark' on field 'title': rejected value []; codes [NotEmpty.bookmark.title,NotEmpty.title,NotEmpty.java.lang.String,NotEmpty]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [bookmark.title,title]; arguments []; default message [title]]; default message [Title is mandatory]] 
            at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.resolveArgument(RequestResponseBodyMethodProcessor.java:144)
            at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122)
            at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:181)
            ....
            ....
            ",
    "message": "Validation failed for object='bookmark'. Error count: 2",
    "errors": [
        {
            "codes": [
                "NotEmpty.bookmark.url",
                "NotEmpty.url",
                "NotEmpty.java.lang.String",
                "NotEmpty"
            ],
            "arguments": [
                {
                    "codes": [
                        "bookmark.url",
                        "url"
                    ],
                    "arguments": null,
                    "defaultMessage": "url",
                    "code": "url"
                }
            ],
            "defaultMessage": "Url is mandatory",
            "objectName": "bookmark",
            "field": "url",
            "rejectedValue": "",
            "bindingFailure": false,
            "code": "NotEmpty"
        },
        {
            "codes": [
                "NotEmpty.bookmark.title",
                "NotEmpty.title",
                "NotEmpty.java.lang.String",
                "NotEmpty"
            ],
            "arguments": [
                {
                    "codes": [
                        "bookmark.title",
                        "title"
                    ],
                    "arguments": null,
                    "defaultMessage": "title",
                    "code": "title"
                }
            ],
            "defaultMessage": "Title is mandatory",
            "objectName": "bookmark",
            "field": "title",
            "rejectedValue": "",
            "bindingFailure": false,
            "code": "NotEmpty"
        }
    ],
    "path": "/api/bookmarks"
}

现在让我们看看如何使用 ProblemDetails API 返回符合 RFC 7807 标准的响应。

1、启用 RFC 7807 响应

要启用 RFC 7807 响应,我们需要通过继承 ResponseEntityExceptionHandler,使用 @ControllerAdvice 创建一个全局异常 handler。

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

}

现在,当你再次调用上述 API 时,你将收到符合 RFC 7807 标准的响应,Content-Type 头为 application/problem+json

{
    "type": "about:blank",
    "title": "Bad Request",
    "status": 400,
    "detail": "Invalid request content.",
    "instance": "/api/bookmarks"
}

ResponseEntityExceptionHandler 为大多数 Spring MVC 内置异常(如 MethodArgumentNotValidExceptionServletRequestBindingExceptionHttpRequestMethodNotSupportedException 等)实现了 @ExceptionHandler 方法。因此,MethodArgumentNotValidException 会被 exception handler method 处理,并返回相应的错误响应。

2、处理自定义异常

让我们看看如何使用 ProblemDetails API 处理我们自己的自定义异常并返回符合 RFC 7807 标准的响应。

让我们调用 HTTP API 获取一个id 不存在的书签

curl --location --request GET 'http://localhost:8080/api/bookmarks/111'

则会得到如下响应:

{
    "timestamp": "2022-11-30T04:34:42.002+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "trace": "com.sivalabs.bookmarks.domain.BookmarkNotFoundException: Bookmark with id: 111 not found  
              at com.sivalabs.bookmarks.web.BookmarkController.lambda$getBookmarkById$0(BookmarkController.java:31)
              at java.base/java.util.Optional.orElseThrow(Optional.java:403)
              at com.sivalabs.bookmarks.web.BookmarkController.getBookmarkById(BookmarkController.java:31)
              at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
              ....,
              ....
              ",
    "message": "Bookmark with id: 111 not found",
    "path": "/api/bookmarks/111"
}

要自定义错误响应,我们可以在 GlobalExceptionHandler 中创建一个 ExceptionHandler,并使用 ProblemDetail 返回自定义响应。

@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"));
        return problemDetail;
    }
}

现在,当你调用 API 来获取不存在 id 的书签时,你将得到以下响应:

{
  "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"
}

除了标准字段 typetitlestatusdetailinstance 外,我们还可以包含以下自定义属性:

@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;
    }
}

我们添加了 2 个自定义属性 errorCategorytimestamp,它们将包含在响应中,如下所示:

{
    "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": "2022-11-30T05:21:59.828411Z"
}

除了 ProblemDetail 外,你还可以返回 ErrorResponse 的实例,它是一种约定,用于公开 HTTP 错误响应的详细信息,包括 HTTP status、响应头和 RFC 7807 格式的响应体。

所有 Spring MVC 异常,如 MethodArgumentNotValidExceptionServletRequestBindingExceptionHttpRequestMethodNotSupportedException 等,都实现了 ErrorResponse

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(BookmarkNotFoundException.class)
    ErrorResponse handleBookmarkNotFoundException(BookmarkNotFoundException e) {
        return ErrorResponse.builder(e, HttpStatus.NOT_FOUND, e.getMessage())
                .title("Bookmark not found")
                .type(URI.create("https://api.bookmarks.com/errors/not-found"))
                .property("errorCategory", "Generic")
                .property("timestamp", Instant.now())
                .build();
    }
}

3、自定义异常继承 ErrorResponseException

与其为自定义异常实现 @ExceptionHandler 方法,我们可以继承 ErrorResponseException ,然后直接抛出异常。

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;

import java.net.URI;
import java.time.Instant;

public class BookmarkNotFoundException extends ErrorResponseException {

    public BookmarkNotFoundException(Long bookmarkId) {
        super(HttpStatus.NOT_FOUND, asProblemDetail("Bookmark with id "+ bookmarkId+" not found"), null);
    }

    private static ProblemDetail asProblemDetail(String message) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, message);
        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 继承 ErrorResponseException,我们只需抛出 BookmarkNotFoundException,SpringMVC 就会处理它,并以符合 RFC 7807 的格式返回错误响应,而无需在 GlobalExceptionHandler 中实现 @ExceptionHandler 方法。

4、总结

通过使用 Spring Framework 的 ProblemDetails API 来处理错误响应,我们可以对响应格式进行标准化,这在大量微服务相互通信时非常有益。


参考:https://www.sivalabs.in/spring-boot-3-error-reporting-using-problem-details/