避免濫用http狀態碼,如何將後端業務錯誤準確地傳遞到Restful客戶端?Spring Boot和JAX-RS的RFC-7807問題詳細資訊 - codecentric

banq發表於2020-01-18

在使用JAX-RS,Spring Boot或任何其他技術的RESTful Web服務中,必須使用機器可讀且人性化的自定義業務錯誤代號。

假設您正在編寫訂單處理系統,客戶可能沒有資格使用某種付款方式下訂單,您想通過Web前端或HTTP API呼叫的結果向使用者反饋這種問題。可以通過檢視http規範,並使用程式碼405:“不允許使用方法”來解決。

聽起來完全符合您的需求。它可以在您的測試中工作得很好,並且可以投入生產正常執行一段時間。但是隨後,對負載均衡器進行例行更新時會破壞系統。很快,在開發人員和運維人員之間進行了相互指責,最終爆發了一場全面的責任大戰。看起來好像是由ops運維人員進行更新引起的問題,但他們聲稱負載平衡器中沒有錯誤,原因是由於舊版本的安全性問題,必須對其進行更新。

實際上確實應該歸咎於開發人員:您誤用了具有特定語義的技術程式碼,以表示完全不同的業務語義-這絕不是一個好主意。在這種情況下,明確使用了可以允許快取的405程式碼。

