SpringCloud升級之路2020.0.x版-34.驗證重試配置正確性(1)

乾貨滿滿張雜湊發表於2021-11-13

本系列程式碼地址:https://github.com/JoJoTec/spring-cloud-parent

在前面一節,我們利用 resilience4j 粘合了 OpenFeign 實現了斷路器、重試以及執行緒隔離,並使用了新的負載均衡演算法優化了業務激增時的負載均衡演算法表現。這一節,我們開始編寫單元測試驗證這些功能的正確性,以便於日後升級依賴,修改的時候能保證正確性。同時,通過單元測試,我們更能深入理解 Spring Cloud。

驗證重試配置

對於我們實現的重試,我們需要驗證:

  1. 驗證配置正確載入:即我們在 Spring 配置(例如 application.yml)中的加入的 Resilience4j 的配置被正確載入應用了。
  2. 驗證針對 ConnectTimeout 重試正確:FeignClient 可以配置 ConnectTimeout 連線超時時間,如果連線超時會有連線超時異常丟擲,對於這種異常無論什麼請求都應該重試,因為請求並沒有發出。
  3. 驗證針對斷路器異常的重試正確:斷路器是微服務例項方法級別的,如果丟擲斷路器開啟異常,應該直接重試下一個例項。
  4. 驗證針對限流器異常的重試正確:當某個例項執行緒隔離滿了的時候,丟擲執行緒限流異常應該直接重試下一個例項。
  5. 驗證針對非 2xx 響應碼可重試的方法重試正確
  6. 驗證針對非 2xx 響應碼不可重試的方法沒有重試
  7. 驗證針對可重試的方法響應超時異常重試正確:FeignClient 可以配置 ReadTimeout 即響應超時,如果方法可以重試,則需要重試。
  8. 驗證針對不可重試的方法響應超時異常不能重試:FeignClient 可以配置 ReadTimeout 即響應超時,如果方法不可以重試,則不能重試。

驗證配置正確載入

我們可以定義不同的 FeignClient,之後檢查 resilience4j 載入的重試配置來驗證重試配置的正確載入。

首先定義兩個 FeignClient,微服務分別是 testService1 和 testService2,contextId 分別是 testService1Client 和 testService2Client

@FeignClient(name = "testService1", contextId = "testService1Client")
public interface TestService1Client {
    @GetMapping("/anything")
    HttpBinAnythingResponse anything();
}
@FeignClient(name = "testService2", contextId = "testService2Client")
    public interface TestService2Client {
        @GetMapping("/anything")
        HttpBinAnythingResponse anything();
}

然後,我們增加 Spring 配置,使用 SpringExtension 編寫單元測試類:

//SpringExtension也包含了 Mockito 相關的 Extension,所以 @Mock 等註解也生效了
@ExtendWith(SpringExtension.class)
@SpringBootTest(properties = {
        //預設請求重試次數為 3
        "resilience4j.retry.configs.default.maxAttempts=3",
        // testService2Client 裡面的所有方法請求重試次數為 2
        "resilience4j.retry.configs.testService2Client.maxAttempts=2",
})
@Log4j2
public class OpenFeignClientTest {
    @SpringBootApplication
    @Configuration
    public static class App {
    }
}

編寫測試程式碼,驗證配置載入正確性:

@Test
public void testConfigureRetry() {
    //讀取所有的 Retry
    List<Retry> retries = retryRegistry.getAllRetries().asJava();
    //驗證其中的配置是否符合我們填寫的配置
    Map<String, Retry> retryMap = retries.stream().collect(Collectors.toMap(Retry::getName, v -> v));
    //我們初始化 Retry 的時候,使用 FeignClient 的 ContextId 作為了 Retry 的 Name
    Retry retry = retryMap.get("testService1Client");
    //驗證 Retry 配置存在
    Assertions.assertNotNull(retry);
    //驗證 Retry 配置符合我們的配置
    Assertions.assertEquals(retry.getRetryConfig().getMaxAttempts(), 3);
    retry = retryMap.get("testService2Client");
    //驗證 Retry 配置存在
    Assertions.assertNotNull(retry);
    //驗證 Retry 配置符合我們的配置
    Assertions.assertEquals(retry.getRetryConfig().getMaxAttempts(), 2);
}

驗證針對 ConnectTimeout 重試正確

我們可以通過針對一個微服務註冊兩個例項,一個例項是連線不上的,另一個例項是可以正常連線的,無論怎麼呼叫 FeignClient,請求都不會失敗,來驗證重試是否生效。我們使用 HTTP 測試網站來測試,即 http://httpbin.org 。這個網站的 api 可以用來模擬各種呼叫。其中 /status/{status} 就是將傳送的請求原封不動的在響應中返回。在單元測試中,我們不會單獨部署一個註冊中心,而是直接 Mock spring cloud 中服務發現的核心介面 DiscoveryClient,並且將我們 Eureka 的服務發現以及註冊通過配置都關閉,即:

