diff --git a/pom.xml b/pom.xml index 9d3495881..923394033 100644 --- a/pom.xml +++ b/pom.xml @@ -88,6 +88,8 @@ 1.0.0 1.83 true + 1.0.2 + 1.79.0 diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerClientsProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerClientsProperties.java index 32ade4130..1f7bf082c 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerClientsProperties.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerClientsProperties.java @@ -36,8 +36,32 @@ public class LoadBalancerClientsProperties extends LoadBalancerProperties { private final Map clients = new HashMap<>(); + private GrpcClient grpcClient = new GrpcClient(); + public Map getClients() { return this.clients; } + public GrpcClient getGrpcClient() { + return grpcClient; + } + + public void setGrpcClient(GrpcClient grpcClient) { + this.grpcClient = grpcClient; + } + + public static class GrpcClient { + + private boolean enabled = false; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + } + } diff --git a/spring-cloud-loadbalancer/pom.xml b/spring-cloud-loadbalancer/pom.xml index a52d776b1..539f82b82 100644 --- a/spring-cloud-loadbalancer/pom.xml +++ b/spring-cloud-loadbalancer/pom.xml @@ -94,6 +94,18 @@ spring-boot-starter-web true + + org.springframework.grpc + spring-grpc-core + ${spring-grpc.version} + true + + + io.grpc + grpc-netty + ${grpc.version} + true + org.springframework.boot spring-boot-starter-test diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/annotation/grpc/GrpcClient.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/annotation/grpc/GrpcClient.java new file mode 100644 index 000000000..b445c2c86 --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/annotation/grpc/GrpcClient.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-present 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.annotation.grpc; + +import org.springframework.core.annotation.AliasFor; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @author KouShenhai(laokou) + */ +@Inherited +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER }) +public @interface GrpcClient { + + /** + * Synonym for name (the name of the grpc client). + * + * @see #name() + * @return the name of the load balancer grpc client + */ + @AliasFor("name") + String value() default ""; + + /** + * The name of the load balancer grpc client, uniquely identifying a set of client + * resources, including a load balancer. + * @return the name of the load balancer grpc client + */ + @AliasFor("value") + String name() default ""; + +} diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/annotation/grpc/GrpcClientBeanPostProcessor.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/annotation/grpc/GrpcClientBeanPostProcessor.java new file mode 100644 index 000000000..2065cf494 --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/annotation/grpc/GrpcClientBeanPostProcessor.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-present 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.annotation.grpc; + +import org.jspecify.annotations.NonNull; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.grpc.client.GrpcClientFactory; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +/** + * @author KouShenhai(laokou) + */ +public class GrpcClientBeanPostProcessor implements BeanPostProcessor { + + private final GrpcClientFactory grpcClientFactory; + + public GrpcClientBeanPostProcessor(GrpcClientFactory grpcClientFactory) { + this.grpcClientFactory = grpcClientFactory; + } + + @Override + public Object postProcessBeforeInitialization(@NonNull Object bean, @NonNull String beanName) + throws BeansException { + Class clazz = bean.getClass(); + do { + setFields(bean, clazz); + setMethods(bean, clazz); + clazz = clazz.getSuperclass(); + } + while (!ObjectUtils.isEmpty(clazz)); + return bean; + } + + private void setFields(Object bean, Class clazz) { + for (Field field : clazz.getDeclaredFields()) { + GrpcClient grpcClient = AnnotationUtils.findAnnotation(field, GrpcClient.class); + if (!ObjectUtils.isEmpty(grpcClient)) { + ReflectionUtils.makeAccessible(field); + ReflectionUtils.setField(field, bean, getClient(field.getType(), grpcClient)); + } + } + } + + private void setMethods(Object bean, Class clazz) { + for (Method method : clazz.getDeclaredMethods()) { + GrpcClient grpcClient = AnnotationUtils.findAnnotation(method, GrpcClient.class); + if (!ObjectUtils.isEmpty(grpcClient)) { + ReflectionUtils.makeAccessible(method); + ReflectionUtils.invokeMethod(method, bean, getClient(method.getParameterTypes()[0], grpcClient)); + } + } + } + + private T getClient(Class type, @NonNull GrpcClient grpcClient) { + String target = String.format("discovery://%s", getClientName(grpcClient)); + return grpcClientFactory.getClient(target, type, null); + } + + private String getClientName(GrpcClient grpcClient) { + String value = grpcClient.value(); + if (!StringUtils.hasText(value)) { + value = grpcClient.name(); + } + if (StringUtils.hasText(value)) { + return value; + } + throw new IllegalStateException("Either 'name' or 'value' must be provided in @GrpcClient"); + } + +} 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 916708f57..e178aac1e 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 @@ -18,25 +18,37 @@ import java.util.Collections; import java.util.List; +import java.util.concurrent.ExecutorService; +import io.grpc.netty.NettyChannelBuilder; +import org.jspecify.annotations.NonNull; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.cloud.client.loadbalancer.LoadBalancerClientsProperties; import org.springframework.cloud.client.loadbalancer.LoadBalancerEagerLoadProperties; import org.springframework.cloud.client.loadbalancer.reactive.LoadBalancerBeanPostProcessorAutoConfiguration; 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.annotation.grpc.GrpcClientBeanPostProcessor; import org.springframework.cloud.loadbalancer.aot.LoadBalancerChildContextInitializer; import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; import org.springframework.cloud.loadbalancer.support.LoadBalancerEagerContextInitializer; +import org.springframework.cloud.loadbalancer.support.grpc.DiscoveryGrpcChannelFactory; +import org.springframework.cloud.loadbalancer.support.grpc.DiscoveryNameResolverProvider; +import org.springframework.cloud.loadbalancer.support.grpc.DiscoveryNameResolverRegister; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.GrpcClientFactory; /** * @author Spencer Gibb @@ -77,4 +89,37 @@ static LoadBalancerChildContextInitializer loadBalancerChildContextInitializer( return new LoadBalancerChildContextInitializer(loadBalancerClientFactory, parentContext); } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ DiscoveryClient.class, GrpcClientFactory.class, ClientInterceptorsConfigurer.class, + ExecutorService.class }) + @ConditionalOnProperty(prefix = "spring.cloud.loadbalancer.grpc-client", name = "enabled", havingValue = "true", + matchIfMissing = true) + static class GrpcClientConfiguration { + + @Bean + DiscoveryNameResolverProvider discoveryNameResolverProvider(DiscoveryClient discoveryClient, + ExecutorService executorService) { + return new DiscoveryNameResolverProvider(discoveryClient, executorService); + } + + @Bean + DiscoveryNameResolverRegister discoveryNameResolverRegister( + DiscoveryNameResolverProvider discoveryNameResolverProvider) { + return new DiscoveryNameResolverRegister(discoveryNameResolverProvider); + } + + @Bean + GrpcClientBeanPostProcessor grpcClientBeanPostProcessor(GrpcClientFactory grpcClientFactory) { + return new GrpcClientBeanPostProcessor(grpcClientFactory); + } + + @Bean + DiscoveryGrpcChannelFactory discoveryGrpcChannelFactory( + List> globalCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer) { + return new DiscoveryGrpcChannelFactory(globalCustomizers, interceptorsConfigurer); + } + + } + } diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryGrpcChannelFactory.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryGrpcChannelFactory.java new file mode 100644 index 000000000..5ce58408a --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryGrpcChannelFactory.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-present 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.support.grpc; + +import io.grpc.ChannelCredentials; +import io.grpc.netty.NettyChannelBuilder; +import org.jspecify.annotations.NonNull; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.NettyGrpcChannelFactory; + +import java.util.List; + +/** + * Discovery Grpc ChannelFactory. + * + * @author KouShenhai(laokou) + */ +public class DiscoveryGrpcChannelFactory extends NettyGrpcChannelFactory { + + /** + * 构建通道工厂实例. + * @param globalCustomizers 要应用于所有已创建频道的全局自定义设置 + * @param interceptorsConfigurer 配置已创建通道上的客户端拦截器 + */ + public DiscoveryGrpcChannelFactory( + List> globalCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer) { + super(globalCustomizers, interceptorsConfigurer); + setVirtualTargets((p) -> p.substring(12)); + } + + @Override + public boolean supports(String target) { + return target.startsWith("discovery:"); + } + + @NonNull + @Override + protected NettyChannelBuilder newChannelBuilder(@NonNull String target, @NonNull ChannelCredentials credentials) { + return NettyChannelBuilder.forTarget(String.format("discovery://%s", target), credentials); + } + +} diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolver.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolver.java new file mode 100644 index 000000000..9960b9dc4 --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolver.java @@ -0,0 +1,165 @@ +/* + * Copyright 2012-present 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.support.grpc; + +import io.grpc.Attributes; +import io.grpc.EquivalentAddressGroup; +import io.grpc.NameResolver; +import io.grpc.Status; +import io.grpc.StatusOr; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.DiscoveryClient; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; + +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +/** + * Discovery NameResolver. + * + * @author KouShenhai(laokou) + */ +public class DiscoveryNameResolver extends NameResolver { + + private final String serviceName; + + private final DiscoveryClient discoveryClient; + + private final ExecutorService executorService; + + private final AtomicReference> serviceInstanceReference; + + private final AtomicBoolean resolving; + + private Listener2 listener; + + public DiscoveryNameResolver(String serviceName, DiscoveryClient discoveryClient, ExecutorService executorService) { + this.serviceName = serviceName; + this.discoveryClient = discoveryClient; + this.executorService = executorService; + this.serviceInstanceReference = new AtomicReference<>(Collections.emptyList()); + this.resolving = new AtomicBoolean(false); + } + + @Override + public String getServiceAuthority() { + return serviceName; + } + + @Override + public void shutdown() { + this.serviceInstanceReference.set(null); + } + + @Override + public void start(Listener2 listener) { + this.listener = listener; + resolve(); + } + + @Override + public void refresh() { + resolve(); + } + + public void refreshFromExternal() { + executorService.execute(() -> { + if (!ObjectUtils.isEmpty(listener)) { + resolve(); + } + }); + } + + private void resolve() { + if (this.resolving.compareAndSet(false, true)) { + this.executorService.execute(() -> { + this.serviceInstanceReference.set(resolveInternal()); + this.resolving.set(false); + }); + } + } + + private List resolveInternal() { + List serviceInstances = serviceInstanceReference.get(); + List newServiceInstanceList = this.discoveryClient.getInstances(this.serviceName); + if (CollectionUtils.isEmpty(newServiceInstanceList)) { + listener.onError(Status.UNAVAILABLE.withDescription("No servers found for " + serviceName)); + return Collections.emptyList(); + } + if (!isUpdateServiceInstance(serviceInstances, newServiceInstanceList)) { + return serviceInstances; + } + this.listener.onResult(ResolutionResult.newBuilder() + .setAddressesOrError(StatusOr.fromValue(toAddresses(newServiceInstanceList))) + .build()); + return newServiceInstanceList; + } + + private boolean isUpdateServiceInstance(List serviceInstances, + List newServiceInstanceList) { + if (serviceInstances.size() != newServiceInstanceList.size()) { + return true; + } + Set oldSet = serviceInstances.stream().map(this::getAddressStr).collect(Collectors.toSet()); + Set newSet = newServiceInstanceList.stream().map(this::getAddressStr).collect(Collectors.toSet()); + return !Objects.equals(oldSet, newSet); + } + + private List toAddresses(List newServiceInstanceList) { + List addresses = new ArrayList<>(newServiceInstanceList.size()); + for (ServiceInstance serviceInstance : newServiceInstanceList) { + addresses.add(toAddress(serviceInstance)); + } + return addresses; + } + + private EquivalentAddressGroup toAddress(ServiceInstance serviceInstance) { + String host = serviceInstance.getHost(); + int port = getGrpcPost(serviceInstance); + return new EquivalentAddressGroup(new InetSocketAddress(host, port), getAttributes(serviceInstance)); + } + + private Attributes getAttributes(ServiceInstance serviceInstance) { + return Attributes.newBuilder() + .set(Attributes.Key.create("serviceName"), serviceName) + .set(Attributes.Key.create("instanceId"), serviceInstance.getInstanceId()) + .build(); + } + + private String getAddressStr(ServiceInstance serviceInstance) { + return serviceInstance.getHost() + ":" + getGrpcPost(serviceInstance); + } + + private int getGrpcPost(ServiceInstance serviceInstance) { + Map metadata = serviceInstance.getMetadata(); + if (!ObjectUtils.isEmpty(metadata) && !metadata.isEmpty()) { + return Integer.parseInt(metadata.getOrDefault("grpc_port", "9090")); + } + return 9090; + } + +} diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverProvider.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverProvider.java new file mode 100644 index 000000000..d3b9da812 --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverProvider.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-present 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.support.grpc; + +import io.grpc.NameResolver; +import io.grpc.NameResolverProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cloud.client.discovery.DiscoveryClient; +import org.springframework.cloud.client.discovery.event.HeartbeatEvent; +import org.springframework.context.event.EventListener; + +import java.net.URI; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ExecutorService; + +/** + * Discovery NameResolver Provider. + * + * @author KouShenhai(laokou) + */ +public class DiscoveryNameResolverProvider extends NameResolverProvider { + + private final Logger logger = LoggerFactory.getLogger(DiscoveryNameResolverProvider.class); + + private final DiscoveryClient discoveryClient; + + private final ExecutorService executorService; + + private final Set discoveryNameResolvers; + + public DiscoveryNameResolverProvider(DiscoveryClient discoveryClient, ExecutorService executorService) { + this.discoveryClient = discoveryClient; + this.executorService = executorService; + this.discoveryNameResolvers = new HashSet<>(); + } + + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 6; + } + + @Override + public NameResolver newNameResolver(URI uri, NameResolver.Args args) { + DiscoveryNameResolver discoveryNameResolver = new DiscoveryNameResolver(uri.getHost(), discoveryClient, + executorService); + discoveryNameResolvers.add(discoveryNameResolver); + return discoveryNameResolver; + } + + @Override + public String getDefaultScheme() { + return "discovery"; + } + + @EventListener(HeartbeatEvent.class) + public void onHeartbeatEvent(HeartbeatEvent event) { + logger.debug("Received HeartbeatEvent, refreshing DiscoveryNameResolvers, event: {}", event.getValue()); + for (DiscoveryNameResolver discoveryNameResolver : discoveryNameResolvers) { + discoveryNameResolver.refreshFromExternal(); + } + } + +} diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverRegister.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverRegister.java new file mode 100644 index 000000000..d7e41444d --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverRegister.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-present 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.support.grpc; + +import io.grpc.NameResolverRegistry; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; + +/** + * @author KouShenhai(laokou) + */ +public class DiscoveryNameResolverRegister implements InitializingBean, DisposableBean { + + private final DiscoveryNameResolverProvider discoveryNameResolverProvider; + + public DiscoveryNameResolverRegister(DiscoveryNameResolverProvider discoveryNameResolverProvider) { + this.discoveryNameResolverProvider = discoveryNameResolverProvider; + } + + @Override + public void afterPropertiesSet() { + NameResolverRegistry.getDefaultRegistry().register(discoveryNameResolverProvider); + } + + @Override + public void destroy() { + NameResolverRegistry.getDefaultRegistry().deregister(discoveryNameResolverProvider); + } + +} diff --git a/spring-cloud-loadbalancer/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-loadbalancer/src/main/resources/META-INF/additional-spring-configuration-metadata.json index d3a57e68e..9d83fddda 100644 --- a/spring-cloud-loadbalancer/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-loadbalancer/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -44,6 +44,12 @@ "name": "spring.cloud.loadbalancer.stats.micrometer.enabled", "description": "Enables Spring Cloud LoadBalancer Micrometer stats.", "type": "java.lang.Boolean" - } + }, + { + "defaultValue": "false", + "name": "spring.cloud.loadbalancer.grpc-client.enabled", + "description": "Enables Spring Cloud LoadBalancer Grpc Client.", + "type": "java.lang.Boolean" + } ] } diff --git a/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/annotation/grpc/GrpcClientBeanPostProcessorTest.java b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/annotation/grpc/GrpcClientBeanPostProcessorTest.java new file mode 100644 index 000000000..20521928d --- /dev/null +++ b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/annotation/grpc/GrpcClientBeanPostProcessorTest.java @@ -0,0 +1,116 @@ +/* + * Copyright 2012-present 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.annotation.grpc; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springframework.grpc.client.GrpcClientFactory; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author KouShenhai(laokou) + */ +class GrpcClientBeanPostProcessorTest { + + private GrpcClientBeanPostProcessor postProcessor; + + private final String mockClient = "mockClient"; + + @BeforeEach + void setUp() { + GrpcClientFactory grpcClientFactory = mock(GrpcClientFactory.class); + postProcessor = new GrpcClientBeanPostProcessor(grpcClientFactory); + when(grpcClientFactory.getClient(ArgumentMatchers.eq("discovery://test-service"), + ArgumentMatchers.eq(String.class), ArgumentMatchers.any())) + .thenReturn(mockClient); + } + + @Test + void testFieldInjection() { + FieldBean bean = new FieldBean(); + postProcessor.postProcessBeforeInitialization(bean, "fieldBean"); + Assertions.assertThat(bean.getClient()).isEqualTo(mockClient); + } + + @Test + void testMethodInjection() { + MethodBean bean = new MethodBean(); + postProcessor.postProcessBeforeInitialization(bean, "methodBean"); + Assertions.assertThat(bean.getClient()).isEqualTo(mockClient); + } + + @Test + void testSuperclassInjection() { + SubBean bean = new SubBean(); + postProcessor.postProcessBeforeInitialization(bean, "subBean"); + Assertions.assertThat(bean.getClient()).isEqualTo(mockClient); + } + + static class FieldBean { + + @GrpcClient(value = "test-service") + private String client; + + public void setClient(String client) { + this.client = client; + } + + public String getClient() { + return client; + } + + } + + static class MethodBean { + + private String client; + + @GrpcClient(value = "test-service") + public void setClient(String client) { + this.client = client; + } + + public String getClient() { + return client; + } + + } + + static class BaseBean { + + @GrpcClient(value = "test-service") + protected String client; + + public void setClient(String client) { + this.client = client; + } + + public String getClient() { + return client; + } + + } + + static class SubBean extends BaseBean { + + } + +} diff --git a/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryGrpcChannelFactoryTest.java b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryGrpcChannelFactoryTest.java new file mode 100644 index 000000000..5b67906ec --- /dev/null +++ b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryGrpcChannelFactoryTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-present 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.support.grpc; + +import io.grpc.ChannelCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.netty.NettyChannelBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * @author KouShenhai(laokou) + */ +class DiscoveryGrpcChannelFactoryTest { + + private DiscoveryGrpcChannelFactory factory; + + @BeforeEach + void setUp() { + ClientInterceptorsConfigurer configurer = mock(ClientInterceptorsConfigurer.class); + factory = new DiscoveryGrpcChannelFactory(Collections.emptyList(), configurer); + } + + @Test + void testSupports() { + assertThat(factory.supports("discovery:test-service")).isTrue(); + assertThat(factory.supports("static:localhost")).isFalse(); + assertThat(factory.supports("dns:localhost")).isFalse(); + } + + @Test + void testNewChannelBuilder() { + String target = "test-service"; + ChannelCredentials credentials = InsecureChannelCredentials.create(); + NettyChannelBuilder builder = factory.newChannelBuilder(target, credentials); + assertThat(builder).isNotNull(); + } + +} diff --git a/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverProviderTest.java b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverProviderTest.java new file mode 100644 index 000000000..cfb92b2aa --- /dev/null +++ b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverProviderTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-present 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.support.grpc; + +import io.grpc.NameResolver; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.cloud.client.discovery.DiscoveryClient; +import org.springframework.cloud.client.discovery.event.HeartbeatEvent; + +import java.net.URI; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.mockito.Mockito.mock; + +/** + * @author KouShenhai(laokou) + */ +class DiscoveryNameResolverProviderTest { + + private DiscoveryNameResolverProvider provider; + + @BeforeEach + void setUp() { + DiscoveryClient discoveryClient = mock(DiscoveryClient.class); + ExecutorService executorService = Executors.newSingleThreadExecutor(); + provider = new DiscoveryNameResolverProvider(discoveryClient, executorService); + } + + @Test + void testIsAvailable() { + Assertions.assertThat(provider.isAvailable()).isTrue(); + } + + @Test + void testPriority() { + Assertions.assertThat(provider.priority()).isEqualTo(6); + } + + @Test + void testGetDefaultScheme() { + Assertions.assertThat(provider.getDefaultScheme()).isEqualTo("discovery"); + } + + @Test + void testNewNameResolver() { + URI uri = URI.create("discovery://test-service"); + NameResolver.Args args = mock(NameResolver.Args.class); + Mockito.when(args.getServiceConfigParser()).thenReturn(mock(NameResolver.ServiceConfigParser.class)); + + NameResolver resolver = provider.newNameResolver(uri, args); + Assertions.assertThat(resolver).isExactlyInstanceOf(DiscoveryNameResolver.class); + Assertions.assertThat(resolver.getServiceAuthority()).isEqualTo("test-service"); + } + + @Test + void testOnHeartbeatEvent() { + URI uri = URI.create("discovery://test-service"); + NameResolver.Args args = mock(NameResolver.Args.class); + Mockito.when(args.getServiceConfigParser()).thenReturn(mock(NameResolver.ServiceConfigParser.class)); + + // Register a resolver + provider.newNameResolver(uri, args); + + HeartbeatEvent event = new HeartbeatEvent(new Object(), "test"); + provider.onHeartbeatEvent(event); + } + +} diff --git a/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverTest.java b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverTest.java new file mode 100644 index 000000000..cf396104b --- /dev/null +++ b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/support/grpc/DiscoveryNameResolverTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2012-present 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.support.grpc; + +import io.grpc.EquivalentAddressGroup; +import io.grpc.NameResolver; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.cloud.client.DefaultServiceInstance; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.DiscoveryClient; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +/** + * @author KouShenhai(laokou) + */ +class DiscoveryNameResolverTest { + + private DiscoveryClient discoveryClient; + + private NameResolver.Listener2 listener; + + private DiscoveryNameResolver resolver; + + private final String serviceName = "test"; + + @BeforeEach + void setUp() { + discoveryClient = Mockito.mock(DiscoveryClient.class); + ExecutorService executorService = Executors.newSingleThreadExecutor(); + NameResolver.Args args = Mockito.mock(NameResolver.Args.class); + listener = Mockito.mock(NameResolver.Listener2.class); + + Mockito.when(args.getServiceConfigParser()).thenReturn(Mockito.mock(NameResolver.ServiceConfigParser.class)); + + resolver = new DiscoveryNameResolver(serviceName, discoveryClient, executorService); + } + + @Test + void testGetServiceAuthority() { + assertThat(resolver.getServiceAuthority()).isEqualTo(serviceName); + } + + @Test + void testStartAndResolve() throws InterruptedException { + Map metadata = new HashMap<>(); + metadata.put("grpc_port", "9091"); + ServiceInstance instance = new DefaultServiceInstance(serviceName + "-1", serviceName, "localhost", 9090, false, + metadata); + Mockito.when(discoveryClient.getInstances(serviceName)).thenReturn(List.of(instance)); + + resolver.start(listener); + + // Wait for async execution + Thread.sleep(200); + + ArgumentCaptor resultCaptor = ArgumentCaptor + .forClass(NameResolver.ResolutionResult.class); + verify(listener).onResult(resultCaptor.capture()); + + NameResolver.ResolutionResult result = resultCaptor.getValue(); + List list = result.getAddressesOrError().getValue(); + assertThat(list).hasSize(1); + assertThat(list.get(0).getAddresses().get(0).toString()).contains("9091"); + } + + @Test + void testResolveNoInstances() throws InterruptedException { + Mockito.when(discoveryClient.getInstances(serviceName)).thenReturn(Collections.emptyList()); + + resolver.start(listener); + + // Wait for async execution + Thread.sleep(200); + + verify(listener).onError(Mockito.any()); + } + + @Test + void testRefresh() throws InterruptedException { + Mockito.when(discoveryClient.getInstances(serviceName)).thenReturn(Collections.emptyList()); + resolver.start(listener); + Thread.sleep(100); + + resolver.refresh(); + Thread.sleep(100); + + // verify called twice (start and refresh) + verify(discoveryClient, Mockito.times(2)).getInstances(serviceName); + } + + @Test + void testRefreshFromExternal() throws InterruptedException { + Mockito.when(discoveryClient.getInstances(serviceName)).thenReturn(Collections.emptyList()); + resolver.start(listener); + Thread.sleep(100); + + resolver.refreshFromExternal(); + Thread.sleep(100); + + verify(discoveryClient, Mockito.times(2)).getInstances(serviceName); + } + +}