使用 Spring Boot 提供API错误消息的好方式


对于 API 用户来说,API 提供有用的错误消息非常重要。否则,很难弄清楚为什么事情不起作用。与在服务器端实际实现有用的错误响应相比,调试错误可能会很快成为客户端更大的工作。如果客户无法自己解决问题并且需要额外的沟通,则尤其如此。
尽管如此,这个话题经常被忽视或三心二意地实施。
 
客户端和安全角度
关于错误消息有不同的观点。详细的错误消息对客户端更有帮助,而从安全角度来看,最好公开尽可能少的信息。幸运的是,如果正确实施,这两种观点通常不会发生太大冲突。
如果错误是由客户产生的,那么客户通常对非常具体的错误消息感兴趣。这通常应由4xx 状态代码指示。在这里,我们需要在不暴露任何内部实现细节的情况下指向客户端所犯错误的特定消息。
另一方面,如果客户端请求有效并且错误是由服务器产生的(5xx 状态代码),我们应该对错误消息持保守态度。在这种情况下,客户端无法解决问题,因此不需要有关错误的任何详细信息。
指示错误的响应应至少包含两件事:人类可读的消息和错误代码。第一个帮助在日志文件中看到错误消息的开发人员。后者允许在客户端上进行特定的错误处理(例如,向用户显示特定的错误消息)。
 
如何在 Spring Boot 应用程序中构建有用的错误响应?
假设我们有一个可以发布文章的小应用程序。执行此操作的简单 Spring 控制器可能如下所示:

@RestController
public class ArticleController {
 
    @Autowired
    private ArticleService articleService;
 
    @PostMapping("/articles/{id}/publish")
    public void publishArticle(@PathVariable ArticleId id) {
        articleService.publishArticle(id);
    }
}

这里没什么特别的,控制器只是将操作委托给一个服务,它看起来像这样:
@Service
public class ArticleService {
 
    @Autowired
    private ArticleRepository articleRepository;
 
    public void publishArticle(ArticleId id) {
        Article article = articleRepository.findById(id)
                .orElseThrow(() -> new ArticleNotFoundException(id));
 
        if (!article.isApproved()) {
            throw new ArticleNotApprovedException(article);
        }
 
        ...
    }
}

在服务内部,我们针对可能的客户端错误抛出特定异常。请注意,这些异常不仅仅描述了错误。它们还携带可能有助于我们稍后生成良好错误消息的信息:

public class ArticleNotFoundException extends RuntimeException {
    private final ArticleId articleId;
 
    public ArticleNotFoundException(ArticleId articleId) {
        super(String.format("No article with id %s found", articleId));
        this.articleId = articleId;
    }
     
   
// getter
}

如果异常足够具体,我们不需要通用消息参数。相反,我们可以在异常构造函数中定义消息。
接下来我们可以在@ControllerAdvice bean 中使用@ExceptionHandler方法来处理实际的异常:

@ControllerAdvice
public class ArticleExceptionHandler {
 
    @ExceptionHandler(ArticleNotFoundException.class)
    public ResponseEntity<ErrorResponse> onArticleNotFoundException(ArticleNotFoundException e) {
        String message = String.format("No article with id %s found", e.getArticleId());
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse(
"ARTICLE_NOT_FOUND", message));
    }
     
    ...
}

如果控制器方法抛出异常,Spring 会尝试找到一个用匹配的@ExceptionHandler注释来注释的方法。@ExceptionHandler方法可以有灵活的方法签名,类似于标准控制器方法。例如,我们可以给一个HttpServletRequest请求参数,Spring 会传入当前的请求对象。可能的参数和返回类型在@ExceptionHandlerJavadocs 中描述
在这个例子中,我们创建了一个简单的ErrorResponse对象,它包含一个错误代码和一条消息。
消息是根据异常携带的数据构造的。也可以将异常消息传递给客户端。但是,在这种情况下,我们需要确保团队中的每个人都知道这一点,并且异常消息不包含敏感信息。否则,我们可能会不小心将内部信息泄露给客户端。
ErrorResponse是一个用于 JSON 序列化的简单 Pojo:
public class ErrorResponse {
    private final String code;
    private final String message;
 
    public ErrorResponse(String code, String message) {
        this.code = code;
        this.message = message;
    }
 
    // getter
}

 
测试错误响应
一个好的测试套件不应错过针对特定错误响应的测试。在我们的示例中,我们可以用不同的方式验证错误行为。一种方法是使用Spring MockMvc测试。

@SpringBootTest
@AutoConfigureMockMvc
public class ArticleExceptionHandlerTest {
 
    @Autowired
    private MockMvc mvc;
 
    @MockBean
    private ArticleRepository articleRepository;
 
    @Test
    public void articleNotFound() throws Exception {
        when(articleRepository.findById(new ArticleId("123"))).thenReturn(Optional.empty());
 
        mvc.perform(post(
"/articles/123/publish"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath(
"$.code").value("ARTICLE_NOT_FOUND"))
                .andExpect(jsonPath(
"$.message").value("No article with id 123 found"));
    }
}

在这里,我们使用一个模拟的 ArticleRepository,它为传递的 id返回一个空的Optional。然后我们验证错误代码和消息是否与预期的字符串匹配。
 
总结
有用的错误消息是 API 的重要组成部分。
如果客户端产生错误(HTTP 4xx 状态代码),服务器应提供至少包含错误代码和人类可读错误消息的描述性错误响应。对意外服务器错误 (HTTP 5xx) 的响应应该是保守的,以避免意外暴露任何内部信息。
为了提供有用的错误响应,我们可以使用携带相关数据的特定异常。在@ExceptionHandler方法中,我们然后根据异常数据构造错误消息。