//SpringExtension也包含了 Mockito 相關的 Extension,所以 @Mock 等註解也生效了
@ExtendWith(SpringExtension.class)
@SpringBootTest(properties = {
        //關閉 eureka client
        "eureka.client.enabled=false",
        //預設請求重試次數為 3
        "resilience4j.retry.configs.default.maxAttempts=3"
})
@Log4j2
public class OpenFeignClientTest {
    @SpringBootApplication
    @Configuration
    public static class App {
        @Bean
        public DiscoveryClient discoveryClient() {
            //模擬兩個服務例項
            ServiceInstance service1Instance1 = Mockito.spy(ServiceInstance.class);
            ServiceInstance service1Instance4 = Mockito.spy(ServiceInstance.class);
            Map<String, String> zone1 = Map.ofEntries(
                    Map.entry("zone", "zone1")
            );
            when(service1Instance1.getMetadata()).thenReturn(zone1);
            when(service1Instance1.getInstanceId()).thenReturn("service1Instance1");
            when(service1Instance1.getHost()).thenReturn("httpbin.org");
            when(service1Instance1.getPort()).thenReturn(80);
            when(service1Instance4.getInstanceId()).thenReturn("service1Instance4");
            when(service1Instance4.getHost()).thenReturn("www.httpbin.org");
            //這個port連不上,測試 IOException
            when(service1Instance4.getPort()).thenReturn(18080);
            DiscoveryClient spy = Mockito.spy(DiscoveryClient.class);
            //微服務 testService3 有兩個例項即 service1Instance1 和 service1Instance4
            Mockito.when(spy.getInstances("testService3"))
                    .thenReturn(List.of(service1Instance1, service1Instance4));
            return spy;
        }
    }
}

編寫 FeignClient:

@FeignClient(name = "testService3", contextId = "testService3Client")
public interface TestService3Client {
    @PostMapping("/anything")
    HttpBinAnythingResponse anything();
}

呼叫 TestService3Client 的 anything 方法,驗證是否有重試:

@SpyBean
private TestService3Client testService3Client;

/**
 * 驗證對於有不正常例項(正在關閉的例項,會 connect timeout)請求是否正常重試
 */
@Test
public void testIOExceptionRetry() {
    //防止斷路器影響
    circuitBreakerRegistry.getAllCircuitBreakers().asJava().forEach(CircuitBreaker::reset);
    for (int i = 0; i < 5; i++) {
        Span span = tracer.nextSpan();
        try (Tracer.SpanInScope cleared = tracer.withSpanInScope(span)) {
            //不丟擲異常,則正常重試了
            testService3Client.anything();
            testService3Client.anything();
        }
    }
}

這裡強調一點,由於我們在這個類中還會測試其他異常,以及斷路器,我們需要避免這些測試一起執行的時候,斷路器開啟了,所以我們在所有測試呼叫 FeignClient 的方法開頭,清空所有斷路器的資料,通過:

circuitBreakerRegistry.getAllCircuitBreakers().asJava().forEach(CircuitBreaker::reset);

並且通過日誌中可以看出由於 connect timeout 進行重試:

call url: POST -> http://www.httpbin.org:18080/anything, ThreadPoolStats(testService3Client:www.httpbin.org:18080): {"coreThreadPoolSize":10,"maximumThreadPoolSize":10,"queueCapacity":100,"queueDepth":0,"remainingQueueCapacity":100,"threadPoolSize":1}, CircuitBreakStats(testService3Client:www.httpbin.org:18080:public abstract com.github.jojotech.spring.cloud.webmvc.test.feign.HttpBinAnythingResponse com.github.jojotech.spring.cloud.webmvc.test.feign.OpenFeignClientTest$TestService3Client.anything()): {"failureRate":-1.0,"numberOfBufferedCalls":0,"numberOfFailedCalls":0,"numberOfNotPermittedCalls":0,"numberOfSlowCalls":0,"numberOfSlowFailedCalls":0,"numberOfSlowSuccessfulCalls":0,"numberOfSuccessfulCalls":0,"slowCallRate":-1.0}
TestService3Client#anything() response: 582-Connect to www.httpbin.org:18080 [www.httpbin.org/34.192.79.103, www.httpbin.org/18.232.227.86, www.httpbin.org/3.216.167.140, www.httpbin.org/54.156.165.4] failed: Connect timed out, should retry: true
call url: POST -> http://httpbin.org:80/anything, ThreadPoolStats(testService3Client:httpbin.org:80): {"coreThreadPoolSize":10,"maximumThreadPoolSize":10,"queueCapacity":100,"queueDepth":0,"remainingQueueCapacity":100,"threadPoolSize":1}, CircuitBreakStats(testService3Client:httpbin.org:80:public abstract com.github.jojotech.spring.cloud.webmvc.test.feign.HttpBinAnythingResponse com.github.jojotech.spring.cloud.webmvc.test.feign.OpenFeignClientTest$TestService3Client.anything()): {"failureRate":-1.0,"numberOfBufferedCalls":0,"numberOfFailedCalls":0,"numberOfNotPermittedCalls":0,"numberOfSlowCalls":0,"numberOfSlowFailedCalls":0,"numberOfSlowSuccessfulCalls":0,"numberOfSuccessfulCalls":0,"slowCallRate":-1.0}
response: 200 - OK

