SpringCloud升級之路2020.0.x版-38. 實現自定義 WebClient 的 NamedContextFactory

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

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

實現 WeClient 的 NamedContextFactory

我們要實現的是不同微服務自動配置裝載不同的 WebClient Bean,這樣就可以通過 NamedContextFactory 實現。我們先來編寫下實現這個 NamedContextFactory 整個的載入流程的程式碼,其結構圖如下所示:

image

spring.factories

# AutoConfiguration
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.github.jojotech.spring.cloud.webflux.auto.WebClientAutoConfiguration

在 spring.factories 定義了自動裝載的自動配置類 WebClientAutoConfiguration

WebClientAutoConfiguration

@Import(WebClientConfiguration.class)
@Configuration(proxyBeanMethods = false)
public class WebClientAutoConfiguration {
}

WebClientAutoConfiguration 這個自動配置類 Import 了 WebClientConfiguration

WebClientConfiguration

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(WebClientConfigurationProperties.class)
public class WebClientConfiguration {
    @Bean
    public WebClientNamedContextFactory getWebClientNamedContextFactory() {
        return new WebClientNamedContextFactory();
    }
}

WebClientConfiguration 中建立了 WebClientNamedContextFactory 這個 NamedContextFactory 的 Bean。在這個 NamedContextFactory 中,定義了預設配置 WebClientDefaultConfiguration。在這個預設配置中,主要是給每個微服務都定義了一個 WebClient

定義 WebClient 的配置類

我們編寫下上一節定義的配置,包括:

  • 微服務名稱
  • 微服務地址,服務地址,不填寫則為 http://微服務名稱
  • 連線超時,使用 Duration,這樣我們可以用更直觀的配置了,例如 5ms,6s,7m 等等
  • 響應超時,使用 Duration,這樣我們可以用更直觀的配置了,例如 5ms,6s,7m 等等
  • 可以重試的路徑,預設只對 GET 方法重試,通過這個配置增加針對某些非 GET 方法的路徑的重試;同時,這些路徑可以使用 * 等路徑匹配符,即 Spring 中的 AntPathMatcher 進行路徑匹配多個路徑。例如 /query/order/**

WebClientConfigurationProperties

@Data
@NoArgsConstructor
@ConfigurationProperties(prefix = "webclient")
public class WebClientConfigurationProperties {
    private Map<String, WebClientProperties> configs;
    @Data
    @NoArgsConstructor
    public static class WebClientProperties {
        private static AntPathMatcher antPathMatcher = new AntPathMatcher();
        private Cache<String, Boolean> retryablePathsMatchResult = Caffeine.newBuilder().build();
        /**
         * 服務地址,不填寫則為 http://serviceName
         */
        private String baseUrl;
        /**
         * 微服務名稱,不填寫就是 configs 這個 map 的 key
         */
        private String serviceName;
        /**
         * 可以重試的路徑,預設只對 GET 方法重試,通過這個配置增加針對某些非 GET 方法的路徑的重試
         */
        private List<String> retryablePaths;
        /**
         * 連線超時
         */
        private Duration connectTimeout = Duration.ofMillis(500);
        /**
         * 響應超時
         */
        private Duration responseTimeout = Duration.ofSeconds(8);

        /**
         * 是否匹配
         * @param path
         * @return
         */
        public boolean retryablePathsMatch(String path) {
            if (CollectionUtils.isEmpty(retryablePaths)) {
                return false;
            }
            return retryablePathsMatchResult.get(path, k -> {
                return retryablePaths.stream().anyMatch(pattern -> antPathMatcher.match(pattern, path));
            });
        }
    }
}

粘合 WebClient 與 resilience4j

接下來粘合 WebClient 與 resilience4j 實現斷路器以及重試邏輯,WebClient 基於 project-reactor 實現,resilience4j 官方提供了與 project-reactor 的粘合庫:

<!--粘合 project-reactor 與 resilience4j,這個在非同步場景經常會用到-->
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-reactor</artifactId>
</dependency>

參考官方文件,我們可以像下面這樣給普通的 WebClient 增加相關元件:

增加重試器

//由於還是在前面弄好的 spring-cloud 環境下,所以還是可以這樣獲取配置對應的 retry
Retry retry;
try {
    retry = retryRegistry.retry(name, name);
} catch (ConfigurationNotFoundException e) {
    retry = retryRegistry.retry(name);
}

Retry finalRetry = retry;
WebClient.builder().filter((clientRequest, exchangeFunction) -> {
    return exchangeFunction.exchange(clientRequest)
        //核心就是加入 RetryOperator
        .transform(RetryOperator.of(finalRetry));
})

這個 RetryOperator 其實就是使用了 project-reactor 中的 retryWhen 方法實現了 resilience4j 的 retry 機制:

RetryOperator

@Override
public Publisher<T> apply(Publisher<T> publisher) {
    //對於 mono 的處理
    if (publisher instanceof Mono) {
        Context<T> context = new Context<>(retry.asyncContext());
        Mono<T> upstream = (Mono<T>) publisher;
        return upstream.doOnNext(context::handleResult)
            .retryWhen(reactor.util.retry.Retry.withThrowable(errors -> errors.flatMap(context::handleErrors)))
            .doOnSuccess(t -> context.onComplete());
    } else if (publisher instanceof Flux) {
        //對於 flux 的處理
        Context<T> context = new Context<>(retry.asyncContext());
        Flux<T> upstream = (Flux<T>) publisher;
        return upstream.doOnNext(context::handleResult)
            .retryWhen(reactor.util.retry.Retry.withThrowable(errors -> errors.flatMap(context::handleErrors)))
            .doOnComplete(context::onComplete);
    } else {
        //不可能是 mono 或者 flux 以外的其他的
        throw new IllegalPublisherException(publisher);
    }
}

