Browse Source

Add type-safe gRPC service config properties

Update `GrpcClientProperties` to include type-safe service config
properties commonly configured items.

Closes gh-49540
pull/46608/head
Phillip Webb 5 days ago
parent
commit
f75fea3601
  1. 1
      module/spring-boot-grpc-client/build.gradle
  2. 13
      module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java
  3. 41
      module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java
  4. 430
      module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfig.java
  5. 34
      module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java
  6. 393
      module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfigTests.java

1
module/spring-boot-grpc-client/build.gradle

@ -30,6 +30,7 @@ dependencies { @@ -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")

13
module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java

@ -70,6 +70,11 @@ public class GrpcClientProperties { @@ -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 { @@ -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;
}

41
module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java

@ -16,9 +16,15 @@ @@ -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( @@ -34,11 +40,38 @@ record PropertiesGrpcClientDefaultServiceConfigCustomizer(
public void customize(String target, Map<String, Object> 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<String, String> 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<String, Object> defaultServiceConfig) {
if (serviceConfig != null) {
serviceConfig.applyTo(defaultServiceConfig);
}
}
private void applyHealth(Health health, Map<String, Object> defaultServiceConfig) {
if (!health.isEnabled()) {
return;
}
String serviceName = (health.getServiceName() != null) ? health.getServiceName() : "";
Map<String, Object> 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<String, Object> cloneOrCreateHealthCheckConfig(Map<String, Object> defaultServiceConfig) {
Map<String, Object> healthCheckConfig = (Map<String, Object>) defaultServiceConfig
.get(ServiceConfig.HEALTH_CHECK_CONFIG_KEY);
return new LinkedHashMap<>((healthCheckConfig != null) ? healthCheckConfig : Collections.emptyMap());
}
}

430
module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfig.java

@ -0,0 +1,430 @@ @@ -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}.
* <p>
* The configuration provided here is a subset of the canonical <a href=
* "https://github.com/grpc/grpc-proto/blob/master/grpc/service_config/service_config.proto">service_config.proto</a>
* 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<LoadBalancingConfig> loadbalancing, @Nullable List<MethodConfig> 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<String, Object> 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 <T> Adapter<List<T>, @Nullable List<Map<String, Object>>> listOf(Function<T, Map<String, Object>> 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<String, Object> grpcJavaConfig() {
LinkedHashMap<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<LoadBalancingConfig> 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<String, Object> 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> 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<Map<String, Object>> grpcJavaConfigs(List<MethodConfig> methodConfigs) {
return (!CollectionUtils.isEmpty(methodConfigs))
? methodConfigs.stream().map(MethodConfig::grpcJavaConfig).toList() : null;
}
Map<String, Object> 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<String, Object> 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<Status.Code> retryableStatusCodes) {
Map<String, Object> 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<Status.Code> nonFatalStatusCodes) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> asMap) {
GrpcJavaConfig() {
this(new LinkedHashMap<>());
}
<T> Consumer<T> in(String key) {
return (value) -> this.asMap.put(key, value);
}
}
}

34
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; @@ -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 { @@ -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<String, Object> 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();

393
module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfigTests.java

@ -0,0 +1,393 @@ @@ -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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("methodConfig");
Map<String, ?> 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<String, Object> map = bindAndGetAsMap();
Map<String, ?> 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<String, Object> map = bindAndGetAsMap();
Map<String, ?> 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<String, Object> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("healthCheckConfig");
Map<String, Object> healthCheckedService = (Map<String, Object>) ServiceConfigUtil.getHealthCheckedService(map);
assertThat(healthCheckedService).hasSize(1).containsEntry("serviceName", "test");
}
private PolicySelection getLoadBalancingPolicySelection(List<Map<String, ?>> rawConfigs) {
List<LbConfig> 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<String, ?> getServiceMethodMap(Map<String, Object> 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<String, ?>) serviceMethodMap;
}
private Map<String, Object> bindAndGetAsMap() throws Exception {
Map<String, Object> 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();
}
}
Loading…
Cancel
Save