From 38af35ac370251af85a7e5417e4be192746adc9c Mon Sep 17 00:00:00 2001 From: Olga Maciaszek-Sharma Date: Mon, 2 Sep 2019 20:38:47 +0200 Subject: [PATCH 1/2] Fix gh 591 backport lb changes (#597) * Gh 576 use reactive load balancer (#584) * Add `ReactorLoadBalancerClient` interface and its default implementation. * Add initial `ReactorLoadBalancerExchangeFilterFunction` implementation. Add `ReactorLoadBalancerClientAutoConfiguration`. Refactor `ReactorLoadBalancerClient` interface and default implementation. * Implement configuration changes to make default `ReactorLoadBalancer` and `ReactorLoadBalancerClient` work out of the box with `@LoadBalanced WebClient.Builder`. * Add more tests for ReactorLoadBalancerExchangeFilterFunction and DefaultReactorLoadBalancer. * Fix configuration. Add tests. Add documentation. * Add information on caching to the documentation. * Add fixes after code review. * Small refactoring after code review. * Switch from handle(response, sink) to map(response). * Remove redundant cast. * Add link to caching in Springboot reference to the docs. * Add more information on working with spring-cloud-loadbalancer vs. spring-cloud-starter-netflix-ribbon to the docs. * Fix after code review. (cherry picked from commit 3f17c0d902932f829fe583ac7e80d9f7a4b806b0) * Fix gh 491 gh 553 non reactive loadbalancer client (#590) * Provide non-reactive LB client implemenation to use with RestTemplate. Fixes gh-491. Fixes gh-553. * Add javadoc. (cherry picked from commit c60df0f18e674f737c9862171275e6c795fd7b82) * Backport LoadBalancer changes. Fixes gh-591. Fix gh 491 gh 553 non reactive loadbalancer client (#590) * Provide non-reactive LB client implemenation to use with RestTemplate. Fixes gh-491. Fixes gh-553. * Add javadoc. (cherry picked from commit c60df0f18e674f737c9862171275e6c795fd7b82) * Update @since in javadocs after backporting changes to 2.1.x. --- .../main/asciidoc/spring-cloud-commons.adoc | 103 ++++++- .../loadbalancer/LoadBalancerUriTools.java | 124 ++++++++ .../reactive/ReactiveLoadBalancer.java | 8 + ...ReactiveLoadBalancerAutoConfiguration.java | 6 +- ...orLoadBalancerClientAutoConfiguration.java | 80 +++++ ...torLoadBalancerExchangeFilterFunction.java | 110 +++++++ .../main/resources/META-INF/spring.factories | 1 + .../LoadBalancerUriToolsTests.java | 276 ++++++++++++++++++ .../reactive/LoadBalancerTestUtils.java | 61 ++++ ...iveLoadBalancerAutoConfigurationTests.java | 60 ++-- ...dBalancerClientAutoConfigurationTests.java | 147 ++++++++++ ...adBalancerExchangeFilterFunctionTests.java | 152 ++++++++++ .../reactive/TestReactiveLoadBalancer.java | 49 ++++ spring-cloud-loadbalancer/pom.xml | 2 +- .../LoadBalancerClientConfiguration.java | 19 ++ .../client/BlockingLoadBalancerClient.java | 93 ++++++ ...ngLoadBalancerClientAutoConfiguration.java | 85 ++++++ .../config/LoadBalancerAutoConfiguration.java | 6 + .../ReactorServiceInstanceLoadBalancer.java | 31 ++ .../core/RoundRobinLoadBalancer.java | 2 +- .../support/LoadBalancerClientFactory.java | 12 +- .../main/resources/META-INF/spring.factories | 4 +- .../BlockingLoadBalancerClientTests.java | 209 +++++++++++++ 23 files changed, 1606 insertions(+), 34 deletions(-) create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerUriTools.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerClientAutoConfiguration.java create mode 100644 spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunction.java create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/LoadBalancerUriToolsTests.java create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/LoadBalancerTestUtils.java create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerClientAutoConfigurationTests.java create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunctionTests.java create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/TestReactiveLoadBalancer.java create mode 100644 spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClient.java create mode 100644 spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/BlockingLoadBalancerClientAutoConfiguration.java create mode 100644 spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/core/ReactorServiceInstanceLoadBalancer.java create mode 100644 spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClientTests.java diff --git a/docs/src/main/asciidoc/spring-cloud-commons.adoc b/docs/src/main/asciidoc/spring-cloud-commons.adoc index 11e37b6c..8403629f 100644 --- a/docs/src/main/asciidoc/spring-cloud-commons.adoc +++ b/docs/src/main/asciidoc/spring-cloud-commons.adoc @@ -336,7 +336,7 @@ For instance, Eureka's supported statuses are `UP`, `DOWN`, `OUT_OF_SERVICE`, an === Spring RestTemplate as a Load Balancer Client -`RestTemplate` can be automatically configured to use ribbon. +`RestTemplate` can be automatically configured to use a Load-balancer client under the hood. To create a load-balanced `RestTemplate`, create a `RestTemplate` `@Bean` and use the `@LoadBalanced` qualifier, as shown in the following example: [source,java,indent=0] @@ -369,9 +369,18 @@ The URI needs to use a virtual host name (that is, a service name, not a host na The Ribbon client is used to create a full physical address. See {githubroot}/spring-cloud-netflix/blob/master/spring-cloud-netflix-ribbon/src/main/java/org/springframework/cloud/netflix/ribbon/RibbonAutoConfiguration.java[RibbonAutoConfiguration] for details of how the `RestTemplate` is set up. +IMPORTANT: In order to use a load-balanced `RestTemplate`, you need to have a load-balancer implementation in your classpath. +The recommended implementation is `BlockingLoadBalancerClient` +- add `org.springframework.cloud:spring-cloud-loadbalancer` in order to use it. +The +`RibbonLoadBalancerClient` also can be used, but it's now under maintenance and we do not recommend adding it to new projects. + +WARNING: If you want to use `BlockingLoadBalancerClient`, make sure you do not have +`RibbonLoadBalancerClient` in the project classpath, as for backward compatibility reasons, it will be used by default. + === Spring WebClient as a Load Balancer Client -`WebClient` can be automatically configured to use the `LoadBalancerClient`. +`WebClient` can be automatically configured to use a load-balancer client. To create a load-balanced `WebClient`, create a `WebClient.Builder` `@Bean` and use the `@LoadBalanced` qualifier, as shown in the following example: [source,java,indent=0] @@ -400,6 +409,20 @@ public class MyClass { The URI needs to use a virtual host name (that is, a service name, not a host name). The Ribbon client is used to create a full physical address. +IMPORTANT: If you want to use a `@LoadBalanced WebClient.Builder`, you need to have a loadbalancer +implementation in the classpath. It is recommended that you add the +`org.springframework.cloud:spring-cloud-loadbalancer` dependency to your project. +Then, `ReactiveLoadBalancer` will be used underneath. +Alternatively, this functionality will also work with spring-cloud-starter-netflix-ribbon, but the request +will be handled by a non-reactive `LoadBalancerClient` under the hood. Additionally, +spring-cloud-starter-netflix-ribbon is already in maintenance mode, so we do not recommned +adding it to new projects. + +TIP: The `ReactorLoadBalancer` used underneath supports caching. If `cacheManager` is detected, +cached version of `ServiceInstanceSupplier` will be used. If not, we will retrieve instances +from discovery service without caching them. We recommend https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-caching.html[enabling caching] in your project +if you use `ReactiveLoadBalancer`. + ==== Retrying Failed Requests A load-balanced `RestTemplate` can be configured to retry failed requests. @@ -514,7 +537,42 @@ TIP: If you see errors such as `java.lang.IllegalArgumentException: Can not set [[loadbalanced-webclient]] === Spring WebFlux WebClient as a Load Balancer Client -`WebClient` can be configured to use the `LoadBalancerClient`. `LoadBalancerExchangeFilterFunction` is auto-configured if `spring-webflux` is on the classpath. The following example shows how to configure a `WebClient` to use load balancer: +[[webflux-with-reactive-loadbalancer]] +==== Spring WebFlux WebClient with Reactive Load Balancer + +`WebClient` can be configured to use the `ReactiveLoadBalancer`. +If you add `org.springframework.cloud:spring-cloud-loadbalancer` to your project, + `ReactorLoadBalancerExchangeFilterFunction` is auto-configured if `spring-webflux` is on the classpath. +The following example shows how to configure a `WebClient` to use reactive load balancer under the hood: + +[source,java,indent=0] +---- +public class MyClass { + @Autowired + private ReactorLoadBalancerExchangeFilterFunction lbFunction; + + public Mono doOtherStuff() { + return WebClient.builder().baseUrl("http://stores") + .filter(lbFunction) + .build() + .get() + .uri("/stores") + .retrieve() + .bodyToMono(String.class); + } +} +---- + +The URI needs to use a virtual host name (that is, a service name, not a host name). +The `ReactorLoadBalancerClient` is used to create a full physical address. + +==== Spring WebFlux WebClient with non-reactive Load Balancer Client + +If you you don't have `org.springframework.cloud:spring-cloud-loadbalancer` in your project, +but you do have spring-cloud-starter-netflix-ribbon, you can still use `WebClient` with `LoadBalancerClient`. `LoadBalancerExchangeFilterFunction` +will be auto-configured if `spring-webflux` is on the classpath. Please note, however, that this is +uses a non-reactive client under the hood. +The following example shows how to configure a `WebClient` to use load balancer: [source,java,indent=0] ---- @@ -537,6 +595,45 @@ public class MyClass { The URI needs to use a virtual host name (that is, a service name, not a host name). The `LoadBalancerClient` is used to create a full physical address. +WARN: +This approach is now deprecated. +We suggest you use <> +instead. + +==== Passing your own Load-Balancer Client configuration + +You can also use the `@LoadBalancerClient` annotation to pass your own load-balancer client configuration, passing the name of the load-balancer client and the configuration class, like so: + +[source,java,indent=0] +---- +@Configuration +@LoadBalancerClient(value = "stores", configuration = StoresLoadBalancerClientConfiguration.class) +public class MyConfiguration { + + @Bean + @LoadBalanced + public WebClient.Builder loadBalancedWebClientBuilder() { + return WebClient.builder(); + } +} +---- + +It is also possible to pass together multiple configurations (for more than one load-balancer client) via the `@LoadBalancerClients` annotation, as shown below: + +[source,java,indent=0] +---- +@Configuration +@LoadBalancerClients({@LoadBalancerClient(value = "stores", configuration = StoresLoadBalancerClientConfiguration.class), @LoadBalancerClient(value = "customers", configuration = CustomersLoadBalancerClientConfiguration.class)}) +public class MyConfiguration { + + @Bean + @LoadBalanced + public WebClient.Builder loadBalancedWebClientBuilder() { + return WebClient.builder(); + } +} +---- + [[ignore-network-interfaces]] === Ignore Network Interfaces diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerUriTools.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerUriTools.java new file mode 100644 index 00000000..45379735 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerUriTools.java @@ -0,0 +1,124 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.client.loadbalancer; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * @author Olga Maciaszek-Sharma + * @since 2.1.3 + */ +public final class LoadBalancerUriTools { + + private LoadBalancerUriTools() { + throw new IllegalStateException("Can't instantiate a utility class"); + } + + private static final String PERCENTAGE_SIGN = "%"; + + private static final String DEFAULT_SCHEME = "http"; + + private static final String DEFAULT_SECURE_SCHEME = "https"; + + private static final Map INSECURE_SCHEME_MAPPINGS; + + static { + INSECURE_SCHEME_MAPPINGS = new HashMap<>(); + INSECURE_SCHEME_MAPPINGS.put(DEFAULT_SCHEME, DEFAULT_SECURE_SCHEME); + INSECURE_SCHEME_MAPPINGS.put("ws", "wss"); + } + + // see original + // https://github.com/spring-cloud/spring-cloud-gateway/blob/master/spring-cloud-gateway-core/ + // src/main/java/org/springframework/cloud/gateway/support/ServerWebExchangeUtils.java + private static boolean containsEncodedParts(URI uri) { + boolean encoded = (uri.getRawQuery() != null + && uri.getRawQuery().contains(PERCENTAGE_SIGN)) + || (uri.getRawPath() != null + && uri.getRawPath().contains(PERCENTAGE_SIGN)) + || (uri.getRawFragment() != null + && uri.getRawFragment().contains(PERCENTAGE_SIGN)); + // Verify if it is really fully encoded. Treat partial encoded as unencoded. + if (encoded) { + try { + UriComponentsBuilder.fromUri(uri).build(true); + return true; + } + catch (IllegalArgumentException ignore) { + } + return false; + } + return false; + } + + private static int computePort(int port, String scheme) { + if (port >= 0) { + return port; + } + if (Objects.equals(scheme, DEFAULT_SECURE_SCHEME)) { + return 443; + } + return 80; + } + + /** + * Modifies the URI in order to redirect the request to a service instance of choice. + * @param serviceInstance the {@link ServiceInstance} to redirect the request to. + * @param original the {@link URI} from the original request + * @return the modified {@link URI} + */ + public static URI reconstructURI(ServiceInstance serviceInstance, URI original) { + if (serviceInstance == null) { + throw new IllegalArgumentException("Service Instance cannot be null."); + } + return doReconstructURI(serviceInstance, original); + } + + private static URI doReconstructURI(ServiceInstance serviceInstance, URI original) { + String host = serviceInstance.getHost(); + String scheme = Optional.ofNullable(serviceInstance.getScheme()) + .orElse(computeScheme(original, serviceInstance)); + int port = computePort(serviceInstance.getPort(), scheme); + + if (Objects.equals(host, original.getHost()) && port == original.getPort() + && Objects.equals(scheme, original.getScheme())) { + return original; + } + + boolean encoded = containsEncodedParts(original); + return UriComponentsBuilder.fromUri(original).scheme(scheme).host(host).port(port) + .build(encoded).toUri(); + } + + private static String computeScheme(URI original, ServiceInstance serviceInstance) { + String originalOrDefault = Optional.ofNullable(original.getScheme()) + .orElse(DEFAULT_SCHEME); + if (serviceInstance.isSecure() + && INSECURE_SCHEME_MAPPINGS.containsKey(originalOrDefault)) { + return INSECURE_SCHEME_MAPPINGS.get(originalOrDefault); + } + return originalOrDefault; + } + +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancer.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancer.java index 8d29c102..37eb3561 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancer.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancer.java @@ -23,6 +23,7 @@ import org.reactivestreams.Publisher; * * @param type of the response * @author Spencer Gibb + * @author Olga Maciaszek-Sharma */ public interface ReactiveLoadBalancer { @@ -42,4 +43,11 @@ public interface ReactiveLoadBalancer { return choose(REQUEST); } + @FunctionalInterface + interface Factory { + + ReactiveLoadBalancer getInstance(String serviceId); + + } + } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancerAutoConfiguration.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancerAutoConfiguration.java index 47b3209c..5984e3ab 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancerAutoConfiguration.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancerAutoConfiguration.java @@ -23,6 +23,7 @@ import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.cloud.client.loadbalancer.LoadBalanced; import org.springframework.cloud.client.loadbalancer.LoadBalancerClient; import org.springframework.context.annotation.Bean; @@ -30,11 +31,14 @@ import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.client.WebClient; /** + * @deprecated in favour of {@link ReactorLoadBalancerClientAutoConfiguration} * @author Spencer Gibb + * @author Olga Maciaszek-Sharma */ @Configuration @ConditionalOnClass(WebClient.class) @ConditionalOnBean(LoadBalancerClient.class) +@ConditionalOnMissingBean(ReactiveLoadBalancer.Factory.class) public class ReactiveLoadBalancerAutoConfiguration { @LoadBalanced @@ -58,7 +62,7 @@ public class ReactiveLoadBalancerAutoConfiguration { } @Bean - public WebClientCustomizer loadbalanceClientWebClientCustomizer( + public WebClientCustomizer loadBalancerClientWebClientCustomizer( LoadBalancerExchangeFilterFunction filterFunction) { return builder -> builder.filter(filterFunction); } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerClientAutoConfiguration.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerClientAutoConfiguration.java new file mode 100644 index 00000000..092a215d --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerClientAutoConfiguration.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.client.loadbalancer.reactive; + +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.cloud.client.loadbalancer.LoadBalanced; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * An auto-configuration that allows the use of a {@link LoadBalanced} + * {@link WebClient.Builder} with {@link ReactorLoadBalancerExchangeFilterFunction} and + * {@link ReactiveLoadBalancer} used under the hood. + * + * @author Olga Maciaszek-Sharma + * @since 2.1.3 + */ +@Configuration +@ConditionalOnClass(WebClient.class) +@ConditionalOnBean(ReactiveLoadBalancer.Factory.class) +public class ReactorLoadBalancerClientAutoConfiguration { + + private List webClientBuilders = Collections.emptyList(); + + List getBuilders() { + return this.webClientBuilders; + } + + @Bean + public SmartInitializingSingleton loadBalancedWebClientInitializer( + final List customizers) { + return () -> { + for (WebClient.Builder webClientBuilder : getBuilders()) { + for (WebClientCustomizer customizer : customizers) { + customizer.customize(webClientBuilder); + } + } + }; + } + + @Bean + public WebClientCustomizer loadBalancerClientWebClientCustomizer( + ReactorLoadBalancerExchangeFilterFunction filterFunction) { + return builder -> builder.filter(filterFunction); + } + + @Bean + public ReactorLoadBalancerExchangeFilterFunction loadBalancerExchangeFilterFunction( + ReactiveLoadBalancer.Factory loadBalancerFactory) { + return new ReactorLoadBalancerExchangeFilterFunction(loadBalancerFactory); + } + + @LoadBalanced + @Autowired(required = false) + void setWebClientBuilders(List webClientBuilders) { + this.webClientBuilders = webClientBuilders; + } + +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunction.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunction.java new file mode 100644 index 00000000..fd419565 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunction.java @@ -0,0 +1,110 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.client.loadbalancer.reactive; + +import java.net.URI; +import java.util.Objects; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; + +/** + * An {@link ExchangeFilterFunction} that uses {@link ReactiveLoadBalancer} to execute + * requests against a correct {@link ServiceInstance}. + * + * @author Olga Maciaszek-Sharma + * @since 2.1.3 + */ +public class ReactorLoadBalancerExchangeFilterFunction implements ExchangeFilterFunction { + + private static final Log LOG = LogFactory + .getLog(LoadBalancerExchangeFilterFunction.class); + + private final ReactiveLoadBalancer.Factory loadBalancerFactory; + + public ReactorLoadBalancerExchangeFilterFunction( + ReactiveLoadBalancer.Factory loadBalancerFactory) { + this.loadBalancerFactory = loadBalancerFactory; + } + + @Override + public Mono filter(ClientRequest request, ExchangeFunction next) { + URI originalUrl = request.url(); + String serviceId = originalUrl.getHost(); + if (serviceId == null) { + String message = String.format( + "Request URI does not contain a valid hostname: %s", + originalUrl.toString()); + if (LOG.isWarnEnabled()) { + LOG.warn(message); + } + return Mono.just( + ClientResponse.create(HttpStatus.BAD_REQUEST).body(message).build()); + } + return choose(serviceId).flatMap(response -> { + ServiceInstance instance = response.getServer(); + if (instance == null) { + String message = serviceInstanceUnavailableMessage(serviceId); + if (LOG.isWarnEnabled()) { + LOG.warn(message); + } + return Mono.just(ClientResponse.create(HttpStatus.SERVICE_UNAVAILABLE) + .body(serviceInstanceUnavailableMessage(serviceId)).build()); + } + + if (LOG.isDebugEnabled()) { + LOG.debug(String.format( + "Load balancer has retrieved the instance for service %s: %s", + serviceId, Objects.requireNonNull(instance).getUri())); + } + ClientRequest newRequest = buildClientRequest(request, + LoadBalancerUriTools.reconstructURI(instance, originalUrl)); + return next.exchange(newRequest); + }); + } + + private Mono> choose(String serviceId) { + ReactiveLoadBalancer loadBalancer = loadBalancerFactory + .getInstance(serviceId); + if (loadBalancer == null) { + return Mono.just(new EmptyResponse()); + } + return Mono.from(loadBalancer.choose()); + } + + private String serviceInstanceUnavailableMessage(String serviceId) { + return "Load balancer does not contain an instance for the service " + serviceId; + } + + private ClientRequest buildClientRequest(ClientRequest request, URI uri) { + return ClientRequest.create(request.method(), uri) + .headers(headers -> headers.addAll(request.headers())) + .cookies(cookies -> cookies.addAll(request.cookies())) + .attributes(attributes -> attributes.putAll(request.attributes())) + .body(request.body()).build(); + } + +} diff --git a/spring-cloud-commons/src/main/resources/META-INF/spring.factories b/spring-cloud-commons/src/main/resources/META-INF/spring.factories index 773c967a..16d4bb38 100644 --- a/spring-cloud-commons/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-commons/src/main/resources/META-INF/spring.factories @@ -7,6 +7,7 @@ org.springframework.cloud.client.discovery.simple.SimpleDiscoveryClientAutoConfi org.springframework.cloud.client.hypermedia.CloudHypermediaAutoConfiguration,\ org.springframework.cloud.client.loadbalancer.AsyncLoadBalancerAutoConfiguration,\ org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration,\ +org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerClientAutoConfiguration,\ org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancerAutoConfiguration,\ org.springframework.cloud.client.serviceregistry.ServiceRegistryAutoConfiguration,\ org.springframework.cloud.commons.httpclient.HttpClientConfiguration,\ diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/LoadBalancerUriToolsTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/LoadBalancerUriToolsTests.java new file mode 100644 index 00000000..b44445eb --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/LoadBalancerUriToolsTests.java @@ -0,0 +1,276 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.client.loadbalancer; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LoadBalancerUriTools}. + * + * @author Olga Maciaszek-Sharma + */ +public class LoadBalancerUriToolsTests { + + @Test + public void originalURIReturnedIfDataMatches() { + TestServiceInstance serviceInstance = new TestServiceInstance(); + URI original = UriComponentsBuilder.fromUriString("http://test.example:8080/xxx") + .build().toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void serviceInstanceHostSet() { + TestServiceInstance serviceInstance = new TestServiceInstance(); + URI original = UriComponentsBuilder + .fromUriString("http://testHost.example:8080/xxx").build().toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getHost()).isEqualTo(serviceInstance.getHost()); + } + + @Test + public void serviceInstanceSchemeSet() { + TestServiceInstance serviceInstance = new TestServiceInstance() + .withScheme("https"); + URI original = UriComponentsBuilder.fromUriString("http://test.example/xxx") + .build().toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getScheme()).isEqualTo(serviceInstance.getScheme()); + } + + @Test + public void originalSchemeSetIfServiceInstanceSchemeMissing() { + TestServiceInstance serviceInstance = new TestServiceInstance().withScheme(null); + URI original = UriComponentsBuilder.fromUriString("https://test.example/xxx") + .build().toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getScheme()).isEqualTo(original.getScheme()); + } + + @Test + public void secureSchemeSetIfServiceInstanceSchemeMissingAndServiceInstanceSecure() { + TestServiceInstance serviceInstance = new TestServiceInstance().withScheme(null) + .withSecure(true); + URI original = UriComponentsBuilder.fromUriString("http://test.example/xxx") + .build().toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getScheme()).isEqualTo("https"); + } + + @Test + public void secureWsSchemeSetIfServiceInstanceSchemeMissingAndServiceInstanceSecure() { + TestServiceInstance serviceInstance = new TestServiceInstance().withScheme(null) + .withSecure(true); + URI original = UriComponentsBuilder.fromUriString("ws://test.example/xxx").build() + .toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getScheme()).isEqualTo("wss"); + } + + @Test + public void defaultSchemeSetIfMissing() { + TestServiceInstance serviceInstance = new TestServiceInstance().withScheme(null); + URI original = UriComponentsBuilder.fromUriString("//test.example/xxx").build() + .toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getScheme()).isEqualTo("http"); + } + + @Test + public void serviceInstancePortSet() { + TestServiceInstance serviceInstance = new TestServiceInstance().withPort(0); + URI original = UriComponentsBuilder.fromUriString("http://test.example:8080/xxx") + .build().toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getPort()).isEqualTo(serviceInstance.getPort()); + } + + @Test + public void defaultHttpPortSetIfServiceInstancePortIncorrect() { + TestServiceInstance serviceInstance = new TestServiceInstance().withPort(-1); + URI original = UriComponentsBuilder.fromUriString("http://test.example:8888/xxx") + .build().toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getPort()).isEqualTo(80); + } + + @Test + public void defaultHttpsPortSetIfServiceInstancePortIncorrect() { + TestServiceInstance serviceInstance = new TestServiceInstance() + .withScheme("https").withPort(-1); + URI original = UriComponentsBuilder.fromUriString("http://test.example:8888/xxx") + .build().toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getPort()).isEqualTo(443); + } + + @Test + public void originalUserInfoSet() { + TestServiceInstance serviceInstance = new TestServiceInstance(); + URI original = UriComponentsBuilder.fromUriString( + "http://testUser@testHost.example/path?query1=test1&query2=test2#fragment") + .build().toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getRawUserInfo()).isEqualTo(original.getRawUserInfo()); + assertThat(reconstructed.getRawQuery()).isEqualTo(original.getRawQuery()); + assertThat(reconstructed.getRawPath()).isEqualTo(original.getRawPath()); + assertThat(reconstructed.getRawQuery()).isEqualTo(original.getRawQuery()); + assertThat(reconstructed.getRawFragment()).isEqualTo(original.getRawFragment()); + assertThat(reconstructed.getHost()).isEqualTo(serviceInstance.getHost()); + assertThat(reconstructed.getPort()).isEqualTo(serviceInstance.getPort()); + } + + @Test + public void reconstructedURIEncodedCorrectly() { + TestServiceInstance serviceInstance = new TestServiceInstance(); + URI original = UriComponentsBuilder.fromUriString( + "http://test.example/path%40%21%242?query=val%40%21%242#frag%40%21%242") + .build().toUri(); + + URI reconstructed = LoadBalancerUriTools.reconstructURI(serviceInstance, + original); + + assertThat(reconstructed).isNotNull(); + assertThat(reconstructed.getRawUserInfo()).isEqualTo(original.getRawUserInfo()); + assertThat(reconstructed.getRawQuery()).isEqualTo(original.getRawQuery()); + assertThat(reconstructed.getRawPath()).isEqualTo(original.getRawPath()); + assertThat(reconstructed.getRawQuery()).isEqualTo(original.getRawQuery()); + assertThat(reconstructed.getRawFragment()).isEqualTo(original.getRawFragment()); + assertThat(reconstructed.getHost()).isEqualTo(serviceInstance.getHost()); + assertThat(reconstructed.getPort()).isEqualTo(serviceInstance.getPort()); + } + +} + +class TestServiceInstance implements ServiceInstance { + + private URI uri; + + private String scheme = "http"; + + private String host = "test.example"; + + private int port = 8080; + + private boolean secure; + + private Map metadata = new LinkedHashMap<>(); + + TestServiceInstance withScheme(String scheme) { + this.scheme = scheme; + return this; + } + + TestServiceInstance withPort(int port) { + this.port = port; + return this; + } + + TestServiceInstance withSecure(boolean secure) { + this.secure = secure; + return this; + } + + @Override + public String getServiceId() { + return "test-service"; + } + + @Override + public String getHost() { + return host; + } + + @Override + public int getPort() { + return port; + } + + @Override + public boolean isSecure() { + return secure; + } + + @Override + public URI getUri() { + return uri; + } + + @Override + public Map getMetadata() { + return metadata; + } + + @Override + public String getScheme() { + return scheme; + } + +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/LoadBalancerTestUtils.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/LoadBalancerTestUtils.java new file mode 100644 index 00000000..c1ab9d8e --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/LoadBalancerTestUtils.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.client.loadbalancer.reactive; + +import java.util.List; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.assertj.core.api.BDDAssertions.then; + +/** + * Utility class for testing reactive load-balancer clients. + * + * @author Olga Maciaszek-Sharma + */ +final class LoadBalancerTestUtils { + + private LoadBalancerTestUtils() { + throw new IllegalStateException("Can't instantiate a utility class"); + } + + static ConfigurableApplicationContext init(Class config, Class clientClass) { + return new SpringApplicationBuilder().web(WebApplicationType.NONE) + .sources(config, WebClientAutoConfiguration.class, clientClass).run(); + } + + @SuppressWarnings("unchecked") + static List getFilters(WebClient.Builder builder) { + return (List) ReflectionTestUtils.getField(builder, + "filters"); + } + + static void assertLoadBalanced(WebClient.Builder webClientBuilder, + Class exchangeFilterFunctionClass) { + List filters = getFilters(webClientBuilder); + then(filters).hasSize(1); + ExchangeFilterFunction interceptor = filters.get(0); + then(interceptor).isInstanceOf(exchangeFilterFunctionClass); + } + +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancerAutoConfigurationTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancerAutoConfigurationTests.java index 11009a2c..44e51432 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancerAutoConfigurationTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactiveLoadBalancerAutoConfigurationTests.java @@ -18,16 +18,12 @@ package org.springframework.cloud.client.loadbalancer.reactive; import java.io.IOException; import java.net.URI; -import java.util.List; import java.util.Map; import java.util.Random; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.WebApplicationType; -import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; -import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.cloud.client.DefaultServiceInstance; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.loadbalancer.LoadBalanced; @@ -38,15 +34,15 @@ import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.reactive.function.client.ExchangeFilterFunction; import org.springframework.web.reactive.function.client.WebClient; import static org.assertj.core.api.BDDAssertions.then; +import static org.springframework.cloud.client.loadbalancer.reactive.LoadBalancerTestUtils.getFilters; /** * @author Spencer Gibb * @author Tim Ysewyn + * @author Olga Maciaszek-Sharma */ public class ReactiveLoadBalancerAutoConfigurationTests { @@ -63,19 +59,6 @@ public class ReactiveLoadBalancerAutoConfigurationTests { assertLoadBalanced(webClientBuilder); } - private void assertLoadBalanced(WebClient.Builder webClientBuilder) { - List filters = getFilters(webClientBuilder); - then(filters).hasSize(1); - ExchangeFilterFunction interceptor = filters.get(0); - then(interceptor).isInstanceOf(LoadBalancerExchangeFilterFunction.class); - } - - @SuppressWarnings("unchecked") - private List getFilters(WebClient.Builder builder) { - return (List) ReflectionTestUtils.getField(builder, - "filters"); - } - @Test public void multipleWebClientBuilders() { ConfigurableApplicationContext context = init(TwoWebClientBuilders.class); @@ -107,12 +90,29 @@ public class ReactiveLoadBalancerAutoConfigurationTests { then(getFilters(builder)).isNullOrEmpty(); } - protected ConfigurableApplicationContext init(Class config) { - return new SpringApplicationBuilder().web(WebApplicationType.NONE) - // .properties("spring.aop.proxyTargetClass=true") - .sources(config, WebClientAutoConfiguration.class, - ReactiveLoadBalancerAutoConfiguration.class) - .run(); + @Test + public void autoConfigurationNotLoadedWhenReactorLoadBalancerClientPresent() { + ConfigurableApplicationContext context = init( + ReactorLoadBalancerClientPresent.class); + final Map webClientBuilders = context + .getBeansOfType(WebClient.Builder.class); + + then(webClientBuilders).hasSize(1); + + WebClient.Builder builder = context.getBean(WebClient.Builder.class); + + then(builder).isNotNull(); + then(getFilters(builder)).isNullOrEmpty(); + } + + private ConfigurableApplicationContext init(Class config) { + return LoadBalancerTestUtils.init(config, + ReactiveLoadBalancerAutoConfiguration.class); + } + + private void assertLoadBalanced(WebClient.Builder builder) { + LoadBalancerTestUtils.assertLoadBalanced(builder, + LoadBalancerExchangeFilterFunction.class); } @Configuration @@ -131,6 +131,16 @@ public class ReactiveLoadBalancerAutoConfigurationTests { } + @Configuration + protected static class ReactorLoadBalancerClientPresent extends OneWebClientBuilder { + + @Bean + ReactiveLoadBalancer.Factory reactiveLoadBalancerFactory() { + return serviceId -> new TestReactiveLoadBalancer(); + } + + } + @Configuration protected static class OneWebClientBuilder extends NoWebClientBuilder { diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerClientAutoConfigurationTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerClientAutoConfigurationTests.java new file mode 100644 index 00000000..00028cc2 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerClientAutoConfigurationTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.client.loadbalancer.reactive; + +import java.util.Map; + +import org.junit.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.LoadBalanced; +import org.springframework.cloud.client.loadbalancer.LoadBalancedRetryFactory; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.assertj.core.api.BDDAssertions.then; +import static org.springframework.cloud.client.loadbalancer.reactive.LoadBalancerTestUtils.getFilters; + +/** + * Tests for {@link ReactorLoadBalancerClientAutoConfiguration}. + * + * @author Olga Maciaszek-Sharma + */ +public class ReactorLoadBalancerClientAutoConfigurationTests { + + @Test + public void loadBalancerFilterAddedToWebClientBuilder() { + ConfigurableApplicationContext context = init(OneWebClientBuilder.class); + final Map webClientBuilders = context + .getBeansOfType(WebClient.Builder.class); + + then(webClientBuilders).isNotNull().hasSize(1); + WebClient.Builder webClientBuilder = webClientBuilders.values().iterator().next(); + then(webClientBuilder).isNotNull(); + + assertLoadBalanced(webClientBuilder); + } + + @Test + public void loadBalancerFilterAddedOnlyToLoadBalancedWebClientBuilder() { + ConfigurableApplicationContext context = init(TwoWebClientBuilders.class); + final Map webClientBuilders = context + .getBeansOfType(WebClient.Builder.class); + + then(webClientBuilders).hasSize(2); + + TwoWebClientBuilders.Two two = context.getBean(TwoWebClientBuilders.Two.class); + + then(two.loadBalanced).isNotNull(); + assertLoadBalanced(two.loadBalanced); + + then(two.nonLoadBalanced).isNotNull(); + then(getFilters(two.nonLoadBalanced)).isNullOrEmpty(); + } + + @Test + public void noCustomWebClientBuilders() { + ConfigurableApplicationContext context = init(NoWebClientBuilder.class); + final Map webClientBuilders = context + .getBeansOfType(WebClient.Builder.class); + + then(webClientBuilders).hasSize(1); + + WebClient.Builder builder = context.getBean(WebClient.Builder.class); + + then(builder).isNotNull(); + then(getFilters(builder)).isNullOrEmpty(); + } + + private ConfigurableApplicationContext init(Class config) { + return LoadBalancerTestUtils.init(config, + ReactorLoadBalancerClientAutoConfiguration.class); + } + + private void assertLoadBalanced(WebClient.Builder webClientBuilder) { + LoadBalancerTestUtils.assertLoadBalanced(webClientBuilder, + ReactorLoadBalancerExchangeFilterFunction.class); + } + + @Configuration + protected static class NoWebClientBuilder { + + @Bean + ReactiveLoadBalancer.Factory reactiveLoadBalancerFactory() { + return serviceId -> new TestReactiveLoadBalancer(); + } + + @Bean + LoadBalancedRetryFactory loadBalancedRetryFactory() { + return new LoadBalancedRetryFactory() { + }; + } + + } + + @Configuration + protected static class OneWebClientBuilder extends NoWebClientBuilder { + + @Bean + @LoadBalanced + WebClient.Builder loadBalancedWebClientBuilder() { + return WebClient.builder(); + } + + } + + @Configuration + protected static class TwoWebClientBuilders extends OneWebClientBuilder { + + @Primary + @Bean + WebClient.Builder webClientBuilder() { + return WebClient.builder(); + } + + @Configuration + protected static class Two { + + @Autowired + WebClient.Builder nonLoadBalanced; + + @Autowired + @LoadBalanced + WebClient.Builder loadBalanced; + + } + + } + +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunctionTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunctionTests.java new file mode 100644 index 00000000..a47db196 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunctionTests.java @@ -0,0 +1,152 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.client.loadbalancer.reactive; + +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.DiscoveryClient; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.client.discovery.simple.SimpleDiscoveryProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.assertj.core.api.BDDAssertions.then; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +/** + * Tests for {@link ReactorLoadBalancerExchangeFilterFunction}. + * + * @author Olga Maciaszek-Sharma + */ +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = RANDOM_PORT) +public class ReactorLoadBalancerExchangeFilterFunctionTests { + + @Autowired + private ReactorLoadBalancerExchangeFilterFunction loadBalancerFunction; + + @Autowired + private SimpleDiscoveryProperties properties; + + @LocalServerPort + private int port; + + @Before + public void setUp() { + SimpleDiscoveryProperties.SimpleServiceInstance instance = new SimpleDiscoveryProperties.SimpleServiceInstance(); + instance.setServiceId("testservice"); + instance.setUri(URI.create("http://localhost:" + this.port)); + this.properties.getInstances().put("testservice", + Collections.singletonList(instance)); + } + + @Test + public void correctResponseReturnedForExistingHostAndInstancePresent() { + ClientResponse clientResponse = WebClient.builder().baseUrl("http://testservice") + .filter(this.loadBalancerFunction).build().get().uri("/hello").exchange() + .block(); + then(clientResponse.statusCode()).isEqualTo(HttpStatus.OK); + then(clientResponse.bodyToMono(String.class).block()).isEqualTo("Hello World"); + } + + @Test + public void serviceUnavailableReturnedWhenNoInstancePresent() { + ClientResponse clientResponse = WebClient.builder().baseUrl("http://xxx") + .filter(this.loadBalancerFunction).build().get().exchange().block(); + then(clientResponse.statusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + } + + @Test + public void badRequestReturnedForIncorrectHost() { + ClientResponse clientResponse = WebClient.builder().baseUrl("http:///xxx") + .filter(this.loadBalancerFunction).build().get().exchange().block(); + then(clientResponse.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @EnableDiscoveryClient + @EnableAutoConfiguration + @SpringBootConfiguration + @RestController + static class Config { + + @RequestMapping("/hello") + public String hello() { + return "Hello World"; + } + + @Bean + ReactiveLoadBalancer.Factory reactiveLoadBalancerFactory( + DiscoveryClient discoveryClient) { + return serviceId -> new DiscoveryClientBasedReactiveLoadBalancer(serviceId, + discoveryClient); + } + + } + +} + +class DiscoveryClientBasedReactiveLoadBalancer + implements ReactiveLoadBalancer { + + private final Random random = new Random(); + + private final String serviceId; + + private final DiscoveryClient discoveryClient; + + DiscoveryClientBasedReactiveLoadBalancer(String serviceId, + DiscoveryClient discoveryClient) { + this.serviceId = serviceId; + this.discoveryClient = discoveryClient; + } + + @Override + public Publisher> choose() { + List instances = discoveryClient.getInstances(serviceId); + if (instances.size() == 0) { + return Mono.just(new EmptyResponse()); + } + int instanceIdx = this.random.nextInt(instances.size()); + return Mono.just(new DefaultResponse(instances.get(instanceIdx))); + } + + @Override + public Publisher> choose(Request request) { + return choose(); + } + +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/TestReactiveLoadBalancer.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/TestReactiveLoadBalancer.java new file mode 100644 index 00000000..89fc15e5 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/TestReactiveLoadBalancer.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.client.loadbalancer.reactive; + +import java.util.Random; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.client.DefaultServiceInstance; +import org.springframework.cloud.client.ServiceInstance; + +/** + * A sample implementation of {@link ReactiveLoadBalancer} used for tests. + * + * @author Olga Maciaszek-Sharma + */ +class TestReactiveLoadBalancer implements ReactiveLoadBalancer { + + private static final String TEST_SERVICE_ID = "testServiceId"; + + private final Random random = new Random(); + + @Override + public Publisher> choose() { + return Mono.just(new DefaultResponse(new DefaultServiceInstance(TEST_SERVICE_ID, + TEST_SERVICE_ID, TEST_SERVICE_ID, random.nextInt(40000), false))); + } + + @Override + public Publisher> choose(Request request) { + return choose(); + } + +} diff --git a/spring-cloud-loadbalancer/pom.xml b/spring-cloud-loadbalancer/pom.xml index cebcfe7a..c68965a8 100644 --- a/spring-cloud-loadbalancer/pom.xml +++ b/spring-cloud-loadbalancer/pom.xml @@ -56,7 +56,7 @@ org.springframework.boot spring-boot-starter-webflux - test + true org.springframework.boot diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/annotation/LoadBalancerClientConfiguration.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/annotation/LoadBalancerClientConfiguration.java index b038b9c8..fc7b38e2 100644 --- a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/annotation/LoadBalancerClientConfiguration.java +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/annotation/LoadBalancerClientConfiguration.java @@ -17,25 +17,34 @@ package org.springframework.cloud.loadbalancer.annotation; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cache.CacheManager; +import org.springframework.cloud.client.ConditionalOnDiscoveryEnabled; +import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.cloud.loadbalancer.core.CachingServiceInstanceSupplier; import org.springframework.cloud.loadbalancer.core.DiscoveryClientServiceInstanceSupplier; +import org.springframework.cloud.loadbalancer.core.ReactorLoadBalancer; +import org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer; import org.springframework.cloud.loadbalancer.core.ServiceInstanceSupplier; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; /** * @author Spencer Gibb + * @author Olga Maciaszek-Sharma */ @Configuration @EnableConfigurationProperties +@ConditionalOnDiscoveryEnabled public class LoadBalancerClientConfiguration { @Bean + @ConditionalOnBean(DiscoveryClient.class) @ConditionalOnMissingBean public ServiceInstanceSupplier discoveryClientServiceInstanceSupplier( DiscoveryClient discoveryClient, Environment env, @@ -50,4 +59,14 @@ public class LoadBalancerClientConfiguration { return delegate; } + @Bean + @ConditionalOnMissingBean + public ReactorLoadBalancer reactorServiceInstanceLoadBalancer( + Environment environment, + LoadBalancerClientFactory loadBalancerClientFactory) { + String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME); + return new RoundRobinLoadBalancer(name, loadBalancerClientFactory + .getLazyProvider(name, ServiceInstanceSupplier.class)); + } + } diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClient.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClient.java new file mode 100644 index 00000000..a39e4314 --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClient.java @@ -0,0 +1,93 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.loadbalancer.blocking.client; + +import java.io.IOException; +import java.net.URI; + +import reactor.core.publisher.Mono; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.LoadBalancerClient; +import org.springframework.cloud.client.loadbalancer.LoadBalancerRequest; +import org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.client.loadbalancer.reactive.Response; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.util.ReflectionUtils; + +/** + * The default {@link LoadBalancerClient} implementation. + * + * @author Olga Maciaszek-Sharma + * @since 2.1.3 + */ +public class BlockingLoadBalancerClient implements LoadBalancerClient { + + private final LoadBalancerClientFactory loadBalancerClientFactory; + + public BlockingLoadBalancerClient( + LoadBalancerClientFactory loadBalancerClientFactory) { + this.loadBalancerClientFactory = loadBalancerClientFactory; + } + + @Override + public T execute(String serviceId, LoadBalancerRequest request) + throws IOException { + ServiceInstance serviceInstance = choose(serviceId); + if (serviceInstance == null) { + throw new IllegalStateException("No instances available for " + serviceId); + } + return execute(serviceId, serviceInstance, request); + } + + @Override + public T execute(String serviceId, ServiceInstance serviceInstance, + LoadBalancerRequest request) throws IOException { + try { + return request.apply(serviceInstance); + } + catch (IOException iOException) { + throw iOException; + } + catch (Exception exception) { + ReflectionUtils.rethrowRuntimeException(exception); + } + return null; + } + + @Override + public URI reconstructURI(ServiceInstance serviceInstance, URI original) { + return LoadBalancerUriTools.reconstructURI(serviceInstance, original); + } + + @Override + public ServiceInstance choose(String serviceId) { + ReactiveLoadBalancer loadBalancer = loadBalancerClientFactory + .getInstance(serviceId); + if (loadBalancer == null) { + return null; + } + Response loadBalancerResponse = Mono.from(loadBalancer.choose()) + .block(); + if (loadBalancerResponse == null) { + return null; + } + return loadBalancerResponse.getServer(); + } + +} diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/BlockingLoadBalancerClientAutoConfiguration.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/BlockingLoadBalancerClientAutoConfiguration.java new file mode 100644 index 00000000..42cf13c4 --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/BlockingLoadBalancerClientAutoConfiguration.java @@ -0,0 +1,85 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.loadbalancer.config; + +import javax.annotation.PostConstruct; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.cloud.client.loadbalancer.AsyncLoadBalancerAutoConfiguration; +import org.springframework.cloud.client.loadbalancer.LoadBalancerClient; +import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients; +import org.springframework.cloud.loadbalancer.blocking.client.BlockingLoadBalancerClient; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +/** + * An autoconfiguration for {@link BlockingLoadBalancerClient}. + * + * @author Olga Maciaszek-Sharma + * @since 2.1.3 + */ +@Configuration +@LoadBalancerClients +@AutoConfigureAfter(LoadBalancerAutoConfiguration.class) +@AutoConfigureBefore({ + org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration.class, + AsyncLoadBalancerAutoConfiguration.class }) +public class BlockingLoadBalancerClientAutoConfiguration { + + @Bean + @ConditionalOnClass(name = "org.springframework.cloud.netflix.ribbon.RibbonLoadBalancerClient") + public RibbonWarnLogger ribbonWarnLogger() { + return new RibbonWarnLogger(); + } + + @Bean + @ConditionalOnBean(LoadBalancerClientFactory.class) + @ConditionalOnClass(RestTemplate.class) + @ConditionalOnMissingBean + @ConditionalOnMissingClass("org.springframework.cloud.netflix.ribbon.RibbonLoadBalancerClient") + public LoadBalancerClient loadBalancerClient( + LoadBalancerClientFactory loadBalancerClientFactory) { + return new BlockingLoadBalancerClient(loadBalancerClientFactory); + } + +} + +class RibbonWarnLogger { + + private static final Log LOG = LogFactory.getLog(RibbonWarnLogger.class); + + @PostConstruct + void logWarning() { + if (LOG.isWarnEnabled()) { + LOG.warn( + "You already have RibbonLoadBalancerClient on your classpath. It will be used by default. To use " + + BlockingLoadBalancerClient.class.getSimpleName() + + " remove spring-cloud-starter-netflix-ribbon from your project."); + } + } + +} diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/LoadBalancerAutoConfiguration.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/LoadBalancerAutoConfiguration.java index 219c8d1a..96f62a96 100644 --- a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/LoadBalancerAutoConfiguration.java +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/LoadBalancerAutoConfiguration.java @@ -20,6 +20,9 @@ import java.util.Collections; import java.util.List; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancerAutoConfiguration; +import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerClientAutoConfiguration; import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClientSpecification; import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients; import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; @@ -28,9 +31,12 @@ import org.springframework.context.annotation.Configuration; /** * @author Spencer Gibb + * @author Olga Maciaszek-Sharma */ @Configuration @LoadBalancerClients +@AutoConfigureBefore({ ReactorLoadBalancerClientAutoConfiguration.class, + ReactiveLoadBalancerAutoConfiguration.class }) // @EnableCaching //TODO: how to enforce, or check conditions? // @AutoConfigureBefore(CacheAutoConfiguration.class) public class LoadBalancerAutoConfiguration { diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/core/ReactorServiceInstanceLoadBalancer.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/core/ReactorServiceInstanceLoadBalancer.java new file mode 100644 index 00000000..54ea4ab0 --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/core/ReactorServiceInstanceLoadBalancer.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.loadbalancer.core; + +import org.springframework.cloud.client.ServiceInstance; + +/** + * A marker interface for {@link ReactorLoadBalancer} that allows selecting + * {@link ServiceInstance} objects. + * + * @author Olga Maciaszek-Sharma + * @since 2.1.3 + */ +public interface ReactorServiceInstanceLoadBalancer + extends ReactorLoadBalancer { + +} diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/core/RoundRobinLoadBalancer.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/core/RoundRobinLoadBalancer.java index d5e66382..aebfef8b 100644 --- a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/core/RoundRobinLoadBalancer.java +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/core/RoundRobinLoadBalancer.java @@ -33,7 +33,7 @@ import org.springframework.cloud.client.loadbalancer.reactive.Response; /** * @author Spencer Gibb */ -public class RoundRobinLoadBalancer implements ReactorLoadBalancer { +public class RoundRobinLoadBalancer implements ReactorServiceInstanceLoadBalancer { private static final Log log = LogFactory.getLog(RoundRobinLoadBalancer.class); diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/LoadBalancerClientFactory.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/LoadBalancerClientFactory.java index 4bd7d1d2..de2e0087 100644 --- a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/LoadBalancerClientFactory.java +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/LoadBalancerClientFactory.java @@ -16,9 +16,12 @@ package org.springframework.cloud.loadbalancer.support; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; import org.springframework.cloud.context.named.NamedContextFactory; import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClientConfiguration; import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClientSpecification; +import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; import org.springframework.core.env.Environment; /** @@ -28,9 +31,11 @@ import org.springframework.core.env.Environment; * * @author Spencer Gibb * @author Dave Syer + * @author Olga Maciaszek-Sharma */ public class LoadBalancerClientFactory - extends NamedContextFactory { + extends NamedContextFactory + implements ReactiveLoadBalancer.Factory { /** * Property source name for load balancer. @@ -50,4 +55,9 @@ public class LoadBalancerClientFactory return environment.getProperty(PROPERTY_NAME); } + @Override + public ReactiveLoadBalancer getInstance(String serviceId) { + return getInstance(serviceId, ReactorServiceInstanceLoadBalancer.class); + } + } diff --git a/spring-cloud-loadbalancer/src/main/resources/META-INF/spring.factories b/spring-cloud-loadbalancer/src/main/resources/META-INF/spring.factories index b12b173d..9fa6cb80 100644 --- a/spring-cloud-loadbalancer/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-loadbalancer/src/main/resources/META-INF/spring.factories @@ -1,4 +1,4 @@ # AutoConfiguration org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ -org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration - +org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration,\ +org.springframework.cloud.loadbalancer.config.BlockingLoadBalancerClientAutoConfiguration diff --git a/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClientTests.java b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClientTests.java new file mode 100644 index 00000000..7a00f51d --- /dev/null +++ b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClientTests.java @@ -0,0 +1,209 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.loadbalancer.blocking.client; + +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.DiscoveryClient; +import org.springframework.cloud.client.discovery.simple.SimpleDiscoveryProperties; +import org.springframework.cloud.client.loadbalancer.LoadBalancerRequest; +import org.springframework.cloud.client.loadbalancer.reactive.DefaultResponse; +import org.springframework.cloud.client.loadbalancer.reactive.EmptyResponse; +import org.springframework.cloud.client.loadbalancer.reactive.Request; +import org.springframework.cloud.client.loadbalancer.reactive.Response; +import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients; +import org.springframework.cloud.loadbalancer.core.ReactorLoadBalancer; +import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * Tests for {@link BlockingLoadBalancerClient}. + * + * @author Olga Maciaszek-Sharma + */ +@SpringBootTest +@RunWith(SpringRunner.class) +public class BlockingLoadBalancerClientTests { + + @Autowired + private BlockingLoadBalancerClient loadBalancerClient; + + @Autowired + private SimpleDiscoveryProperties properties; + + @Before + public void setUp() { + properties.getInstances().put("myservice", + Collections.singletonList( + new SimpleDiscoveryProperties.SimpleServiceInstance( + URI.create("https://test.example:9999")))); + } + + @Test + public void correctServiceInstanceChosen() { + ServiceInstance serviceInstance = loadBalancerClient.choose("myservice"); + assertThat(serviceInstance.getHost()).isEqualTo("test.example"); + } + + @Test + public void nullReturnedIfInstanceMissing() { + ServiceInstance serviceInstance = loadBalancerClient.choose("unknownservice"); + assertThat(serviceInstance).isNull(); + } + + @Test + public void requestExecutedAgainstCorrectInstance() throws IOException { + final String result = "result"; + Object actualResult = loadBalancerClient.execute("myservice", + (LoadBalancerRequest) instance -> { + assertThat(instance.getHost()).isEqualTo("test.example"); + return result; + }); + assertThat(actualResult).isEqualTo(result); + } + + @Test + public void exceptionThrownIfInstanceNotAvailableForRequestExecution() + throws IOException { + try { + final String result = "result"; + Object actualResult = loadBalancerClient.execute("unknownservice", + (LoadBalancerRequest) instance -> result); + assertThat(actualResult).isEqualTo(result); + fail("Should have thrown exception."); + } + catch (Exception exception) { + assertThat(exception).isNotNull(); + assertThat(exception).isInstanceOf(IllegalStateException.class); + assertThat(exception).hasMessage("No instances available for unknownservice"); + } + } + + @Test + public void exceptionRethrownAsRuntime() { + try { + loadBalancerClient.execute("myservice", instance -> { + assertThat(instance.getHost()).isEqualTo("test.example"); + throw new Exception("Should throw exception."); + }); + fail("Should have thrown exception."); + } + catch (Exception exception) { + assertThat(exception).isNotNull(); + assertThat(exception).isInstanceOf(RuntimeException.class); + } + } + + @Test + public void IOExceptionRethrown() { + try { + loadBalancerClient.execute("myservice", instance -> { + assertThat(instance.getHost()).isEqualTo("test.example"); + throw new IOException("Should throw IO exception."); + }); + fail("Should have thrown exception."); + } + catch (Exception exception) { + assertThat(exception).isNotNull(); + assertThat(exception).isInstanceOf(IOException.class); + } + } + + @Configuration + @EnableAutoConfiguration + @SpringBootConfiguration + @LoadBalancerClients({ + @org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient(name = "myservice", configuration = MyServiceConfig.class), + @org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient(name = "unknownservice", configuration = UnknownServiceConfig.class) }) + protected static class Config { + + } + + protected static class MyServiceConfig { + + @Bean + ReactorLoadBalancer reactiveLoadBalancer( + DiscoveryClient discoveryClient) { + return new DiscoveryClientBasedReactiveLoadBalancer("myservice", + discoveryClient); + } + + } + + protected static class UnknownServiceConfig { + + @Bean + ReactorLoadBalancer reactiveLoadBalancer( + DiscoveryClient discoveryClient) { + return new DiscoveryClientBasedReactiveLoadBalancer("unknownservice", + discoveryClient); + } + + } + +} + +class DiscoveryClientBasedReactiveLoadBalancer + implements ReactorServiceInstanceLoadBalancer { + + private final Random random = new Random(); + + private final String serviceId; + + private final DiscoveryClient discoveryClient; + + DiscoveryClientBasedReactiveLoadBalancer(String serviceId, + DiscoveryClient discoveryClient) { + this.serviceId = serviceId; + this.discoveryClient = discoveryClient; + } + + @Override + public Mono> choose() { + List instances = discoveryClient.getInstances(serviceId); + if (instances.size() == 0) { + return Mono.just(new EmptyResponse()); + } + int instanceIdx = this.random.nextInt(instances.size()); + return Mono.just(new DefaultResponse(instances.get(instanceIdx))); + } + + @Override + public Mono> choose(Request request) { + return choose(); + } + +} From 1c762065e1d971d068decadaa45c0f8fba1dc552 Mon Sep 17 00:00:00 2001 From: Ryan Baxter Date: Wed, 4 Sep 2019 11:56:54 -0400 Subject: [PATCH 2/2] Use WebServerInitializedEvent to get port for SimpleDiscoveryProperties (#598) * Use WebServerInitializedEvent to get port for SimpleDiscoveryProperties. Fixes #255 --- ...impleDiscoveryClientAutoConfiguration.java | 36 +++++++----- ...overyPropertiesAutoConfigurationTests.java | 55 +++++++++++++++++++ ...overyPropertiesAutoConfigurationTests.java | 55 +++++++++++++++++++ 3 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/discovery/simple/ReactiveSimpleDiscoveryPropertiesAutoConfigurationTests.java create mode 100644 spring-cloud-commons/src/test/java/org/springframework/cloud/client/discovery/simple/ServletSimpleDiscoveryPropertiesAutoConfigurationTests.java diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/discovery/simple/SimpleDiscoveryClientAutoConfiguration.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/discovery/simple/SimpleDiscoveryClientAutoConfiguration.java index d3d1ad1b..0062e2e6 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/discovery/simple/SimpleDiscoveryClientAutoConfiguration.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/discovery/simple/SimpleDiscoveryClientAutoConfiguration.java @@ -22,18 +22,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.web.context.WebServerInitializedEvent; import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.cloud.client.discovery.noop.NoopDiscoveryClientAutoConfiguration; import org.springframework.cloud.commons.util.InetUtils; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; -// import org.springframework.boot.context.embedded.EmbeddedServletContainer; -// import org.springframework.boot.context.embedded.EmbeddedWebApplicationContext; - /** * Spring Boot auto-configuration for simple properties-based discovery client. * @@ -42,7 +41,8 @@ import org.springframework.core.annotation.Order; @Configuration @AutoConfigureBefore(NoopDiscoveryClientAutoConfiguration.class) -public class SimpleDiscoveryClientAutoConfiguration { +public class SimpleDiscoveryClientAutoConfiguration + implements ApplicationListener { @Autowired(required = false) private ServerProperties server; @@ -56,9 +56,12 @@ public class SimpleDiscoveryClientAutoConfiguration { @Autowired private InetUtils inet; + private int port = 0; + + private SimpleDiscoveryProperties simple = new SimpleDiscoveryProperties(); + @Bean public SimpleDiscoveryProperties simpleDiscoveryProperties() { - SimpleDiscoveryProperties simple = new SimpleDiscoveryProperties(); simple.getLocal().setServiceId(this.serviceId); simple.getLocal() .setUri(URI.create( @@ -74,15 +77,9 @@ public class SimpleDiscoveryClientAutoConfiguration { } private int findPort() { - // FIXME: is what is the boot 2.0 equiv? - /* - * if (ClassUtils.isPresent( - * "org.springframework.boot.context.embedded.EmbeddedWebApplicationContext", - * null)) { if (this.context instanceof EmbeddedWebApplicationContext) { - * EmbeddedServletContainer container = ((EmbeddedWebApplicationContext) - * this.context) .getEmbeddedServletContainer(); if (container != null) { return - * container.getPort(); } } } - */ + if (port > 0) { + return port; + } if (this.server != null && this.server.getPort() != null && this.server.getPort() > 0) { return this.server.getPort(); @@ -90,4 +87,15 @@ public class SimpleDiscoveryClientAutoConfiguration { return 8080; } + @Override + public void onApplicationEvent(WebServerInitializedEvent webServerInitializedEvent) { + this.port = webServerInitializedEvent.getWebServer().getPort(); + if (this.port > 0) { + simple.getLocal() + .setUri(URI.create("http://" + + this.inet.findFirstNonLoopbackHostInfo().getHostname() + ":" + + this.port)); + } + } + } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/discovery/simple/ReactiveSimpleDiscoveryPropertiesAutoConfigurationTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/discovery/simple/ReactiveSimpleDiscoveryPropertiesAutoConfigurationTests.java new file mode 100644 index 00000000..fc3728cc --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/discovery/simple/ReactiveSimpleDiscoveryPropertiesAutoConfigurationTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.client.discovery.simple; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.assertj.core.api.BDDAssertions.then; + +/** + * @author Ryan Baxter + */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = ReactiveSimpleDiscoveryPropertiesAutoConfigurationTests.Config.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "spring.main.web-application-type=reactive") +public class ReactiveSimpleDiscoveryPropertiesAutoConfigurationTests { + + @Autowired + private SimpleDiscoveryProperties discoveryProperties; + + @LocalServerPort + private int port; + + @Test + public void testPort() { + then(discoveryProperties.getLocal().getPort()).isEqualTo(port); + } + + @EnableAutoConfiguration + @Configuration + public static class Config { + + } + +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/discovery/simple/ServletSimpleDiscoveryPropertiesAutoConfigurationTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/discovery/simple/ServletSimpleDiscoveryPropertiesAutoConfigurationTests.java new file mode 100644 index 00000000..2d1cb34e --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/discovery/simple/ServletSimpleDiscoveryPropertiesAutoConfigurationTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.client.discovery.simple; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.assertj.core.api.BDDAssertions.then; + +/** + * @author Ryan Baxter + */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = ServletSimpleDiscoveryPropertiesAutoConfigurationTests.Config.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class ServletSimpleDiscoveryPropertiesAutoConfigurationTests { + + @Autowired + private SimpleDiscoveryProperties discoveryProperties; + + @LocalServerPort + private int port; + + @Test + public void testPort() { + then(discoveryProperties.getLocal().getPort()).isEqualTo(port); + } + + @EnableAutoConfiguration + @Configuration + public static class Config { + + } + +}