在 Spring Boot 3 中自定义 WebFlux 异常

1、概览

在本教程中,我们将探索 Spring 框架中不同的错误响应格式。我们还将了解如何使用自定义属性抛出和处理 RFC7807 ProblemDetail,以及如何在 Spring WebFlux 中抛出自定义异常。

2、Spring Boot 3 中的异常响应格式

让我们来了解一下开箱即用的各种错误响应格式。

默认情况下,Spring Framework 提供了实现 ErrorAttributes 接口的 DefaultErrorAttributes 类,用于在出现未处理的错误时生成错误响应。在默认错误的情况下,系统会生成一个 JSON 响应,提供了一些详细的信息:

{
    "timestamp": "2023-04-01T00:00:00.000+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/api/example"
}

虽然该错误响应包含一些关键属性,但可能对排除问题没有帮助。幸运的是,我们可以通过在 Spring WebFlux 应用程序中创建 ErrorAttributes 接口的自定义实现来修改默认行为。

从 Spring Framework 6 ProblemDetail 开始,支持 RFC7807 规范的表示。ProblemDetail 包含一些定义错误细节的标准属性,还提供了扩展细节以进行自定义的选项。支持的属性如下所示:

  • type(string) - 标识问题类型的 URI 参考地址。
  • title(string) - 问题类型简述。
  • status(number) - HTTP 状态码。
  • detail(string) - 应包含异常的详细信息。
  • instance(string) - 一个 URI 参考地址,用于标识问题的具体原因。例如,它可以指向导致问题的属性。

除了上述标准属性外,ProblemDetail 还包含一个 Map<String, Object>,用于添加自定义参数,以提供有关问题的更详细信息。

让我们来看看包含自定义 errors 对象的错误响应结构示例:

{
  "type": "https://example.com/probs/email-invalid",
  "title": "Invalid email address",
  "detail": "The email address 'john.doe' is invalid.",
  "status": 400,
  "timestamp": "2023-04-07T12:34:56.789Z",
  "errors": [
    {
      "code": "123",
      "message": "Error message",
      "reference": "https//error/details#123"
    }
  ]
}

Spring Framework 还提供了一个名为 ErrorResponseException 的基本实现。该异常封装了一个 ProblemDetail 对象,可生成有关所发生错误的附加信息。我们可以继承此异常,自定义并添加属性。

3、如何实现 ProblemDetail RFC 7807 异常

虽然 Spring 6+ / Spring Boot 3+ 应用程序默认支持 ProblemDetail 异常,但我们需要通过以下方式之一启用它。

3.1. 通过 properties 启用 ProblemDetail 异常

可以通过添加一个属性来启用 ProblemDetail 异常:

spring:
  mvc:
    problemdetails:
      enabled: true

3.2、通过添加 Exception Handler 启用 ProblemDetail 异常

也可以通过继承 ResponseEntityExceptionHandler 并添加自定义 exception handler(即使没有任何重写)来启用 ProblemDetail 异常:

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
    //...
}

本文将使用这种方法,因为我们需要添加自定义 exception handler。

3.3、实现 ProblemDetail 异常

让我们通过一个简单的应用程序来研究如何利用自定义属性抛出和处理 ProblemDetail 异常,该应用程序提供了几个端点用于创建和检索 User 信息。

我们的 controller 有一个 GET /v1/users/{userId} 端点,可根据提供的 userId 检索用户信息。如果找不到任何记录,代码就会抛出一个名为 UserNotFoundException 的简单自定义异常:

@GetMapping("/v1/users/{userId}")
public Mono<ResponseEntity<User>> getUserById(@PathVariable Long userId) {
    return Mono.fromCallable(() -> {
        User user = userMap.get(userId);
        if (user == null) {
            throw new UserNotFoundException("User not found with ID: " + userId);
        }
        return new ResponseEntity<>(user, HttpStatus.OK);
    });
}

我们的 UserNotFoundException 继承自 RunTimeException

