使用 Spring Boot 提供API錯誤訊息的好方式

banq發表於2021-06-04

對於 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方法中,我們然後根據異常資料構造錯誤訊息。

相關文章