1、概览 {#1概览}
微服务间的 HTTP API 调用可能会出现异常。在 Spring Boot 中使用 OpenFeign 时,默认会把下游服务的 "Not Found" 等异常全部当做 "Internal Server Error" 响应给客户端。这并不是异常的最佳处理方式,幸而,Spring 和 OpenFeign 都提供了一些机制,允许我们自定义异常处理。
本文将带你了解,Spring Boot 和 OpenFeign 默认的异常传播、处理机制,以及如何实现自定义的异常处理。
2、默认的异常传播策略 {#2默认的异常传播策略}
2.1、Feign 中默认的异常传播 {#21feign-中默认的异常传播}
Feign 使用 ErrorDecoder.Default
内部实现类进行异常处理。每当 Feign 收到任何非 2xx
状态码时,都会将其传递给 ErrorDecoder
的 decode
方法。
如果 HTTP 响应有 Retry-After
头信息,decode
方法就会返回 RetryableException
,否则就会返回 FeignException
。
重试时,如果请求在默认重试次数之后仍然失败,则会返回 FeignException
。
decode
方法将 HTTP 方法 key 和响应存储在 FeignException
中。
2.2、Spring Rest Controller 中的默认异常传播 {#22spring-rest-controller-中的默认异常传播}
只要 RestController
收到任何未处理的异常,它就会向客户端返回 500 Internal Server Error
(内部服务器错误)响应。
该异常响应包含时间戳、HTTP 状态码、异常信息和路径等信息:
{
"timestamp": "2022-07-08T08:07:51.120+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/myapp1/product/Test123"
}
下面,我们通过一个例子来深入了解一下。
3、示例应用 {#3示例应用}
构建一个简单的微服务,调用另一个外部服务返回 product 信息。
首先,创建 Product
Model 类。
public class Product {
private String id;
private String productName;
private double price;
}
然后,在 ProductController
中实现 Get
Product 端点:
@RestController("product_controller")
@RequestMapping(value ="myapp1")
public class ProductController {
private ProductClient productClient;
@Autowired
public ProductController(ProductClient productClient) {
this.productClient = productClient;
}
@GetMapping("/product/{id}")
public Product getProduct(@PathVariable String id) {
return productClient.getProduct(id);
}
}
接下来,将 Feign Logger
注册为 Bean:
public class FeignConfig {
@Bean
Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
}
最后,实现 ProductClient
,以调用外部 API 接口:
@FeignClient(name = "product-client", url="http://localhost:8081/product/", configuration = FeignConfig.class)
public interface ProductClient {
@RequestMapping(value = "{id}", method = RequestMethod.GET")
Product getProduct(@PathVariable(value = "id") String id);
}
4、默认的异常传播 {#4默认的异常传播}
4.1、使用 WireMock Server {#41使用-wiremock-server}
使用 Wiremock 框架来模拟被调用的服务,以进行测试。
首先,添加 WireMockServer
Maven 依赖:
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<version>2.33.2</version>
<scope>test</scope>
</dependency>
然后,配置并启动 WireMockServer
:
WireMockServer wireMockServer = new WireMockServer(8081);
configureFor("localhost", 8081);
wireMockServer.start();
WireMockServer
在与配置的 Feign 客户端相同的 host
和 port
上启动。
4.2、Feign Client 默认的异常传播 {#42feign-client-默认的异常传播}
Feign 默认的 Error handler,ErrorDecoder.Default
总是抛出 FeignException
。
使用 WireMock.stubFor
来模拟 getProduct
方法,返回 SERVICE_UNAVAILABLE 状态(服务不可用)。
String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));
assertThrows(FeignException.class, () -> productClient.getProduct(productId));
如上,当 ProductClient
遇到下游服务的 503
(SERVICE_UNAVAILABLE)异常时,会抛出 FeignException
。
接着,使用 404 Not Found 响应进行同样的测试:
String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.NOT_FOUND.value())));
assertThrows(FeignException.class, () -> productClient.getProduct(productId));
同样地,客户端再次收到 FeignException
。这并不合理,因为 404 NOT_FOUND 异常可能是用户提交的查询有问题。我们需要对不同的异常进行区分,以进行不同的处理。
注意,FeignException
确实具有一个包含 HTTP
状态码的 status
属性,但是 try/catch
策略是根据异常的类型而不是属性来路由异常。
4.3、Spring Rest Controller 的异常传播 {#43spring-rest-controller-的异常传播}
接着,看一下 FeignException
是如何传播回客户端的。
当 ProductController
从 ProductClient
捕获 FeignException
时,它会将其传递给 Spring Boot 默认的异常处理器。
当 product service 不可用时,进行断言:
String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));
mockMvc.perform(get("/myapp1/product/" + productId))
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()));
如上,客户端最终得到得异常状态是 Spring 的 INTERNAL_SERVER_ERROR
状态。
5、使用 ErrorDecoder 在 Feign 中传播自定义异常 {#5使用-errordecoder-在-feign-中传播自定义异常}
为了避免永远返回默认的 FeignException
,我们可以根据 HTTP 状态码返回一些特定的异常。
自定义 ErrorDecoder
实现,覆写 decode
方法:
public class CustomErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
switch (response.status()){
case 400:
return new BadRequestException();
case 404:
return new ProductNotFoundException("Product not found");
case 503:
return new ProductServiceNotAvailableException("Product Api is unavailable");
default:
return new Exception("Exception while getting product details");
}
}
}
在自定义的 decode
方法中,为不同的状态码返回了不同的异常,还在异常中提供了更多的细节信息。
注意,decode
方法是返回 FeignException
,而不是抛出异常。
现在,在 FeignConfig
中将 CustomErrorDecoder
配置为 Spring Bean:
@Bean
public ErrorDecoder errorDecoder() {
return new CustomErrorDecoder();
}
或者,也可以直接在 ProductClient
中配置 CustomErrorDecoder
:
@FeignClient(name = "product-client-2", url = "http://localhost:8081/product/",
configuration = { FeignConfig.class, CustomErrorDecoder.class })
然后,测试 CustomErrorDecoder
是否会返回 ProductServiceNotAvailableException
:
String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));
assertThrows(ProductServiceNotAvailableException.class,
() -> productClient.getProduct(productId));
同样,再写一个测试用例,在 product 不存在时断言 ProductNotFoundException
:
String productId = "test";
stubFor(get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.NOT_FOUND.value())));
assertThrows(ProductNotFoundException.class,
() -> productClient.getProduct(productId));
现在,Feign Client 会根据不同的状态码返回不同的异常,但是在 Spring 捕获该异常后,还是会统一对客户端响应 "internal server error" 状态。
6、在 Spring Rest Controller 中传播自定义异常 {#6在-spring-rest-controller-中传播自定义异常}
Spring Boot 默认的 Error handler 提供了通用的异常响应。客户端可能需要更为详细的异常信息。
有多种方式可以自定义 RestController
的 Exception Handler。在这里我们使用 RestControllerAdvice
注解来处理异常。
6.1、使用 @RestControllerAdvice
{#61使用-restcontrolleradvice}
@RestControllerAdvice
注解允许我们将多个异常合并到一个全局异常处理组件中。
假如:ProductController
需要根据下游服务的异常返回不同的自定义异常响应。
首先,创建 ErrorResponse
类,表示异常响应:
public class ErrorResponse {
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd-MM-yyyy hh:mm:ss")
private Date timestamp;
@JsonProperty(value = "code")
private int code;
@JsonProperty(value = "status")
private String status;
@JsonProperty(value = "message")
private String message;
@JsonProperty(value = "details")
private String details;
}
现在,创建 ResponseEntityExceptionHandler
的子类实现 ProductExceptionHandler
,并在异常处理方法上添加 @ExceptionHandler
注解:
@RestControllerAdvice
public class ProductExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler({ProductServiceNotAvailableException.class})
public ResponseEntity<ErrorResponse> handleProductServiceNotAvailableException(ProductServiceNotAvailableException exception, WebRequest request) {
return new ResponseEntity<>(new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
exception.getMessage(),
request.getDescription(false)),
HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler({ProductNotFoundException.class})
public ResponseEntity<ErrorResponse> handleProductNotFoundException(ProductNotFoundException exception, WebRequest request) {
return new ResponseEntity<>(new ErrorResponse(
HttpStatus.NOT_FOUND,
exception.getMessage(),
request.getDescription(false)),
HttpStatus.NOT_FOUND);
}
}
如上,ProductServiceNotAvailableException
异常会响应 INTERNAL_SERVER_ERROR
状态给客户端。而,用户特定的异常(如 ProductNotFoundException
)会以不同的方式处理,并返回一个 NOT_FOUND
响应。
6.2、测试 Spring Rest Controller {#62测试-spring-rest-controller}
在 product service 不可用时测试 ProductController
:
String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.SERVICE_UNAVAILABLE.value())));
MvcResult result = mockMvc.perform(get("/myapp2/product/" + productId))
.andExpect(status().isInternalServerError()).andReturn();
ErrorResponse errorResponse = objectMapper.readValue(result.getResponse().getContentAsString(), ErrorResponse.class);
assertEquals(500, errorResponse.getCode());
assertEquals("Product Api is unavailable", errorResponse.getMessage());
接着,再次测试同一个 ProductController
,但这次会返回 "Product not found" 异常消息:
String productId = "test";
stubFor(WireMock.get(urlEqualTo("/product/" + productId))
.willReturn(aResponse()
.withStatus(HttpStatus.NOT_FOUND.value())));
MvcResult result = mockMvc.perform(get("/myapp2/product/" + productId))
.andExpect(status().isNotFound()).andReturn();
ErrorResponse errorResponse = objectMapper.readValue(result.getResponse().getContentAsString(), ErrorResponse.class);
assertEquals(404, errorResponse.getCode());
assertEquals("Product not found", errorResponse.getMessage());
上述测试显示了 ProductController
如何根据下游服务的异常返回不同状态的异常响应。
如果我们没有实现自定义的 CustomErrorDecoder
,那么需要使用 RestControllerAdvice
来直接处理 Feign Client 默认的 FeignException
。
7、总结 {#7总结}
在本文中,我们学习了如何在 Feign Client 中使用 ErrorDecoder
以及在 Rest Controller 中使用 RestControllerAdvice
进行自定义异常处理。
参考:https://www.baeldung.com/category/spring