public class UserNotFoundException extends RuntimeException {

    public UserNotFoundException(String message) {
        super(message);
    }
}

由于我们有一个继承了 ResponseEntityExceptionHandlerGlobalExceptionHandler 自定义 handler,因此 ProblemDetail 成为默认的异常格式。要测试这一点,我们可以尝试使用不支持的 HTTP 方法(例如 POST)访问应用程序,以查看异常格式。

当抛出 MethodNotAllowedException 时,ResponseEntityExceptionHandler 将处理异常并生成 ProblemDetail 格式的响应:

curl --location --request POST 'localhost:8080/v1/users/1'

这将会使用 ProblemDetail 对象作为响应:

{
    "type": "about:blank",
    "title": "Method Not Allowed",
    "status": 405,
    "detail": "Supported methods: [GET]",
    "instance": "/users/1"
}

3.4、在 Spring WebFlux 中使用自定义属性继承 ProblemDetail 异常

让我们对示例进行扩展,为 UserNotFoundException 提供一个 exception handler,将一个自定义对象添加到 ProblemDetail 响应中。

ProblemDetail 对象包含一个 properties 属性,该属性接受 String 作为 key,接受任意 Object 作为值。

我们将添加一个名为 ErrorDetails 的自定义对象。该对象包含错误代码和信息,以及一个错误参考 URL,其中包含解决问题的其他详细信息和说明:

@JsonSerialize(using = ErrorDetailsSerializer.class)
public enum ErrorDetails {
    API_USER_NOT_FOUND(123, "User not found", "http://example.com/123");
    @Getter
    private Integer errorCode;
    @Getter
    private String errorMessage;
    @Getter
    private String referenceUrl;

    ErrorDetails(Integer errorCode, String errorMessage, String referenceUrl) {
        this.errorCode = errorCode;
        this.errorMessage = errorMessage;
        this.referenceUrl = referenceUrl;
    }
}

要覆盖 UserNotException 的错误行为,我们需要在 GlobalExceptionHandler 类中提供一个 error handler。该 handler 应设置 ErrorDetails 对象的 API_USER_NOT_FOUND 属性,以及 ProblemDetail 对象提供的任何其他错误详细信息:

@ExceptionHandler(UserNotFoundException.class)
protected ProblemDetail handleNotFound(RuntimeException ex) {
    ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    problemDetail.setTitle("User not found");
    problemDetail.setType(URI.create("https://example.com/problems/user-not-found"));
    problemDetail.setProperty("errors", List.of(ErrorDetails.API_USER_NOT_FOUND));
    return problemDetail;
}

我们还需要 ErrorDetailsSerializerProblemDetailSerializer 来定制响应格式。

ErrorDetailsSerializer 负责格式化包含 error code、error message 和引用详细信息的自定义 error 对象:

public class ErrorDetailsSerializer extends JsonSerializer<ErrorDetails> {
    @Override
    public void serialize(ErrorDetails value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        gen.writeStartObject();
        gen.writeStringField("code", value.getErrorCode().toString());
        gen.writeStringField("message", value.getErrorMessage());
        gen.writeStringField("reference", value.getReferenceUrl());
        gen.writeEndObject();
    }
}

ProblemDetailSerializer 负责格式化整个 ProblemDetail 对象和自定义对象(使用 ErrorDetailsSerializer):

public class ProblemDetailsSerializer extends JsonSerializer<ProblemDetail> {

    @Override
    public void serialize(ProblemDetail value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        gen.writeStartObject();
        gen.writeObjectField("type", value.getType());
        gen.writeObjectField("title", value.getTitle());
        gen.writeObjectField("status", value.getStatus());
        gen.writeObjectField("detail", value.getDetail());
        gen.writeObjectField("instance", value.getInstance());
        gen.writeObjectField("errors", value.getProperties().get("errors"));
        gen.writeEndObject();
    }
}

现在,当我们尝试使用无效的 userId 访问端点时,应该会收到一条包含自定义属性的错误信息:

