1. 引言

本文将深入探讨 Spring 框架中不同的错误响应格式,并学习如何抛出和处理带有自定义属性的 RFC7807 ProblemDetail 异常,以及如何在 Spring WebFlux 中实现自定义异常处理。

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

Spring Boot 3 默认支持多种错误响应格式。当发生未处理异常时,Spring 框架会通过 DefaultErrorAttributes 类(实现了 ErrorAttributes 接口)生成错误响应。默认的 JSON 响应结构如下:

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

虽然这种格式包含基本属性,但排查问题时可能信息不足。好在我们可以通过自定义 ErrorAttributes 实现来修改默认行为。

Spring Framework 6 开始支持 RFC7807 规范的 ProblemDetail 表示,它包含以下标准属性:

  • type (字符串) – 标识问题类型的 URI
  • title (字符串) – 问题类型的简短摘要
  • status (数字) – HTTP 状态码
  • detail (字符串) – 异常详细信息
  • instance (字符串) – 标识具体问题的 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 还提供了 ErrorResponseException 基础实现,它封装了 ProblemDetail 对象,我们可以通过继承它来定制异常属性。

3. 实现 ProblemDetail RFC 7807 异常

虽然 Spring 6+/Spring Boot 3+ 默认支持 ProblemDetail,但需要通过以下方式之一启用:

3.1. 通过配置文件启用

application.yml 中添加属性:

spring:
  mvc:
    problemdetails:
      enabled: true

3.2. 通过异常处理器启用

扩展 ResponseEntityExceptionHandler 并添加自定义异常处理器(即使不重写任何方法):

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

本文将采用这种方式,因为我们需要添加自定义异常处理器。

3.3. 实现 ProblemDetail 异常

以一个简单的用户管理应用为例,演示如何抛出和处理带自定义属性的 ProblemDetail 异常。控制器中的 GET /v1/users/{userId} 接口根据用户 ID 查询用户,未找到时抛出 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);
    }
}

由于我们扩展了 ResponseEntityExceptionHandlerProblemDetail 已成为默认异常格式。测试时使用不支持的 HTTP 方法(如 POST)访问接口:

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 中扩展自定义属性

UserNotFoundException 添加异常处理器,在 ProblemDetail 响应中包含自定义对象。ProblemDetailproperties 属性接受 String 键和任意 Object 值。

定义自定义错误对象 ErrorDetails,包含错误码、消息和参考链接:

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

GlobalExceptionHandler 中添加 UserNotFoundException 的处理器:

@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

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();
    }
}
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();
    }
}

现在访问无效用户 ID 的接口:

$ 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 规范的 HTTP 状态、响应头和响应体。

⚠️ 除了 ResponseEntityExceptionHandler,也可以使用 AbstractErrorWebExceptionHandler 处理全局 WebFlux 异常。

4. 为什么需要自定义异常

虽然 ProblemDetail 格式灵活且支持自定义属性,但某些场景下我们可能希望抛出包含完整错误信息的自定义对象。此时使用自定义异常能提供更清晰、更具体、更一致的错误处理方式

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,抛出此自定义异常(简化处理,部分字段使用随机值):

@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 中添加处理器格式化输出:

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

访问无效用户 ID 的接口:

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

将得到自定义错误响应:

{
    "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 中创建和处理自定义异常的方法。完整示例代码可在 GitHub 获取。


原始标题:Custom WebFlux Exceptions in Spring Boot 3 | Baeldung