NamedContextFactory that implements WeClient

What we want to achieve is that different micro services automatically configure and load different webclient beans, which can be implemented through NamedContextFactory. Let's write the following code to implement the whole loading process of the NamedContextFactory. Its structure diagram is as follows:

spring.factories

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

The auto configuration class WebClientAutoConfiguration for auto loading is defined in spring.factories

WebClientAutoConfiguration

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

WebClientAutoConfiguration this autoconfiguration class imports WebClientAutoConfiguration

WebClientConfiguration

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

WebClientNamedContextFactory is created in WebClientConfiguration, which is the Bean of the NamedContextFactory. In this NamedContextFactory, the default configuration WebClientDefaultConfiguration is defined. In this default configuration, a WebClient is defined for each micro service

Define the configuration class of WebClient

We write the configuration defined in the next section, including:

  • Microservice name
  • Micro service address, service address; if it is not filled in, it is http: / / microservice name
  • Connection timeout, use Duration, so that we can use more intuitive configuration, such as 5ms, 6s, 7m, etc
  • Response timeout, use Duration, so that we can use more intuitive configuration, such as 5ms, 6s, 7m, etc
  • The path that can be retried. By default, only GET methods are retried. Through this configuration, retries for some non GET methods are added; At the same time, these paths can use * and other path matchers, that is, AntPathMatcher in Spring to match multiple paths. For example, / 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();
        /**
         * Service address; if it is not filled in, it is http://serviceName
         */
        private String baseUrl;
        /**
         * The name of the micro service. If it is not filled in, it is the key of the map of configs
         */
        private String serviceName;
        /**
         * The path that can be retried. By default, it is only retried for GET methods. This configuration increases the retry for some non GET methods
         */
        private List<String> retryablePaths;
        /**
         * connection timed out
         */
        private Duration connectTimeout = Duration.ofMillis(500);
        /**
         * Response timeout
         */
        private Duration responseTimeout = Duration.ofSeconds(8);

        /**
         * Match
         * @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));
            });
        }
    }
}

Bonding WebClient and resilience4j

Next, WebClient and resilience4j are bonded to implement circuit breaker and retry logic. WebClient is implemented based on project reactor. Resilience4j officially provides a bonding library with project reactor:

<!--Bonding project-reactor And resilience4j,This is often used in asynchronous scenarios-->
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-reactor</artifactId>
</dependency>

Referring to the official documents, we can add relevant components to the ordinary WebClient as follows:

Add retrier:

//Because it is still in the spring cloud environment, you can still obtain the retry corresponding to the configuration in this way
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)
        //The core is to join RetryOperator
        .transform(RetryOperator.of(finalRetry));
})

The RetryOperator actually uses the retryWhen method in project reactor to implement the retrymechanism of resilience4j:

RetryOperator

@Override
public Publisher<T> apply(Publisher<T> publisher) {
    //Handling of 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) {
        //Handling of 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 {
        //It can't be anything other than mono or flux
        throw new IllegalPublisherException(publisher);
    }
}

It can be seen that it is mainly filled with:

  • doOnNext(context::handleResult): after the response is called, the result of the response is sent to retry's Context to determine whether it is necessary to retry and retest the interval, and throw an exception RetryDueToResultException.
  • Retrywhen (reactor. Util. Retry. Retry. Withthrowable (errors - > errors. Flatmap (context:: handleerrors)): catch the exception RetryDueToResultException, and return the retry interval of reactor according to the interval: mono. Delay (duration. Ofmillis (waitduration millis))
  • doOnComplete(context::onComplete): after the request is completed, there is no exception, then the complete of retry is cleaned.

Add circuit breaker:

//Because it is still in the spring cloud environment, you can still obtain the circuit breaker corresponding to the configuration in this way
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)
        //The core is to join the circuit breaker operator
        .transform(CircuitBreakerOperator.of(finalCircuitBreaker));
})

Similarly, CircuitBreakerOperator is actually some stage methods in the publisher of bonding circuit breaker and reactor to record the success or failure of the result into the circuit breaker. It should be noted here that some links may go to onNext, some links may go to onComplete, or both. Therefore, success should be recorded in both methods, And ensure that it is recorded only once:

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()) {
             //When it is normally completed, the circuit breaker is also marked as successful, because it may be triggered multiple times (because onComplete will also be recorded), so the successSignaled mark is required to be recorded only once
            if (singleProducer && successSignaled.compareAndSet(false, true)) {
                circuitBreaker.onResult(circuitBreaker.getCurrentTimestamp() - start, circuitBreaker.getTimestampUnit(), value);
            }
            //Mark that the event has been sent, that is, the WebClient request has been executed. It will be used later to determine whether to cancel
            eventWasEmitted.set(true);

            downstreamSubscriber.onNext(value);
        }
    }

    @Override
    protected void hookOnComplete() {
        //When it is normally completed, the circuit breaker is also marked as successful, because it may be triggered multiple times (because onNext will also record), so the successSignaled mark is required to be recorded only once
        if (successSignaled.compareAndSet(false, true)) {
            circuitBreaker.onSuccess(circuitBreaker.getCurrentTimestamp() - start, circuitBreaker.getTimestampUnit());
        }

        downstreamSubscriber.onComplete();
    }

    @Override
    public void hookOnCancel() {
        if (!successSignaled.get()) {
            //If the event has been issued, success is also recorded
            if (eventWasEmitted.get()) {
                circuitBreaker.onSuccess(circuitBreaker.getCurrentTimestamp() - start, circuitBreaker.getTimestampUnit());
            } else {
                //Otherwise cancel
                circuitBreaker.releasePermission();
            }
        }
    }

    @Override
    protected void hookOnError(Throwable e) {
        //Record failed
        circuitBreaker.onError(circuitBreaker.getCurrentTimestamp() - start, circuitBreaker.getTimestampUnit(), e);
        downstreamSubscriber.onError(e);
    }
}

We will use this library for bonding, but we will not directly use the above code because we consider:

  • You need to add some logs in the retry and open circuit to facilitate future optimization
  • It is necessary to define a retry Exception, and combine it with the circuit breaker to encapsulate the non 2xx response code into a specific Exception
  • It is necessary to add data updates similar to load balancing in FeignClient in circuit breaker related operators to make load balancing more intelligent

Posted on Tue, 30 Nov 2021 03:23:45 -0500 by jharbin