http狀態程式碼(請參閱rfc-7231或格式正確的https://httpstatuses.com)精確地指定了不同的情況,主要是細粒度的技術問題。特定於應用程式的問題僅限於通用程式碼400 Bad Request(以及其他一些500 Internal Server Error程式碼)或狀態程式碼,它們可用於表示客戶端或伺服器端的一般故障。但是我們需要區分許多情況。我們還能如何將各種問題傳達給客戶端?

http協議允許幾乎在任何響應中不僅在GET請求後具有200 OK狀態,還可以包含一個正文(在RFC中稱為“實體”)。在這種情況下,大多數系統都會顯示自定義html錯誤頁面。如果我們使此主體機器可讀,則我們的客戶可以做出適當的反應。為每個端點甚至每個應用程式定義一個新的文件型別是一項繁重的工作:您不僅需要編寫程式碼,而且還要編寫文件,測試並將其全部傳達給客戶端等,並且客戶端必須使用對於一個請求正是這種格式,對於另一個請求正是這種格式,這太麻煩了。有一個標準會很好-實際上,有一個標準:RFC-7807。

RFC-7807

該標準定義了一種媒體型別application/problem+json(或+xml)以及與其精確語義一起使用的標準欄位。這是一個簡短的摘要:

  • type:一個URI,用於標識發生了什麼型別的問題。理想情況下,它應該是有關此類錯誤的詳細資訊的文件的穩定 URL,例如https://api.myshop.example/problems/not-entitled-for-payment-method;但它也可以是URN,例如urn:problem-type:not-entitled-for-payment-method。在任何情況下,更改都type被定義為API 的重大更改,因此對於客戶而言,使用此方法切換到不同的問題情況是安全的。
  • title:對問題的一般型別的非正式的,人類可讀的簡短描述,例如You're not entitled to use this payment method。可以在不破壞API的情況下進行更改。
  • status:重複響應狀態程式碼,例如403為Forbidden。由於代理更改了http狀態程式碼,因此伺服器丟擲的內容和客戶端收到的內容可能有所不同。它僅是幫助除錯的建議,因此可以在不破壞API的情況下對其進行更改。
  • detail:關於錯誤原因的易於理解的完整描述,例如Customer 123456 has only GOLD status but needs PLATINUM to be entitled to order the sum of USD 1,234.56 on account.可以在不破壞API的情況下進行更改。
  • instance:用於標識問題具體發生的URI。如果這是URL,則應提供有關此事件的詳細資訊,例如,指向您的日誌https://logging.myshop.example/prod?query=d294b32b-9dda-4292-b51f-35f65b4bf64d-請注意,僅僅因為它是URL,並不意味著所有人都必須可以訪問它!如果您甚至不想在Web上提供有關日誌記錄系統的詳細資訊,也可以生成一個UUID URN,例如urn:uuid:d294b32b-9dda-4292-b51f-35f65b4bf64d。可以在不破壞API的情況下進行更改。
  • 所有其他欄位均為副檔名,即自定義的機器可讀欄位;例如customer-status或order-sum。擴充套件也可以是複雜的型別,即列表或包含多個欄位的物件,只要它們可以(反)序列化即可。客戶可能想將此顯示給客戶。您可以在不破壞API的情況下新增新副檔名,但是刪除副檔名(或更改語義)是對API 的重大更改。

Spring Boot

假設我們有一個REST控制器OrderBoundary(我在這裡使用BCE術語“邊界”):

@RestController
@RequestMapping(path = "/orders")
@RequiredArgsConstructor
public class OrderBoundary {
    private final OrderService service;
 
    @PostMapping
    public Shipment order(@RequestParam("article") String article) {
        return service.order(article);
    }
}

這個OrderService也許丟擲UserNotEntitledToOrderOnAccountException錯誤。

預設情況下,Spring Boot已經提供了一個json錯誤體,但這是非常技術性的。它包含以下欄位:

  • status+ error:例如403和Forbidden
  • message:例如 You're not entitled to use this payment method
  • path:例如 /orders
  • timestamp:例如 2020-01-10T12:00:00.000+0000
  • trace:堆疊跟蹤

我們需要通過註釋以下內容來指定UserNotEntitledToOrderOnAccountException錯誤的對應http狀態程式碼和訊息:

@ResponseStatus(code = FORBIDDEN,
    reason = "You're not entitled to use this payment method")
public class UserNotEntitledToOrderOnAccountException
  extends RuntimeException {
    ...
}

注意,沒有統一的欄位可以區分不同的錯誤情況,這是我們的主要用例。因此,我們需要採取不同的路線:

1. 手動異常對映

最基本的方法是手動捕獲和對映異常,即在我們中,OrderBoundary控制器中我們返回的ResponseEntity中帶有兩種不同主體型別之一:要麼是商品已經運貨或出現了問題的詳細資訊:

public class OrderBoundary {
    @PostMapping
    public ResponseEntity<?> order(@RequestParam("article") String article) {
        try {
            Shipment shipment = service.order(article);
            return ResponseEntity.ok(shipment);
 
        } catch (UserNotEntitledToOrderOnAccountException e) {
            ProblemDetail detail = new ProblemDetail();
            detail.setType(URI.create("https://api.myshop.example/problems/" +
                "not-entitled-for-payment-method")); ①
            detail.setTitle("You're not entitled to use this payment method");
            detail.setInstance(URI.create(
                "urn:uuid:" + UUID.randomUUID())); ②
 
            log.debug(detail.toString(), exception); ③
 
            return ResponseEntity.status(FORBIDDEN).
                contentType(ProblemDetail.JSON_MEDIA_TYPE)
                .body(detail);
        }
    }
}

①:選擇type欄位使用固定的URL ,例如對Wiki。

②:選擇使用隨機UUID URN instance。

③:記錄了問題的詳細資訊和堆疊跟蹤,因此我們可以在日誌中搜尋UUID,instance以檢視導致問題的日誌上下文中的所有詳細資訊。

問題細節

ProblemDetail班是微不足道的(使用了Lombok):

@Data
public class ProblemDetail {
    public static final MediaType JSON_MEDIA_TYPE =
        MediaType.valueOf("application/problem+json");
 
    private URI type;
    private String title;
    private String detail;
    private Integer status;
    private URI instance;
}

錯誤處理器

如果要轉換的異常很多,手動對映程式碼可能會增長很多。通過使用一些約定,我們可以為所有異常將其替換為通用對映。我們可以將還原OrderBoundary為簡單形式,而使用異常處理程式控制器建議:

@Slf4j
@ControllerAdvice ①
public class ProblemDetailControllerAdvice {
    @ExceptionHandler(Throwable.class) ②
    public ResponseEntity<?> toProblemDetail(Throwable throwable) {
        ProblemDetail detail = new ProblemDetailBuilder(throwable).build();
 
        log.debug(detail.toString(), throwable); ③
 
        return ResponseEntity.status(detail.getStatus())
            .contentType(ProblemDetail.JSON_MEDIA_TYPE)
            .body(detail);
    }
}

①:使實際的異常處理程式方法可由Spring發現。
②:我們處理所有異常和錯誤。
③:我們記錄詳細資訊(包括instance)和堆疊跟蹤。

有趣的部分在ProblemDetailBuilder裡面。

使用的約定是:

  • type:託管於的異常的javadoc的URL https://api.myshop.example/apidocs。這可能不是最穩定的URL,但此演示可以。
  • title:使用簡單的類名,將駝峰式大小寫轉換為空格。
  • detail:異常訊息。
  • instance:使用隨機UUID URN。
  • status:如果將異常註釋為Status使用該註釋;否則使用500 Internal Server Error。

@Retention(RUNTIME)
@Target(TYPE)
public @interface Status {
    int value();
}

請注意,您應該非常謹慎地使用約定:它們永遠不會令人驚訝。ProblemDetailBuilder是幾行程式碼,但是閱讀起來應該很有趣:

@RequiredArgsConstructor
class ProblemDetailBuilder {
    private final Throwable throwable;
 
    ProblemDetail build() {
        ProblemDetail detail = new ProblemDetail();
        detail.setType(buildType());
        detail.setTitle(buildTitle());
        detail.setDetail(buildDetailMessage());
        detail.setStatus(buildStatus());
        detail.setInstance(buildInstance());
        return detail;
    }
 
    private URI buildType() {
        return URI.create("https://api.myshop.example/apidocs/" +
            javadocName(throwable.getClass()) + ".html");
    }
 
    private static String javadocName(Class<?> type) {
        return type.getName()
            .replace('.', '/') // the package names are delimited like a path
            .replace('$', '.'); // nested classes are delimited with a period
    }
 
    private String buildTitle() {
        return camelToWords(throwable.getClass().getSimpleName());
    }
 
    private static String camelToWords(String input) {
        return String.join(" ", input.split("(?=\\p{javaUpperCase})"));
    }
 
    private String buildDetailMessage() {
        return throwable.getMessage();
    }
 
    private int buildStatus() {
        Status status = throwable.getClass().getAnnotation(Status.class);
        if (status != null) {
            return status.value();
        } else {
            return INTERNAL_SERVER_ERROR.getStatusCode();
        }
    }
 
    private URI buildInstance() {
        return URI.create("urn:uuid:" + UUID.randomUUID());
    }
}

您可以將此錯誤處理提取到單獨的模組中,並且如果您可以與其他團隊達成相同的約定,則可以共享它。您甚至可以簡單地使用其他人(例如mine artifact)定義的問題詳細資訊工件,該工件還允許擴充套件欄位和其他內容。

客戶端

我不想在整個域程式碼中散佈技術細節,因此我提取了一個OrderServiceClient類來進行呼叫並將這些問題詳細資訊對映回異常。我希望領域程式碼看起來像這樣:

@RequiredArgsConstructor
public class MyApplication {
    private final OrderServiceClient client;
    public OrderStatus handleOrder(String articleId) {
        try {
            Shipment shipment = client.postOrder(articleId);
            // store shipment
            return SHIPPED;
        } catch (UserNotEntitledToOrderOnAccount e) {
            return NOT_ENTITLED_TO_ORDER_ON_ACCOUNT;
        }
    }
}

有趣部分在OrderServiceClient,在其中手動對映細節錯誤:

public class OrderServiceClient {
    public Shipment postOrder(String article) {
        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
        form.add("article", article);
        RestTemplate template = new RestTemplate();
        try {
            return template.postForObject(BASE_URI + "/orders", form, Shipment.class);
        } catch (HttpStatusCodeException e) {
            String json = e.getResponseBodyAsString();
            ProblemDetail problemDetail = MAPPER.readValue(json, ProblemDetail.class);
            log.info("got {}", problemDetail);
            switch (problemDetail.getType().toString()) {
                case "https://api.myshop.example/apidocs/com/github/t1/problemdetaildemoapp/" +
                        "OrderService.UserNotEntitledToOrderOnAccount.html":
                    throw new UserNotEntitledToOrderOnAccount();
                default:
                    log.warn("unknown problem detail type [" +
                        ProblemDetail.class + "]:\n" + json);
                    throw e;
            }
        }
    }
 
    private static final ObjectMapper MAPPER = new ObjectMapper()
        .disable(FAIL_ON_UNKNOWN_PROPERTIES);
}

下面是響應錯誤處理,Spring REST客戶端上還有一種機制可以使我們對該處理進行概括:

public class OrderServiceClient {
    public Shipment postOrder(String article) {
        MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
        form.add("article", article);
        RestTemplate template = new RestTemplate();
        template.setErrorHandler(new ProblemDetailErrorHandler()); ①
        return template.postForObject(BASE_URI + "/orders", form,
            Shipment.class);
    }
}
①:此行替換了try-catch塊。

ProblemDetailErrorHandler使用了所有約定; 包括一些錯誤處理。在這種情況下,我們會記錄一條警告,然後回退到Spring預設處理方式:

@Slf4j
public class ProblemDetailErrorHandler extends DefaultResponseErrorHandler {
    @Override public void handleError(ClientHttpResponse response) throws IOException {
        if (ProblemDetail.JSON_MEDIA_TYPE.isCompatibleWith(
            response.getHeaders().getContentType())) {
            triggerException(response);
        }
        super.handleError(response);
    }
 
    private void triggerException(ClientHttpResponse response) throws IOException {
        ProblemDetail problemDetail = readProblemDetail(response);
        if (problemDetail != null) {
            log.info("got {}", problemDetail);
            triggerProblemDetailType(problemDetail.getType().toString());
        }
    }
 
    private ProblemDetail readProblemDetail(ClientHttpResponse response) throws IOException {
        ProblemDetail problemDetail = MAPPER.readValue(response.getBody(), ProblemDetail.class);
        if (problemDetail == null) {
            log.warn("can't deserialize problem detail");
            return null;
        }
        if (problemDetail.getType() == null) {
            log.warn("no problem detail type in:\n" + problemDetail);
            return null;
        }
        return problemDetail;
    }
 
    private void triggerProblemDetailType(String type) {
        if (isJavadocUrl(type)) {
            String className = type.substring(36, type.length() - 5)
                .replace('.', '$').replace('/', '.');
            try {
                Class<?> exceptionType = Class.forName(className);
                if (RuntimeException.class.isAssignableFrom(exceptionType)) {
                    Constructor<?> constructor = exceptionType.getDeclaredConstructor();
                    throw (RuntimeException) constructor.newInstance();
                }
                log.warn("problem detail type [" + type + "] is not a RuntimeException");
            } catch (ReflectiveOperationException e) {
                log.warn("can't instantiate " + className, e);
            }
        } else {
            log.warn("unknown problem detail type [" + type + "]");
        }
    }
 
    private boolean isJavadocUrl(String typeString) {
        return typeString.startsWith("https://api.myshop.example/apidocs/")
            && typeString.endsWith(".html");
    }
 
    private static final ObjectMapper MAPPER = new ObjectMapper()
        .disable(FAIL_ON_UNKNOWN_PROPERTIES);
}

從URL恢復異常型別不是理想的做法,因為它將客戶端與伺服器緊密地耦合在一起,即,它假定我們在同一包中使用相同的類。對於演示來說已經足夠好了,但是要正確地進行演示,您需要一種註冊異常或對其進行掃描的方法,例如在我的庫中,該方法還允許擴充套件欄位和其他內容。

JAX-RS

如果您不喜歡JAX-RS,則可能要跳到Summary

這部分處理可點選標題見原文。

總結

避免濫用http狀態程式碼;那是個蛇坑。而是生成標準化的並因此可互操作的問題詳細資訊,這比您想象的要容易。為了不浪費業務邏輯程式碼,可以在伺服器端和客戶端使用異常。通過引入一些約定,大多數程式碼甚至可以通用,並可以在多個應用程式中重用。

實現提供了註解@Type,@Title,@Status,@Instance,@Detail,並@Extension為您的自定義異常。它與Spring Boot以及JAX-RS和MicroProfile Rest Client一起使用。Zalando在問題庫和Spring整合中採用了不同的方法。problem4j也看起來可用。有一些其他語言的解決方案,例如在GitHub rfc7807rfc-7807上

 

相關文章