可以看出,其實主要填充了:

  • doOnNext(context::handleResult): 在有響應之後呼叫,將響應結果傳入 retry 的 Context,判斷是否需要重試以及重試間隔是多久,並且丟擲異常 RetryDueToResultException
  • retryWhen(reactor.util.retry.Retry.withThrowable(errors -> errors.flatMap(context::handleErrors))):捕捉異常 RetryDueToResultException,根據其中的間隔時間,返回 reactor 的重試間隔: Mono.delay(Duration.ofMillis(waitDurationMillis))
  • doOnComplete(context::onComplete):請求完成,沒有異常之後,呼叫 retry 的 complete 進行清理

增加斷路器

//由於還是在前面弄好的 spring-cloud 環境下,所以還是可以這樣獲取配置對應的 circuitBreaker
CircuitBreaker circuitBreaker;
try {
    circuitBreaker = circuitBreakerRegistry.circuitBreaker(instancId, finalServiceName);
} catch (ConfigurationNotFoundException e) {
    circuitBreaker = circuitBreakerRegistry.circuitBreaker(instancId);
}

CircuitBreaker finalCircuitBreaker = circuitBreaker;
WebClient.builder().filter((clientRequest, exchangeFunction) -> {
    return exchangeFunction.exchange(clientRequest)
        //核心就是加入 CircuitBreakerOperator
        .transform(CircuitBreakerOperator.of(finalCircuitBreaker));
})

類似的,CircuitBreakerOperator 其實也是粘合斷路器與 reactor 的 publisher 中的一些 stage 方法,將結果的成功或者失敗記錄入斷路器,這裡需要注意,可能有的鏈路能走到 onNext,可能有的鏈路能走到 onComplete,也有可能都走到,所以這兩個方法都要記錄成功,並且保證只記錄一次

CircuitBreakerSubscriber

class CircuitBreakerSubscriber<T> extends AbstractSubscriber<T> {

    private final CircuitBreaker circuitBreaker;

    private final long start;
    private final boolean singleProducer;

    private final AtomicBoolean successSignaled = new AtomicBoolean(false);
    private final AtomicBoolean eventWasEmitted = new AtomicBoolean(false);

    protected CircuitBreakerSubscriber(CircuitBreaker circuitBreaker,
        CoreSubscriber<? super T> downstreamSubscriber,
        boolean singleProducer) {
        super(downstreamSubscriber);
        this.circuitBreaker = requireNonNull(circuitBreaker);
        this.singleProducer = singleProducer;
        this.start = circuitBreaker.getCurrentTimestamp();
    }

    @Override
    protected void hookOnNext(T value) {
        if (!isDisposed()) {
             //正常完成時,斷路器也標記成功,因為可能會觸發多次(因為 onComplete 也會記錄),所以需要 successSignaled 標記只記錄一次
            if (singleProducer && successSignaled.compareAndSet(false, true)) {
                circuitBreaker.onResult(circuitBreaker.getCurrentTimestamp() - start, circuitBreaker.getTimestampUnit(), value);
            }
            //標記事件已經發出,就是已經執行完 WebClient 的請求,後面判斷取消的時候會用到
            eventWasEmitted.set(true);

            downstreamSubscriber.onNext(value);
        }
    }

    @Override
    protected void hookOnComplete() {
        //正常完成時,斷路器也標記成功,因為可能會觸發多次(因為 onNext 也會記錄),所以需要 successSignaled 標記只記錄一次
        if (successSignaled.compareAndSet(false, true)) {
            circuitBreaker.onSuccess(circuitBreaker.getCurrentTimestamp() - start, circuitBreaker.getTimestampUnit());
        }

        downstreamSubscriber.onComplete();
    }

    @Override
    public void hookOnCancel() {
        if (!successSignaled.get()) {
            //如果事件已經發出,那麼也記錄成功
            if (eventWasEmitted.get()) {
                circuitBreaker.onSuccess(circuitBreaker.getCurrentTimestamp() - start, circuitBreaker.getTimestampUnit());
            } else {
                //否則取消
                circuitBreaker.releasePermission();
            }
        }
    }

    @Override
    protected void hookOnError(Throwable e) {
        //記錄失敗
        circuitBreaker.onError(circuitBreaker.getCurrentTimestamp() - start, circuitBreaker.getTimestampUnit(), e);
        downstreamSubscriber.onError(e);
    }
}

我們會使用這個庫進行粘合,但是不會直接使用上面的程式碼,因為考慮到:

  • 需要在重試以及斷路中加一些日誌,便於日後的優化
  • 需要定義重試的 Exception,並且與斷路器相結合,將非 2xx 的響應碼也封裝成特定的異常
  • 需要在斷路器相關的 Operator 中增加類似於 FeignClient 中的負載均衡的資料更新,使得負載均衡更加智慧

在下面一節我們會詳細說明我們是如何實現的有斷路器以及重試邏輯和負載均衡資料更新的 WebClient。

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

相關文章