在 Spring Boot 中使用 ProblemDetail 返回错误

1、简介

本文将带你了解如何在 Spring Boot 应用中使用 ProblemDetail 响应错误信息,无论我们处理的是 REST API 还是 Reactive Stream(响应式流),它都提供了一种向客户端传达错误的标准化方式。

2、为什么要关注 ProblemDetail?

使用 ProblemDetail 来标准化错误响应对任何 API 都至关重要。

它可以帮助客户理解和处理错误,提高 API 的可用性和可调试性。这将带来更好的开发体验和更强大的应用。

采用它还有助于提供更翔实的错误信息,这对维护我们的服务和排除故障至关重要。

3、传统的错误处理方式

ProblemDetail 之前,我们经常在 Spring Boot 中实现自定义 ExceptionHandlerResponseEntity 来处理错误。我们会创建自定义的错误响应结构。这导致了不同 API 之间的不一致性。

这种方式不仅需要大量的模板代码。而且,缺乏表示错误的标准化方式,因此客户端很难统一解析和理解错误信息。

4、ProblemDetail 规范

ProblemDetail 规范是 RFC 7807 标准的一部分。它为错误响应定义了一致的结构,包括诸如类型(type)、标题(title)、状态(status)、详情(detail)和实例(instance)等字段。这种标准化提供了一个通用的错误信息格式,有助于 API 开发人员和使用者。

实现 ProblemDetail 可确保我们的错误响应具有可预测性并易于理解。这反过来提高了我们的 API 和其客户端之间的整体沟通效果。

5、在 Spring Boot 中实现 ProblemDetail

在 Spring Boot 中有多种方法可以实现 ProblemDetail。

5.1、通过配置属性启用 ProblemDetail

我们可以添加一个配置属性来启用它。对于 RESTful 服务,在 application.properties 中添加以下属性:

spring.mvc.problemdetails.enabled=true

此属性可使 ProblemDetail 自动用于基于 MVC(servlet 栈)的应用中的错误处理。

对于响应式应用,我们可以添加以下属性:

spring.webflux.problemdetails.enabled=true

启用后,Spring 会使用 ProblemDetail 报告错误:

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

该属性会在错误处理中自动提供 ProblemDetail。如果不需要,我们也可以将其关闭。

5.2、在 ExceptionHandler 中实现 ProblemDetail

全局异常处理器在 Spring Boot REST 应用中实现了集中式错误处理。

来看一个计算折扣价格的简单 REST 服务。

它接收操作请求并返回结果。此外,它还执行输入验证和业务逻辑。

请求参数如下:

public record OperationRequest(
    @NotNull(message = "Base price should be greater than zero.")
    @Positive(message = "Base price should be greater than zero.")
        Double basePrice,
    @Nullable @Positive(message = "Discount should be greater than zero when provided.")
        Double discount) {}

返回的结果如下:

public record OperationResult(
    @Positive(message = "Base price should be greater than zero.") Double basePrice,
    @Nullable @Positive(message = "Discount should be greater than zero when provided.")
        Double discount,
    @Nullable @Positive(message = "Selling price should be greater than zero.")
        Double sellingPrice) {}

下面是无效操作异常的实现:

public class InvalidInputException extends RuntimeException {

    public InvalidInputException(String s) {
        super(s);
    }
}

现在,实现 REST Controller,为端点提供服务:

@RestController
@RequestMapping("sales")
public class SalesController {

    @PostMapping("/calculate")
    public ResponseEntity<OperationResult> calculate(
        @Validated @RequestBody OperationRequest operationRequest) {
    
        OperationResult operationResult = null;
        Double discount = operationRequest.discount();
        if (discount == null) {
            operationResult =
                new OperationResult(operationRequest.basePrice(), null, operationRequest.basePrice());
        } else {
            if (discount.intValue() >= 100) {
                throw new InvalidInputException("Free sale is not allowed.");
            } else if (discount.intValue() > 30) {
                throw new IllegalArgumentException("Discount greater than 30% not allowed.");
            } else {
                operationResult = new OperationResult(operationRequest.basePrice(),
                    discount,
                    operationRequest.basePrice() * (100 - discount) / 100);
            }
        }
        return ResponseEntity.ok(operationResult);
    }
}

SalesController 类在 /sales/calculate 端点处理 HTTP POST 请求。

它检查并验证 OperationRequest 对象。如果请求有效,它就会计算销售价格,并考虑可选折扣。如果折扣无效(超过 100% 或超过 30%),则会抛出异常。如果折扣有效,它将通过应用折扣计算出最终价格,并返回一个封装在 ResponseEntity 中的 OperationResult

现在,来看看如何在 GlobalExceptionHandler 中实现 ProblemDetail

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(InvalidInputException.class)
    public ProblemDetail handleInvalidInputException(InvalidInputException e, WebRequest request) {
        ProblemDetail problemDetail
            = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
        problemDetail.setInstance(URI.create("discount"));
        return problemDetail;
    }
}

GlobalExceptionHandler 类使用 @RestControllerAdvice 进行注解,它继承了 ResponseEntityExceptionHandler,以便在 Spring Boot 应用中提供集中式异常处理。

它定义了一种处理 InvalidInputException 异常的方法。当出现这种异常时,它会创建一个 ProblemDetail 对象,该对象具有 BAD_REQUEST 状态和异常消息。此外,它还会将该实例设置为一个 URI(“discount”),以表明错误的具体情况。

这种标准化的错误响应可为客户提供清晰详细的信息,说明出错的原因。

ResponseEntityExceptionHandler 是一个便于在不同应用中以标准化方式处理异常的类。因此,将异常转换为有意义的 HTTP 响应的过程得以简化。此外,它还提供了使用 ProblemDetail 来处理常见 Spring MVC 异常的方法,如 MissingServletRequestParameterExceptionMethodArgumentNotValidException 等。

5.3、测试 ProblemDetail 实现

测试如下:

@Test
void givenFreeSale_whenSellingPriceIsCalculated_thenReturnError() throws Exception {

    OperationRequest operationRequest = new OperationRequest(100.0, 140.0);
    mockMvc
      .perform(MockMvcRequestBuilders.post("/sales/calculate")
      .content(toJson(operationRequest))
      .contentType(MediaType.APPLICATION_JSON))
      .andDo(print())
      .andExpectAll(status().isBadRequest(),
        jsonPath("$.title").value(HttpStatus.BAD_REQUEST.getReasonPhrase()),
        jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value()),
        jsonPath("$.detail").value("Free sale is not allowed."),
        jsonPath("$.instance").value("discount"))
      .andReturn();
}

在此 SalesControllerUnitTest 中,我们自动装配了 MockMvcObjectMapper 以测试 SalesController

测试方法 givenFreeSale_whenSellingPriceIsCalculated_thenReturnError() 模拟了对 /sales/calculate 端点的 POST 请求,其中的 OperationRequest 包含基本价格 100.0 和折扣 140.0。因此,这将在 Controller 中触发 InvalidOperandException

最后,验证 BadRequest 类型的响应,其中的 ProblemDetail 显示 “Free sale is not allowed.”

6、总结

本文介绍了 ProblemDetails 规范及其在 Spring Boot REST 应用中的实现,还介绍了它相对于传统错误处理的优势,以及如何在 Servlet 和 Reactive 栈中使用它。


Ref:https://www.baeldung.com/spring-boot-return-errors-problemdetail