From f75fea3601ec2113922160b9ba13baae87271df3 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Thu, 19 Mar 2026 11:27:15 -0700 Subject: [PATCH] Add type-safe gRPC service config properties Update `GrpcClientProperties` to include type-safe service config properties commonly configured items. Closes gh-49540 --- module/spring-boot-grpc-client/build.gradle | 1 + .../autoconfigure/GrpcClientProperties.java | 13 + ...cClientDefaultServiceConfigCustomizer.java | 41 +- .../client/autoconfigure/ServiceConfig.java | 430 ++++++++++++++++++ .../GrpcChannelBuilderCustomizersTests.java | 34 ++ .../autoconfigure/ServiceConfigTests.java | 393 ++++++++++++++++ 6 files changed, 908 insertions(+), 4 deletions(-) create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfig.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfigTests.java diff --git a/module/spring-boot-grpc-client/build.gradle b/module/spring-boot-grpc-client/build.gradle index 52bf7893628..6a2d64dd9aa 100644 --- a/module/spring-boot-grpc-client/build.gradle +++ b/module/spring-boot-grpc-client/build.gradle @@ -30,6 +30,7 @@ dependencies { optional(project(":core:spring-boot-autoconfigure")) optional(project(":module:spring-boot-micrometer-observation")) + optional("io.grpc:grpc-grpclb") optional("io.grpc:grpc-stub") optional("io.grpc:grpc-netty") optional("io.grpc:grpc-netty-shaded") diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java index f015938d1be..e57ecece11b 100644 --- a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java @@ -70,6 +70,11 @@ public class GrpcClientProperties { */ private boolean bypassCertificateValidation; + /** + * Service config for the channel. + */ + private @Nullable ServiceConfig serviceConfig; + private final Inbound inbound = new Inbound(); @Name("default") @@ -107,6 +112,14 @@ public class GrpcClientProperties { this.bypassCertificateValidation = bypassCertificateValidation; } + public @Nullable ServiceConfig getServiceConfig() { + return this.serviceConfig; + } + + public void setServiceConfig(@Nullable ServiceConfig serviceConfig) { + this.serviceConfig = serviceConfig; + } + public Inbound getInbound() { return this.inbound; } diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java index 58026ae3e93..c33f9f5782e 100644 --- a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java @@ -16,9 +16,15 @@ package org.springframework.boot.grpc.client.autoconfigure; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; +import org.jspecify.annotations.Nullable; + import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel.Health; +import org.springframework.util.Assert; /** * {@link GrpcClientDefaultServiceConfigCustomizer} to apply {@link GrpcClientProperties}. @@ -34,11 +40,38 @@ record PropertiesGrpcClientDefaultServiceConfigCustomizer( public void customize(String target, Map defaultServiceConfig) { Channel channel = this.properties.getChannel().get(target); channel = (channel != null) ? channel : this.properties.getChannel().get("default"); - if (channel != null && channel.getHealth().isEnabled()) { - String serviceName = channel.getHealth().getServiceName(); - Map healthCheckConfig = Map.of("serviceName", (serviceName != null) ? serviceName : ""); - defaultServiceConfig.put("healthCheckConfig", healthCheckConfig); + if (channel == null) { + return; } + applyServiceConfig(channel.getServiceConfig(), defaultServiceConfig); + applyHealth(channel.getHealth(), defaultServiceConfig); + } + + private void applyServiceConfig(@Nullable ServiceConfig serviceConfig, Map defaultServiceConfig) { + if (serviceConfig != null) { + serviceConfig.applyTo(defaultServiceConfig); + } + } + + private void applyHealth(Health health, Map defaultServiceConfig) { + if (!health.isEnabled()) { + return; + } + String serviceName = (health.getServiceName() != null) ? health.getServiceName() : ""; + Map healthCheckConfig = cloneOrCreateHealthCheckConfig(defaultServiceConfig); + String existingServiceName = (String) healthCheckConfig.get(ServiceConfig.HEALTH_CHECK_SERVICE_NAME_KEY); + Assert.state(existingServiceName == null || serviceName.equals(existingServiceName), + () -> "Unable to change health check config service name from '%s' to '%s'" + .formatted(existingServiceName, serviceName)); + healthCheckConfig.put(ServiceConfig.HEALTH_CHECK_SERVICE_NAME_KEY, serviceName); + defaultServiceConfig.put(ServiceConfig.HEALTH_CHECK_CONFIG_KEY, healthCheckConfig); + } + + @SuppressWarnings("unchecked") + private Map cloneOrCreateHealthCheckConfig(Map defaultServiceConfig) { + Map healthCheckConfig = (Map) defaultServiceConfig + .get(ServiceConfig.HEALTH_CHECK_CONFIG_KEY); + return new LinkedHashMap<>((healthCheckConfig != null) ? healthCheckConfig : Collections.emptyMap()); } } diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfig.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfig.java new file mode 100644 index 00000000000..1ddd11a3a62 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfig.java @@ -0,0 +1,430 @@ +/* + * 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.boot.grpc.client.autoconfigure; + +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; + +import io.grpc.Status; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.context.properties.PropertyMapper.Source.Adapter; +import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.util.CollectionUtils; +import org.springframework.util.unit.DataSize; + +/** + * Bindable service configuration for gRPC channel. Allows type safe binding of common + * service configuration options which can ultimately be applied to the {@link Map} + * provided by a {@link GrpcClientDefaultServiceConfigCustomizer}. + *

+ * The configuration provided here is a subset of the canonical service_config.proto + * protocol definition. For advanced or experimental service configurations, use the + * {@link GrpcClientDefaultServiceConfigCustomizer} to directly add any entries supported + * by {@code grpc-java}. + * + * @author Phillip Webb + * @param loadbalancing load balancing configurations in the order that they should be + * applied + * @param method method configuration + * @param retrythrottling retry throttling policy + * @param healthcheck health check configuration + * @since 4.1.0 + * @see GrpcClientDefaultServiceConfigCustomizer + * @see io.grpc.internal.ServiceConfigUtil + */ +public record ServiceConfig(@Nullable List loadbalancing, @Nullable List method, + @Nullable RetryThrottlingPolicy retrythrottling, @Nullable HealthCheckConfig healthcheck) { + + static final String HEALTH_CHECK_CONFIG_KEY = "healthCheckConfig"; + + static final String HEALTH_CHECK_SERVICE_NAME_KEY = "serviceName"; + + /** + * Apply this service config to the given gRPC Java config Map. + * @param grpcJavaConfig the gRPC Java config map + */ + public void applyTo(Map grpcJavaConfig) { + applyTo(new GrpcJavaConfig(grpcJavaConfig)); + } + + private void applyTo(GrpcJavaConfig config) { + PropertyMapper map = PropertyMapper.get(); + map.from(this::loadbalancing) + .as(listOf(LoadBalancingConfig::grpcJavaConfig)) + .to(config.in("loadBalancingConfig")); + map.from(this::method).as(listOf(MethodConfig::grpcJavaConfig)).to(config.in("methodConfig")); + map.from(this::retrythrottling).as(RetryThrottlingPolicy::grpcJavaConfig).to(config.in("retryThrottling")); + map.from(this::healthcheck).as(HealthCheckConfig::grpcJavaConfig).to(config.in(HEALTH_CHECK_CONFIG_KEY)); + } + + static Adapter, @Nullable List>> listOf(Function> adapter) { + return (list) -> (!CollectionUtils.isEmpty(list)) ? list.stream().map(adapter).toList() : null; + } + + static String durationString(Duration duration) { + return duration.getSeconds() + "." + duration.getNano() + "s"; + } + + static String bytesString(DataSize dataSize) { + return Long.toString(dataSize.toBytes()); + } + + /** + * Load balancing config. + * + * @param pickfirst 'pick first' load balancing + * @param roundrobin 'round robin' load balancing + * @param weightedroundrobin 'weighted round robin' load balancing + * @param grpc 'grpc' load balancing + */ + public record LoadBalancingConfig(@Nullable PickFirstLoadBalancingConfig pickfirst, + @Nullable RoundRobinLoadBalancingConfig roundrobin, + @Nullable WeightedRoundRobinLoadBalancingConfig weightedroundrobin, + @Nullable GrpcLoadBalancingConfig grpc) { + + public LoadBalancingConfig { + if (pickfirst == null && roundrobin == null && weightedroundrobin == null && grpc == null) { + throw new InvalidConfigurationPropertyValueException("loadbalancing", null, + "Missing load balancing strategy"); + } + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("loadbalancing.pickfirst", pickfirst); + entries.put("loadbalancing.roundrobin", roundrobin); + entries.put("loadbalancing.weightedroundrobin", weightedroundrobin); + entries.put("loadbalancing.grpc", grpc); + }); + } + + Map grpcJavaConfig() { + LinkedHashMap grpcJavaConfig = new LinkedHashMap<>(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::pickfirst) + .as(PickFirstLoadBalancingConfig::grpcJavaConfig) + .to((loadBalancingConfig) -> grpcJavaConfig.put("pick_first", loadBalancingConfig)); + map.from(this::roundrobin) + .as(RoundRobinLoadBalancingConfig::grpcJavaConfig) + .to((loadBalancingConfig) -> grpcJavaConfig.put("round_robin", loadBalancingConfig)); + map.from(this::weightedroundrobin) + .as(WeightedRoundRobinLoadBalancingConfig::grpcJavaConfig) + .to((loadBalancingConfig) -> grpcJavaConfig.put("weighted_round_robin", loadBalancingConfig)); + map.from(this::grpc) + .as(GrpcLoadBalancingConfig::grpcJavaConfig) + .to((loadBalancingConfig) -> grpcJavaConfig.put("grpclb", loadBalancingConfig)); + return grpcJavaConfig; + } + + /** + * 'pick first' load balancing. + * + * @param shuffleAddressList randomly shuffle the list of addresses received from + * the name resolver before attempting to connect to them. + */ + public record PickFirstLoadBalancingConfig(Boolean shuffleAddressList) { + + Map grpcJavaConfig() { + // Aligned with PickFirstLoadBalancerProvider + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::shuffleAddressList).to(grpcJavaConfig.in("shuffleAddressList")); + return grpcJavaConfig.asMap(); + } + + } + + /** + * 'round robin' load balancing. + */ + public record RoundRobinLoadBalancingConfig() { + + /** + * Return the gRPC java config as supported by the + * {@code SecretRoundRobinLoadBalancerProvider}. + * @return the config + */ + Map grpcJavaConfig() { + return Collections.emptyMap(); + } + + } + + /** + * 'weighted round robin' load balancing. + * + * @param blackoutPeriod must report load metrics continuously for at least this + * long before the endpoint weight will be used + * @param weightExpirationPeriod if has not reported load metrics in this long, + * then we stop using the reported weight + * @param outOfBandReportingPeriod load reporting interval to request from the + * server + * @param enableOutOfBandLoadReport whether to enable out-of-band utilization + * reporting collection from the endpoints + * @param weightUpdatePeriod how often endpoint weights are recalculated + * @param errorUtilizationPenalty multiplier used to adjust endpoint weights with + * the error rate calculated as eps/qps + */ + public record WeightedRoundRobinLoadBalancingConfig(Duration blackoutPeriod, Duration weightExpirationPeriod, + Duration outOfBandReportingPeriod, Boolean enableOutOfBandLoadReport, Duration weightUpdatePeriod, + Float errorUtilizationPenalty) { + + Map grpcJavaConfig() { + // Aligned with WeightedRoundRobinLoadBalancerProvider + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::blackoutPeriod) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("blackoutPeriod")); + map.from(this::weightExpirationPeriod) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("weightExpirationPeriod")); + map.from(this::outOfBandReportingPeriod) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("oobReportingPeriod")); + map.from(this::enableOutOfBandLoadReport).to(grpcJavaConfig.in("enableOobLoadReport")); + map.from(this::weightUpdatePeriod) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("weightUpdatePeriod")); + map.from(this::errorUtilizationPenalty).to(grpcJavaConfig.in("errorUtilizationPenalty")); + return grpcJavaConfig.asMap(); + } + + } + + /** + * 'grpc' load balancing. + * + * @param child what load balancer policies to use for routing between the backend + * addresses + * @param serviceName override of the service name to be sent to the balancer + * @param initialFallbackTimeout timeout in seconds for receiving the server list + */ + public record GrpcLoadBalancingConfig(List child, String serviceName, + Duration initialFallbackTimeout) { + + public GrpcLoadBalancingConfig { + child.forEach(this::assertChild); + } + + private void assertChild(LoadBalancingConfig child) { + if (child.pickfirst() == null && child.roundrobin() == null) { + throw new InvalidConfigurationPropertyValueException("loadbalancing.grpc.child", null, + "Only 'pickfirst' or 'roundrobin' child load balancer strategies can be used"); + } + } + + Map grpcJavaConfig() { + // Aligned with GrpclbLoadBalancerProvider + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::child) + .as(listOf(LoadBalancingConfig::grpcJavaConfig)) + .to(grpcJavaConfig.in("childPolicy")); + map.from(this::serviceName).to(grpcJavaConfig.in("serviceName")); + map.from(this::initialFallbackTimeout) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("initialFallbackTimeout")); + return grpcJavaConfig.asMap(); + } + + } + + } + + /** + * Method configuration. + * + * @param name Names of the methods to which this configuration applies + * @param waitForReady Whether RPCs sent to this method should wait until the + * connection is ready by default + * @param maxRequestMessage maximum allowed payload size for an individual request or + * object in a stream + * @param maxResponseMessage maximum allowed payload size for an individual response + * or object in a stream + * @param timeout default timeout for RPCs sent to this method + * @param retry retry policy for outgoing RPCs + * @param hedging hedging policy for outgoing RPCs + */ + public record MethodConfig(List name, Boolean waitForReady, DataSize maxRequestMessage, + DataSize maxResponseMessage, Duration timeout, RetryPolicy retry, HedgingPolicy hedging) { + + public MethodConfig { + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("method.retry", retry); + entries.put("method.hedging", hedging); + }); + } + + static @Nullable List> grpcJavaConfigs(List methodConfigs) { + return (!CollectionUtils.isEmpty(methodConfigs)) + ? methodConfigs.stream().map(MethodConfig::grpcJavaConfig).toList() : null; + } + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::name).as(listOf(Name::grpcJavaConfig)).to(grpcJavaConfig.in("name")); + map.from(this::waitForReady).to(grpcJavaConfig.in("waitForReady")); + map.from(this::maxRequestMessage) + .as(ServiceConfig::bytesString) + .to(grpcJavaConfig.in("maxRequestMessageBytes")); + map.from(this::maxResponseMessage) + .as(ServiceConfig::bytesString) + .to(grpcJavaConfig.in("maxResponseMessageBytes")); + map.from(this::timeout).as(ServiceConfig::durationString).to(grpcJavaConfig.in("timeout")); + map.from(this::retry).as(RetryPolicy::grpcJavaConfig).to(grpcJavaConfig.in("retryPolicy")); + map.from(this::hedging).as(HedgingPolicy::grpcJavaConfig).to(grpcJavaConfig.in("hedgingPolicy")); + return grpcJavaConfig.asMap(); + } + + /** + * The name of a gRPC method. + * + * @param service service name + * @param method method name + */ + public record Name(String service, String method) { + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::service).to(grpcJavaConfig.in("service")); + map.from(this::method).to(grpcJavaConfig.in("method")); + return grpcJavaConfig.asMap(); + } + + } + + /** + * Retry policy for outgoing RPCs. + * + * @param maxAttempts maximum number of RPC attempts, including the original + * attempt + * @param initialBackoff initial exponential backoff + * @param maxBackoff maximum exponential backoff + * @param backoffMultiplier exponential backoff multiplier + * @param perAttemptReceiveTimeout per-attempt receive timeout + * @param retryableStatusCodes status codes which may be retried + */ + public record RetryPolicy(Integer maxAttempts, Duration initialBackoff, Duration maxBackoff, + Double backoffMultiplier, Duration perAttemptReceiveTimeout, Set retryableStatusCodes) { + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::maxAttempts).as(Objects::toString).to(grpcJavaConfig.in("maxAttempts")); + map.from(this::initialBackoff) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("initialBackoff")); + map.from(this::maxBackoff).as(ServiceConfig::durationString).to(grpcJavaConfig.in("maxBackoff")); + map.from(this::backoffMultiplier).to(grpcJavaConfig.in("backoffMultiplier")); + map.from(this::perAttemptReceiveTimeout) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("perAttemptRecvTimeout")); + map.from(this::retryableStatusCodes) + .as((codes) -> codes.stream().map(Objects::toString).toList()) + .to(grpcJavaConfig.in("retryableStatusCodes")); + return grpcJavaConfig.asMap(); + } + + } + + /** + * Hedging policy for outgoing RPCs. + * + * @param maxAttempts maximum number of send attempts + * @param delay delay for subsequent RPCs + * @param nonFatalStatusCodes status codes which indicate other hedged RPCs may + * still succeed + */ + public record HedgingPolicy(Integer maxAttempts, Duration delay, Set nonFatalStatusCodes) { + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::maxAttempts).as(Objects::toString).to(grpcJavaConfig.in("maxAttempts")); + map.from(this::delay).as(ServiceConfig::durationString).to(grpcJavaConfig.in("hedgingDelay")); + map.from(this::nonFatalStatusCodes) + .as((codes) -> codes.stream().map(Objects::toString).toList()) + .to(grpcJavaConfig.in("nonFatalStatusCodes")); + return grpcJavaConfig.asMap(); + } + + } + } + + /** + * Retry throttling policy. + * + * @param maxTokens maximum number of tokens + * @param tokenRatio the token ratio + */ + public record RetryThrottlingPolicy(Float maxTokens, Float tokenRatio) { + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::maxTokens).as(Objects::toString).to(grpcJavaConfig.in("maxTokens")); + map.from(this::tokenRatio).as(Objects::toString).to(grpcJavaConfig.in("tokenRatio")); + return grpcJavaConfig.asMap(); + } + + } + + /** + * Health check configuration. + * + * @param serviceName service name to use in the health-checking request. + */ + public record HealthCheckConfig(String serviceName) { + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::serviceName).to(grpcJavaConfig.in(HEALTH_CHECK_SERVICE_NAME_KEY)); + return grpcJavaConfig.asMap(); + } + + } + + /** + * Internal helper to collection gRPC java config. + * + * @param asMap the underling data as a map + */ + record GrpcJavaConfig(Map asMap) { + + GrpcJavaConfig() { + this(new LinkedHashMap<>()); + } + + Consumer in(String key) { + return (value) -> this.asMap.put(key, value); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java index d49b4bfc585..ac59391bca2 100644 --- a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java @@ -29,9 +29,11 @@ import io.grpc.netty.NettyChannelBuilder; import org.junit.jupiter.api.Test; import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.grpc.client.autoconfigure.ServiceConfig.HealthCheckConfig; import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -169,6 +171,38 @@ class GrpcChannelBuilderCustomizersTests { then(builder).should().defaultServiceConfig(expected); } + @Test + void applyWhenHasServiceConfig() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + ServiceConfig serviceConfig = new ServiceConfig(null, null, null, new HealthCheckConfig("test")); + channel.setServiceConfig(serviceConfig); + properties.getChannel().put("default", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + Map expected = new LinkedHashMap<>(); + expected.put("healthCheckConfig", Map.of("serviceName", "test")); + then(builder).should().defaultServiceConfig(expected); + } + + @Test + void applyWhenHasClashingServiceConfigAndHealth() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.getHealth().setEnabled(true); + channel.getHealth().setServiceName("fromhealth"); + ServiceConfig serviceConfig = new ServiceConfig(null, null, null, new HealthCheckConfig("fromservice")); + channel.setServiceConfig(serviceConfig); + properties.getChannel().put("default", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + assertThatIllegalStateException().isThrownBy(() -> customizers.apply("target", builder)) + .withMessage("Unable to change health check config service name from 'fromservice' to 'fromhealth'"); + } + @Test void applyWhenHealthEnabledAndNoServiceNameAddsHealthConfig() { GrpcClientProperties properties = new GrpcClientProperties(); diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfigTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfigTests.java new file mode 100644 index 00000000000..f7b4e56174e --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfigTests.java @@ -0,0 +1,393 @@ +/* + * 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.boot.grpc.client.autoconfigure; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.grpc.LoadBalancerRegistry; +import io.grpc.NameResolver.ConfigOrError; +import io.grpc.Status.Code; +import io.grpc.internal.AutoConfiguredLoadBalancerFactory; +import io.grpc.internal.ScParser; +import io.grpc.internal.ServiceConfigUtil; +import io.grpc.internal.ServiceConfigUtil.LbConfig; +import io.grpc.internal.ServiceConfigUtil.PolicySelection; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.boot.testsupport.classpath.resources.WithResource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link ServiceConfig}. + * + * @author Phillip Webb + */ +class ServiceConfigTests { + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - pickfirst: {} + """) + void pickFirstLoadBalancing() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("pick_first"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("PickFirstLoadBalancer"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("shuffleAddressList").isNull(); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - pickfirst: + shuffle-address-list: true + """) + void pickFirstLoadBalancingWithProperties() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("pick_first"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("PickFirstLoadBalancer"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("shuffleAddressList").isEqualTo(Boolean.TRUE); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - roundrobin: {} + """) + void roundRobinLoadBalancing() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("round_robin"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("policy=round_robin") + .contains("no service config"); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - weightedroundrobin: {} + """) + void weightedRoundRobinLoadBalancing() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("weighted_round_robin"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("WeightedRoundRobinLoadBalancerProvider"); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - weightedroundrobin: + blackout-period: 1m + weight-expiration-period: 500ms + out-of-band-reporting-period: 1s + enable-out-of-band-load-report: true + weight-update-period: 2s + error-utilization-penalty: 0.5 + """) + void weightedRoundRobinLoadBalancingWithProperties() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("weighted_round_robin"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("WeightedRoundRobinLoadBalancerProvider"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("blackoutPeriodNanos") + .isEqualTo(Duration.ofMinutes(1).toNanos()); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("weightExpirationPeriodNanos") + .isEqualTo(Duration.ofMillis(500).toNanos()); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("enableOobLoadReport").isEqualTo(true); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("oobReportingPeriodNanos") + .isEqualTo(Duration.ofSeconds(1).toNanos()); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("weightUpdatePeriodNanos") + .isEqualTo(Duration.ofSeconds(2).toNanos()); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("errorUtilizationPenalty").isEqualTo(0.5f); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - grpc: + child: + - roundrobin: {} + - pickfirst: {} + service-name: test + initial-fallback-timeout: 10s + """) + void grpcLoadBalancingWithProperties() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("grpclb"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("GrpclbLoadBalancerProvider"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("mode").hasToString("ROUND_ROBIN"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("serviceName").isEqualTo("test"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("fallbackTimeoutMs") + .isEqualTo(Duration.ofSeconds(10).toMillis()); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - pickfirst: {} + - weightedroundrobin: {} + """) + void multipleLoadBalancerPolicies() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(2); + assertThat(loadBalancingConfigs.get(0)).containsKey("pick_first"); + assertThat(loadBalancingConfigs.get(1)).containsKey("weighted_round_robin"); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - pickfirst: {} + weightedroundrobin: {} + """) + void whenMultileLoadBalancingPoliciesInListItemThrowsException() { + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindAndGetAsMap()) + .havingRootCause() + .isInstanceOf(MutuallyExclusiveConfigurationPropertiesException.class); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - {} + """) + void whenNoLoadBalancingPoliciesInListItemThrowsException() { + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindAndGetAsMap()) + .havingRootCause() + .isInstanceOf(InvalidConfigurationPropertyValueException.class); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + method: + - name: + - service: s-one + method: m-one + - service: s-two + method: m-two + wait-for-ready: true + max-request-message: 10KB + max-response-message: 20KB + timeout: 30s + """) + @SuppressWarnings("unchecked") + void methodConfig() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("methodConfig"); + Map serviceMethodMap = getServiceMethodMap(map, false); + assertThat(serviceMethodMap).containsOnlyKeys("s-one/m-one", "s-two/m-two"); + Object methodInfo = serviceMethodMap.get("s-one/m-one"); + assertThat(methodInfo).extracting("timeoutNanos").isEqualTo(Duration.ofSeconds(30).toNanos()); + assertThat(methodInfo).extracting("waitForReady").isEqualTo(Boolean.TRUE); + assertThat(methodInfo).extracting("maxOutboundMessageSize").isEqualTo(10240); + assertThat(methodInfo).extracting("maxInboundMessageSize").isEqualTo(20480); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + method: + - name: + - service: s-one + method: m-one + retry: + max-attempts: 2 + initial-backoff: 1m + max-backoff: 1h + backoff-multiplier: 2.5 + per-attempt-receive-timeout: 2s + retryable-status-codes: + - cancelled + - already-exists + """) + void methodConfigRetryPolicy() throws Exception { + Map map = bindAndGetAsMap(); + Map serviceMethodMap = getServiceMethodMap(map, true); + Object methodInfo = serviceMethodMap.get("s-one/m-one"); + assertThat(methodInfo).extracting("retryPolicy.maxAttempts").isEqualTo(2); + assertThat(methodInfo).extracting("retryPolicy.initialBackoffNanos").isEqualTo(Duration.ofMinutes(1).toNanos()); + assertThat(methodInfo).extracting("retryPolicy.maxBackoffNanos").isEqualTo(Duration.ofHours(1).toNanos()); + assertThat(methodInfo).extracting("retryPolicy.backoffMultiplier").isEqualTo(2.5); + assertThat(methodInfo).extracting("retryPolicy.perAttemptRecvTimeoutNanos") + .isEqualTo(Duration.ofSeconds(2).toNanos()); + assertThat(methodInfo).extracting("retryPolicy.retryableStatusCodes") + .asInstanceOf(InstanceOfAssertFactories.SET) + .containsExactlyInAnyOrder(Code.CANCELLED, Code.ALREADY_EXISTS); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + method: + - name: + - service: s-one + method: m-one + hedging: + max-attempts: 4 + delay: 6s + non-fatal-status-codes: + - invalid-argument + - deadline-exceeded + """) + void methodConfigHedgingPolicy() throws Exception { + Map map = bindAndGetAsMap(); + Map serviceMethodMap = getServiceMethodMap(map, true); + Object methodInfo = serviceMethodMap.get("s-one/m-one"); + assertThat(methodInfo).extracting("hedgingPolicy.maxAttempts").isEqualTo(4); + assertThat(methodInfo).extracting("hedgingPolicy.hedgingDelayNanos").isEqualTo(Duration.ofSeconds(6).toNanos()); + assertThat(methodInfo).extracting("hedgingPolicy.nonFatalStatusCodes") + .asInstanceOf(InstanceOfAssertFactories.SET) + .containsExactlyInAnyOrder(Code.INVALID_ARGUMENT, Code.DEADLINE_EXCEEDED); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + method: + - name: + - service: s-one + method: m-one + retry: {} + hedging: {} + """) + void whenMultiplePoliciesInMethodConfigThrowsException() { + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindAndGetAsMap()) + .havingRootCause() + .isInstanceOf(MutuallyExclusiveConfigurationPropertiesException.class); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + retrythrottling: + max-tokens: 2.5 + token-ratio: 1.5 + """) + void retryThrottling() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("retryThrottling"); + Object throttle = ReflectionTestUtils.invokeMethod(ServiceConfigUtil.class, "getThrottlePolicy", map); + assertThat(throttle).extracting("maxTokens").isEqualTo(2500); + assertThat(throttle).extracting("tokenRatio").isEqualTo(1500); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + healthcheck: + service-name: test + """) + @SuppressWarnings("unchecked") + void healthCheck() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("healthCheckConfig"); + Map healthCheckedService = (Map) ServiceConfigUtil.getHealthCheckedService(map); + assertThat(healthCheckedService).hasSize(1).containsEntry("serviceName", "test"); + } + + private PolicySelection getLoadBalancingPolicySelection(List> rawConfigs) { + List unwrappedConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList(rawConfigs); + LoadBalancerRegistry registry = LoadBalancerRegistry.getDefaultRegistry(); + ConfigOrError selected = ServiceConfigUtil.selectLbPolicyFromList(unwrappedConfigs, registry); + assertThat(selected).isNotNull(); + PolicySelection policySelection = (PolicySelection) selected.getConfig(); + if (policySelection == null) { + System.err.println(selected); + System.err.println(selected.getError()); + if (selected.getError() != null && selected.getError().asException() != null) { + selected.getError().asException().printStackTrace(); + } + } + assertThat(policySelection).isNotNull(); + return policySelection; + } + + @SuppressWarnings("unchecked") + private Map getServiceMethodMap(Map map, boolean retryEnabled) { + ScParser scParser = new ScParser(retryEnabled, 100, 100, new AutoConfiguredLoadBalancerFactory("pick_first")); + Object config = scParser.parseServiceConfig(map).getConfig(); + assertThat(config).isNotNull(); + Object serviceMethodMap = ReflectionTestUtils.getField(config, "serviceMethodMap"); + assertThat(serviceMethodMap).isNotNull(); + return (Map) serviceMethodMap; + } + + private Map bindAndGetAsMap() throws Exception { + Map map = new LinkedHashMap<>(); + bind().applyTo(map); + return map; + } + + private ServiceConfig bind() throws Exception { + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + PropertySource propertySource = loader.load("config.yaml", new ClassPathResource("config.yaml")).get(0); + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources().addLast(propertySource); + Binder binder = Binder.get(environment); + return binder.bind("config", ServiceConfig.class).get(); + } + +}