$ curl --location 'localhost:8080/v1/users/1'

这样就会生成带有自定义属性的 ProblemDetail 对象:

{
  "type": "https://example.com/problems/user-not-found",
  "title": "User not found",
  "status": 404,
  "detail": "User not found with ID: 1",
  "instance": "/users/1",
  "errors": [
    {
      "errorCode": 123,
      "errorMessage": "User not found",
      "referenceUrl": "http://example.com/123"
    }
  ]
}

我们还可以使用实现了 ErrorResponseErrorResponseException,通过 RFC 7807 ProblemDetail 规范来暴露 HTTP 状态、响应头和 body。

在这些示例中,我们使用 ResponseEntityExceptionHandler 处理了全局异常。或者,也可以使用 AbstractErrorWebExceptionHandler 来处理全局 Webflux 异常。

4、为什么要自定义异常?

虽然 ProblemDetail 格式有助于灵活添加自定义属性,但在某些情况下,我们可能更喜欢抛出一个包含错误所有详细信息的自定义 error 对象。在这种情况下,使用 Spring 中的自定义异常可以提供一种更清晰、更具体、更一致的方法来处理代码中的错误和异常。

5、 在 Spring WebFlux 中实现自定义异常

让我们考虑实现一个自定义对象代替 ProblemDetail 作为响应:

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CustomErrorResponse {
    private String traceId;
    private OffsetDateTime timestamp;
    private HttpStatus status;
    private List<ErrorDetails> errors;
}

要抛出这个自定义对象,我们需要一个自定义异常:

public class CustomErrorException extends RuntimeException {
    @Getter
    private CustomErrorResponse errorResponse;

    public CustomErrorException(String message, CustomErrorResponse errorResponse) {
        super(message);
        this.errorResponse = errorResponse;
    }
}

让我们创建另一个 v2 版本的端点,它会抛出这种自定义异常。为简单起见,某些字段(如 traceId)将使用随机值填充:

@GetMapping("/v2/users/{userId}")
public Mono<ResponseEntity<User>> getV2UserById(@PathVariable Long userId) {
    return Mono.fromCallable(() -> {
        User user = userMap.get(userId);
        if (user == null) {
            CustomErrorResponse customErrorResponse = CustomErrorResponse
              .builder()
              .traceId(UUID.randomUUID().toString())
              .timestamp(OffsetDateTime.now().now())
              .status(HttpStatus.NOT_FOUND)
              .errors(List.of(ErrorDetails.API_USER_NOT_FOUND))
              .build();
            throw new CustomErrorException("User not found", customErrorResponse);
        }
        return new ResponseEntity<>(user, HttpStatus.OK);
    });
}

我们需要在 GlobalExceptionHandler 中添加一个 handler,以便最终在输出响应中格式化异常:

@ExceptionHandler({CustomErrorException.class})
protected ResponseEntity<CustomErrorResponse> handleCustomError(RuntimeException ex) {
    CustomErrorException customErrorException = (CustomErrorException) ex;
    return ResponseEntity.status(customErrorException.getErrorResponse().getStatus()).body(customErrorException.getErrorResponse());
}

现在,如果我们尝试使用无效的 userId 访问端点,就会收到带有自定义属性的错误信息:

$ curl --location 'localhost:8080/v2/users/1'

这样就会使用 CustomErrorResponse 对象作为响应:

{
    "traceId": "e3853069-095d-4516-8831-5c7cfa124813",
    "timestamp": "2023-04-28T15:36:41.658289Z",
    "status": "NOT_FOUND",
    "errors": [
        {
            "code": "123",
            "message": "User not found",
            "reference": "http://example.com/123"
        }
    ]
}

6、总结

在本文中,我们探讨了如何启用和使用 Spring Framework 提供的 ProblemDetail RFC7807 异常格式,并学习了如何在 Spring WebFlux 中创建和处理自定义异常。


参考:https://www.baeldung.com/spring-boot-custom-webflux-exceptions