驗證針對斷路器異常的重試正確

通過系列前面的原始碼分析,我們知道 spring-cloud-openfeign 的 FeignClient 其實是懶載入的。所以我們實現的斷路器也是懶載入的,需要先呼叫,之後才會初始化斷路器。所以這裡如果我們要模擬斷路器開啟的異常,需要先手動讀取載入斷路器,之後才能獲取對應方法的斷路器,修改狀態。

我們先定義一個 FeignClient:

@FeignClient(name = "testService1", contextId = "testService1Client")
public interface TestService1Client {
    @GetMapping("/anything")
    HttpBinAnythingResponse anything();
}

使用前面同樣的方式,給這個微服務新增例項:

//SpringExtension也包含了 Mockito 相關的 Extension,所以 @Mock 等註解也生效了
@ExtendWith(SpringExtension.class)
@SpringBootTest(properties = {
        //關閉 eureka client
        "eureka.client.enabled=false",
        //預設請求重試次數為 3
        "resilience4j.retry.configs.default.maxAttempts=3",
        //增加斷路器配置
        "resilience4j.circuitbreaker.configs.default.failureRateThreshold=50",
        "resilience4j.circuitbreaker.configs.default.slidingWindowType=COUNT_BASED",
        "resilience4j.circuitbreaker.configs.default.slidingWindowSize=5",
        "resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls=2",
})
@Log4j2
public class OpenFeignClientTest {
    @SpringBootApplication
    @Configuration
    public static class App {
        @Bean
        public DiscoveryClient discoveryClient() {
            //模擬兩個服務例項
            ServiceInstance service1Instance1 = Mockito.spy(ServiceInstance.class);
            ServiceInstance service1Instance3 = Mockito.spy(ServiceInstance.class);
            Map<String, String> zone1 = Map.ofEntries(
                    Map.entry("zone", "zone1")
            );
            when(service1Instance1.getMetadata()).thenReturn(zone1);
            when(service1Instance1.getInstanceId()).thenReturn("service1Instance1");
            when(service1Instance1.getHost()).thenReturn("httpbin.org");
            when(service1Instance1.getPort()).thenReturn(80);
            when(service1Instance3.getMetadata()).thenReturn(zone1);
            when(service1Instance3.getInstanceId()).thenReturn("service1Instance3");
            //這其實就是 httpbin.org ,為了和第一個例項進行區分加上 www
            when(service1Instance3.getHost()).thenReturn("www.httpbin.org");
            DiscoveryClient spy = Mockito.spy(DiscoveryClient.class);
            //微服務 testService3 有兩個例項即 service1Instance1 和 service1Instance4
            Mockito.when(spy.getInstances("testService1"))
                    .thenReturn(List.of(service1Instance1, service1Instance3));
            return spy;
        }
    }
}

然後,編寫測試程式碼:

@Test
public void testRetryOnCircuitBreakerException() {
    //防止斷路器影響
    circuitBreakerRegistry.getAllCircuitBreakers().asJava().forEach(CircuitBreaker::reset);
    CircuitBreaker testService1ClientInstance1Anything;
    try {
        testService1ClientInstance1Anything = circuitBreakerRegistry
                .circuitBreaker("testService1Client:httpbin.org:80:public abstract com.github.jojotech.spring.cloud.webmvc.test.feign.HttpBinAnythingResponse com.github.jojotech.spring.cloud.webmvc.test.feign.OpenFeignClientTest$TestService1Client.anything()", "testService1Client");
    } catch (ConfigurationNotFoundException e) {
        //找不到就用預設配置
        testService1ClientInstance1Anything = circuitBreakerRegistry
                .circuitBreaker("testService1Client:httpbin.org:80:public abstract com.github.jojotech.spring.cloud.webmvc.test.feign.HttpBinAnythingResponse com.github.jojotech.spring.cloud.webmvc.test.feign.OpenFeignClientTest$TestService1Client.anything()");
    }
    //將斷路器開啟
    testService1ClientInstance1Anything.transitionToOpenState();
    //呼叫多次,呼叫成功即對斷路器異常重試了
    for (int i = 0; i < 10; i++) {
        this.testService1Client.anything();
    }
}

執行測試,日誌中可以看出,針對斷路器開啟的異常進行重試了:

2021-11-13 03:40:13.546  INFO [,,] 4388 --- [           main] c.g.j.s.c.w.f.DefaultErrorDecoder        : TestService1Client#anything() response: 581-CircuitBreaker 'testService1Client:httpbin.org:80:public abstract com.github.jojotech.spring.cloud.webmvc.test.feign.HttpBinAnythingResponse com.github.jojotech.spring.cloud.webmvc.test.feign.OpenFeignClientTest$TestService1Client.anything()' is OPEN and does not permit further calls, should retry: true

微信搜尋“我的程式設計喵”關注公眾號,每日一刷,輕鬆提升技術,斬獲各種offer

相關文章