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 内置异常(如 MethodArgumentNotValidException
、ServletRequestBindingException
、HttpRequestMethodNotSupportedException
等)实现了 @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"
}
除了标准字段 type
、title
、status
、detail
和 instance
外,我们还可以包含以下自定义属性:
@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 个自定义属性 errorCategory
和 timestamp
,它们将包含在响应中,如下所示:
{
"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 异常,如
MethodArgumentNotValidException
、ServletRequestBindingException
、HttpRequestMethodNotSupportedException
等,都实现了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/