diff --git a/samples/grpc-server-netty-shaded/src/test/java/com/example/demo/DemoApplicationTests.java b/samples/grpc-server-netty-shaded/src/test/java/com/example/demo/DemoApplicationTests.java index f332422..0fc9b8c 100644 --- a/samples/grpc-server-netty-shaded/src/test/java/com/example/demo/DemoApplicationTests.java +++ b/samples/grpc-server-netty-shaded/src/test/java/com/example/demo/DemoApplicationTests.java @@ -5,12 +5,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.junit.jupiter.api.Test; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Lazy; +import org.springframework.grpc.client.ChannelBuilderOptions; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.grpc.test.LocalGrpcPort; import org.springframework.test.annotation.DirtiesContext; @@ -51,7 +53,8 @@ public class DemoApplicationTests { @Bean @Lazy SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channels, @LocalGrpcPort int port) { - return SimpleGrpc.newBlockingStub(channels.createChannel("0.0.0.0:" + port).build()); + return SimpleGrpc + .newBlockingStub(channels.createChannel("0.0.0.0:" + port, ChannelBuilderOptions.defaults())); } } diff --git a/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java index 471826c..675a3fe 100644 --- a/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java +++ b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java @@ -5,12 +5,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.junit.jupiter.api.Test; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Lazy; +import org.springframework.grpc.client.ChannelBuilderOptions; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.grpc.sample.proto.HelloReply; import org.springframework.grpc.sample.proto.HelloRequest; @@ -50,7 +52,8 @@ public class GrpcServerApplicationTests { @Bean @Lazy SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channels, @LocalGrpcPort int port) { - return SimpleGrpc.newBlockingStub(channels.createChannel("0.0.0.0:" + port).build()); + return SimpleGrpc + .newBlockingStub(channels.createChannel("0.0.0.0:" + port, ChannelBuilderOptions.defaults())); } } diff --git a/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java index a851ca3..8e2fff9 100644 --- a/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java +++ b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java @@ -32,6 +32,7 @@ import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.grpc.client.ChannelBuilderOptions; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.grpc.sample.proto.HelloReply; import org.springframework.grpc.sample.proto.HelloRequest; @@ -62,7 +63,7 @@ class GrpcServerHealthIntegrationTests { @Test void loadBalancerRespectsServerHealth(@Autowired GrpcChannelFactory channels, @Autowired HealthStatusManager healthStatusManager) { - ManagedChannel channel = channels.createChannel("health-test").build(); + ManagedChannel channel = channels.createChannel("health-test", ChannelBuilderOptions.defaults()); SimpleGrpc.SimpleBlockingStub client = SimpleGrpc.newBlockingStub(channel); // put the service up (SERVING) and give load balancer time to update @@ -117,7 +118,7 @@ class GrpcServerHealthIntegrationTests { @Test void healthIndicatorsAdaptedToGrpcHealthStatus(@Autowired GrpcChannelFactory channels) { - var channel = channels.createChannel("0.0.0.0:0").build(); + var channel = channels.createChannel("0.0.0.0:0", ChannelBuilderOptions.defaults()); var healthStub = HealthGrpc.newBlockingStub(channel); var serviceName = "custom"; diff --git a/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerIntegrationTests.java b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerIntegrationTests.java index e702128..9475087 100644 --- a/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerIntegrationTests.java +++ b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerIntegrationTests.java @@ -23,9 +23,11 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledOnOs; import org.junit.jupiter.api.condition.OS; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.grpc.autoconfigure.server.GrpcServerProperties; +import org.springframework.grpc.client.ChannelBuilderOptions; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.grpc.sample.proto.HelloReply; import org.springframework.grpc.sample.proto.HelloRequest; @@ -36,8 +38,9 @@ import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import io.grpc.ManagedChannel; -import io.grpc.StatusRuntimeException; import io.grpc.Status.Code; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.NettyChannelBuilder; /** * More detailed integration tests for {@link GrpcServerFactory gRPC server factories} and @@ -51,7 +54,7 @@ class GrpcServerIntegrationTests { @Test void servesResponseToClient(@Autowired GrpcChannelFactory channels) { - assertThatResponseIsServedToChannel(channels.createChannel("0.0.0.0:0").build()); + assertThatResponseIsServedToChannel(channels.createChannel("0.0.0.0:0", ChannelBuilderOptions.defaults())); } } @@ -63,7 +66,7 @@ class GrpcServerIntegrationTests { @Test void specificErrorResponse(@Autowired GrpcChannelFactory channels) { SimpleGrpc.SimpleBlockingStub client = SimpleGrpc - .newBlockingStub(channels.createChannel("0.0.0.0:0").build()); + .newBlockingStub(channels.createChannel("0.0.0.0:0", ChannelBuilderOptions.defaults())); assertThat(assertThrows(StatusRuntimeException.class, () -> client.sayHello(HelloRequest.newBuilder().setName("error").build())) .getStatus() @@ -73,7 +76,7 @@ class GrpcServerIntegrationTests { @Test void defaultErrorResponseIsUnknown(@Autowired GrpcChannelFactory channels) { SimpleGrpc.SimpleBlockingStub client = SimpleGrpc - .newBlockingStub(channels.createChannel("0.0.0.0:0").build()); + .newBlockingStub(channels.createChannel("0.0.0.0:0", ChannelBuilderOptions.defaults())); assertThat(assertThrows(StatusRuntimeException.class, () -> client.sayHello(HelloRequest.newBuilder().setName("internal").build())) .getStatus() @@ -89,7 +92,7 @@ class GrpcServerIntegrationTests { @Test void specificErrorResponse(@Autowired GrpcChannelFactory channels) { SimpleGrpc.SimpleBlockingStub client = SimpleGrpc - .newBlockingStub(channels.createChannel("0.0.0.0:0").build()); + .newBlockingStub(channels.createChannel("0.0.0.0:0", ChannelBuilderOptions.defaults())); assertThat(assertThrows(StatusRuntimeException.class, () -> client.sayHello(HelloRequest.newBuilder().setName("error").build())) .getStatus() @@ -99,7 +102,7 @@ class GrpcServerIntegrationTests { @Test void defaultErrorResponseIsUnknown(@Autowired GrpcChannelFactory channels) { SimpleGrpc.SimpleBlockingStub client = SimpleGrpc - .newBlockingStub(channels.createChannel("0.0.0.0:0").build()); + .newBlockingStub(channels.createChannel("0.0.0.0:0", ChannelBuilderOptions.defaults())); assertThat(assertThrows(StatusRuntimeException.class, () -> client.sayHello(HelloRequest.newBuilder().setName("internal").build())) .getStatus() @@ -116,7 +119,8 @@ class GrpcServerIntegrationTests { @Test void servesResponseToClientWithAnyIPv4AddressAndRandomPort(@Autowired GrpcChannelFactory channels, @LocalGrpcPort int port) { - assertThatResponseIsServedToChannel(channels.createChannel("0.0.0.0:" + port).build()); + assertThatResponseIsServedToChannel( + channels.createChannel("0.0.0.0:" + port, ChannelBuilderOptions.defaults())); } } @@ -129,7 +133,8 @@ class GrpcServerIntegrationTests { @Test void servesResponseToClientWithAnyIPv4AddressAndRandomPort(@Autowired GrpcChannelFactory channels, @LocalGrpcPort int port) { - assertThatResponseIsServedToChannel(channels.createChannel("0.0.0.0:" + port).build()); + assertThatResponseIsServedToChannel( + channels.createChannel("0.0.0.0:" + port, ChannelBuilderOptions.defaults())); } } @@ -142,7 +147,8 @@ class GrpcServerIntegrationTests { @Test void servesResponseToClientWithLocalhostAndRandomPort(@Autowired GrpcChannelFactory channels, @LocalGrpcPort int port) { - assertThatResponseIsServedToChannel(channels.createChannel("127.0.0.1:" + port).build()); + assertThatResponseIsServedToChannel( + channels.createChannel("127.0.0.1:" + port, ChannelBuilderOptions.defaults())); } } @@ -156,7 +162,8 @@ class GrpcServerIntegrationTests { @Test void servesResponseToClientWithConfiguredChannel(@Autowired GrpcChannelFactory channels) { - assertThatResponseIsServedToChannel(channels.createChannel("test-channel").build()); + assertThatResponseIsServedToChannel( + channels.createChannel("test-channel", ChannelBuilderOptions.defaults())); } } @@ -169,7 +176,8 @@ class GrpcServerIntegrationTests { @Test void clientChannelWithUnixDomain(@Autowired GrpcChannelFactory channels) { - assertThatResponseIsServedToChannel(channels.createChannel("unix:unix-test-channel").build()); + assertThatResponseIsServedToChannel(channels.createChannel("unix:unix-test-channel", + ChannelBuilderOptions.defaults().withCustomizer((__, b) -> b.usePlaintext()))); } } @@ -185,7 +193,8 @@ class GrpcServerIntegrationTests { @Test void clientChannelWithSsl(@Autowired GrpcChannelFactory channels) { - assertThatResponseIsServedToChannel(channels.createChannel("test-channel").build()); + assertThatResponseIsServedToChannel( + channels.createChannel("test-channel", ChannelBuilderOptions.defaults())); } } @@ -203,7 +212,8 @@ class GrpcServerIntegrationTests { @Test void clientChannelWithSsl(@Autowired GrpcChannelFactory channels) { - assertThatResponseIsServedToChannel(channels.createChannel("test-channel").build()); + assertThatResponseIsServedToChannel( + channels.createChannel("test-channel", ChannelBuilderOptions.defaults())); } } diff --git a/samples/grpc-tomcat/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java b/samples/grpc-tomcat/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java index fd747cb..eec7986 100644 --- a/samples/grpc-tomcat/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java +++ b/samples/grpc-tomcat/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.junit.jupiter.api.Test; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.test.context.SpringBootTest; @@ -12,6 +13,7 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Lazy; +import org.springframework.grpc.client.ChannelBuilderOptions; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.grpc.sample.proto.HelloReply; import org.springframework.grpc.sample.proto.HelloRequest; @@ -49,7 +51,8 @@ public class GrpcServerApplicationTests { @Bean @Lazy SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channels, @LocalServerPort int port) { - return SimpleGrpc.newBlockingStub(channels.createChannel("0.0.0.0:" + port).build()); + return SimpleGrpc + .newBlockingStub(channels.createChannel("0.0.0.0:" + port, ChannelBuilderOptions.defaults())); } } diff --git a/samples/grpc-tomcat/src/test/java/org/springframework/grpc/sample/ListenOnTwoPortsTests.java b/samples/grpc-tomcat/src/test/java/org/springframework/grpc/sample/ListenOnTwoPortsTests.java index 97dabdb..9a05a41 100644 --- a/samples/grpc-tomcat/src/test/java/org/springframework/grpc/sample/ListenOnTwoPortsTests.java +++ b/samples/grpc-tomcat/src/test/java/org/springframework/grpc/sample/ListenOnTwoPortsTests.java @@ -11,6 +11,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Lazy; +import org.springframework.grpc.client.ChannelBuilderOptions; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.grpc.sample.proto.HelloReply; import org.springframework.grpc.sample.proto.HelloRequest; @@ -51,7 +52,8 @@ public class ListenOnTwoPortsTests { @Bean @Lazy SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channels, @LocalGrpcPort int port) { - return SimpleGrpc.newBlockingStub(channels.createChannel("0.0.0.0:" + port).build()); + return SimpleGrpc + .newBlockingStub(channels.createChannel("0.0.0.0:" + port, ChannelBuilderOptions.defaults())); } } diff --git a/samples/grpc-webflux/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java b/samples/grpc-webflux/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java index cc6032c..aec62c8 100644 --- a/samples/grpc-webflux/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java +++ b/samples/grpc-webflux/src/test/java/org/springframework/grpc/sample/GrpcServerApplicationTests.java @@ -12,6 +12,7 @@ import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Lazy; +import org.springframework.grpc.client.ChannelBuilderOptions; import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.grpc.sample.proto.HelloReply; import org.springframework.grpc.sample.proto.HelloRequest; @@ -51,7 +52,8 @@ public class GrpcServerApplicationTests { @Bean @Lazy SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channels, @LocalGrpcPort int port) { - return SimpleGrpc.newBlockingStub(channels.createChannel("0.0.0.0:" + port).build()); + return SimpleGrpc + .newBlockingStub(channels.createChannel("0.0.0.0:" + port, ChannelBuilderOptions.defaults())); } } diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/client/ChannelBuilderOptions.java b/spring-grpc-core/src/main/java/org/springframework/grpc/client/ChannelBuilderOptions.java new file mode 100644 index 0000000..a17e5e0 --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/client/ChannelBuilderOptions.java @@ -0,0 +1,151 @@ +/* + * Copyright 2023-2024 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.grpc.client; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; + +import org.springframework.util.Assert; + +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannelBuilder; + +/** + * Options used by {@link GrpcChannelFactory} when building channels. + *

+ * Provides functionality beyond what is available with the native channel builders (e.g. + * {@code shutdownGracePeriod}) and overrides some native channel builder behavior (e.g. + * {@code interceptors}. + * + * @author Chris Bono + */ +public final class ChannelBuilderOptions { + + private final List interceptors; + + private final boolean mergeWithGlobalInterceptors; + + private final Duration shutdownGracePeriod; + + @SuppressWarnings("rawtypes") + private final GrpcChannelBuilderCustomizer customizer; + + @SuppressWarnings("rawtypes") + private ChannelBuilderOptions(List interceptors, boolean mergeWithGlobalInterceptors, + Duration shutdownGracePeriod, GrpcChannelBuilderCustomizer customizer) { + this.interceptors = Collections.unmodifiableList(interceptors); + this.mergeWithGlobalInterceptors = mergeWithGlobalInterceptors; + this.shutdownGracePeriod = shutdownGracePeriod; + this.customizer = customizer; + } + + /** + * Gets the client interceptors to apply to the channel. + * @return the client interceptors to apply to the channel + */ + public List interceptors() { + return this.interceptors; + } + + /** + * Gets whether the provided interceptors should be blended with the global + * interceptors. + * @return whether the provided interceptors should be blended with the global + * interceptors (default false) + */ + public boolean mergeWithGlobalInterceptors() { + return this.mergeWithGlobalInterceptors; + } + + /** + * Gets the time to wait for the channel to gracefully shutdown. + * @return the time to wait for the channel to gracefully shutdown (default of 30s) + */ + public Duration shutdownGracePeriod() { + return this.shutdownGracePeriod; + } + + /** + * Gets the customizer to apply to the builder used to create the channel. + * @param the type of the builder the customizer operates on + * @return the customizer to apply (default of + * {@link GrpcChannelBuilderCustomizer#defaults()}) + */ + @SuppressWarnings("unchecked") + public > GrpcChannelBuilderCustomizer customizer() { + return (GrpcChannelBuilderCustomizer) this.customizer; + } + + /** + * Gets a new immutable options instance populated with default values. + * @return a new immutable options instance populated with default values. + */ + public static ChannelBuilderOptions defaults() { + return new ChannelBuilderOptions(List.of(), false, Duration.ofSeconds(30), + GrpcChannelBuilderCustomizer.defaults()); + } + + /** + * Set the client interceptors to apply to the channel. + * @param interceptors list of client interceptors to apply to the channel or empty + * list to clear out any previously set interceptors + * @return a new immutable options instance populated with the specified interceptors + * and the settings of this current options instance. + */ + public ChannelBuilderOptions withInterceptors(List interceptors) { + Assert.notNull(interceptors, "interceptors must not be null"); + return new ChannelBuilderOptions(interceptors, this.mergeWithGlobalInterceptors, this.shutdownGracePeriod, + this.customizer); + } + + /** + * Set whether the provided interceptors should be blended with the global + * interceptors. + * @param mergeWithGlobalInterceptors whether the provided interceptors should be + * @return a new immutable options instance populated with the specified merge setting + * and the settings of this current options instance. + */ + public ChannelBuilderOptions withInterceptorsMerge(boolean mergeWithGlobalInterceptors) { + return new ChannelBuilderOptions(this.interceptors, mergeWithGlobalInterceptors, this.shutdownGracePeriod, + this.customizer); + } + + /** + * Set the time to wait for the channel to gracefully shutdown. + * @param shutdownGracePeriod the time to wait for the channel to gracefully shutdown + * @return a new immutable options instance populated with the specified + * {@code shutdownGracePeriod} and the settings of this current options instance. + */ + public ChannelBuilderOptions withShutdownGracePeriod(Duration shutdownGracePeriod) { + return new ChannelBuilderOptions(this.interceptors, this.mergeWithGlobalInterceptors, shutdownGracePeriod, + this.customizer); + } + + /** + * Set the customizer to apply to the builder used to create the channel. + * @param type of builder the customizer operates on + * @param customizer the customizer to apply to the builder used to create the channel + * @return a new immutable options instance populated with the specified + * {@code customizer} and the settings of this current options instance. + */ + public > ChannelBuilderOptions withCustomizer( + GrpcChannelBuilderCustomizer customizer) { + return new ChannelBuilderOptions(this.interceptors, this.mergeWithGlobalInterceptors, this.shutdownGracePeriod, + customizer); + } + +} diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/client/DefaultGrpcChannelFactory.java b/spring-grpc-core/src/main/java/org/springframework/grpc/client/DefaultGrpcChannelFactory.java index 6ed45b2..2909a7a 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/client/DefaultGrpcChannelFactory.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/client/DefaultGrpcChannelFactory.java @@ -12,21 +12,18 @@ * 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.grpc.client; +import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import org.springframework.beans.factory.DisposableBean; import org.springframework.util.Assert; import io.grpc.ChannelCredentials; -import io.grpc.ClientInterceptor; -import io.grpc.ForwardingChannelBuilder2; import io.grpc.Grpc; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; @@ -35,18 +32,18 @@ import io.grpc.ManagedChannelBuilder; * Default implementation of {@link GrpcChannelFactory} for creating and managing gRPC * channels. *

- * Implements {@link DisposableBean} to shut down channels when no longer needed + * Implements {@link DisposableBean} to shut down channels when no longer needed. * + * @param concrete type of channel builder used to create the channels * @author David Syer * @author Chris Bono */ -public class DefaultGrpcChannelFactory implements GrpcChannelFactory, DisposableBean { +public class DefaultGrpcChannelFactory> + implements GrpcChannelFactory, DisposableBean { - private final Map> builders = new ConcurrentHashMap<>(); + private final List channels = new ArrayList<>(); - private final Map channels = new ConcurrentHashMap<>(); - - private final List customizers = new ArrayList<>(); + private final List> globalCustomizers = new ArrayList<>(); private final ClientInterceptorsConfigurer interceptorsConfigurer; @@ -54,11 +51,17 @@ public class DefaultGrpcChannelFactory implements GrpcChannelFactory, Disposable private VirtualTargets targets = VirtualTargets.DEFAULT; - public DefaultGrpcChannelFactory(List customizers, + /** + * Construct a channel factory instance. + * @param globalCustomizers the global customizers to apply to all created channels + * @param interceptorsConfigurer configures the client interceptors on the created + * channels + */ + public DefaultGrpcChannelFactory(List> globalCustomizers, ClientInterceptorsConfigurer interceptorsConfigurer) { - Assert.notNull(customizers, () -> "customizers must not be null"); + Assert.notNull(globalCustomizers, () -> "customizers must not be null"); Assert.notNull(interceptorsConfigurer, () -> "interceptorsConfigurer must not be null"); - this.customizers.addAll(customizers); + this.globalCustomizers.addAll(globalCustomizers); this.interceptorsConfigurer = interceptorsConfigurer; } @@ -71,68 +74,48 @@ public class DefaultGrpcChannelFactory implements GrpcChannelFactory, Disposable } @Override - public ManagedChannelBuilder createChannel(String authority) { - return this.createChannel(authority, List.of(), false); - } - - @Override - public ManagedChannelBuilder createChannel(String authority, List interceptors, - boolean mergeWithGlobalInterceptors) { - Assert.notNull(interceptors, () -> "interceptors must not be null"); - return this.builders.computeIfAbsent(authority, path -> { - ManagedChannelBuilder builder = newChannelBuilder(this.targets.getTarget(path), - this.credentials.getChannelCredentials(path)); - this.interceptorsConfigurer.configureInterceptors(builder, interceptors, mergeWithGlobalInterceptors); - this.customizers.forEach((c) -> c.customize(path, builder)); - return new DisposableChannelBuilder(authority, builder); - }); + public ManagedChannel createChannel(String target, ChannelBuilderOptions options) { + var targetUri = this.targets.getTarget(target); + T builder = newChannelBuilder(targetUri, this.credentials.getChannelCredentials(target)); + // Handle interceptors + this.interceptorsConfigurer.configureInterceptors(builder, options.interceptors(), + options.mergeWithGlobalInterceptors()); + // Handle customizers + this.globalCustomizers.forEach((c) -> c.customize(target, builder)); + var customizer = options.customizer(); + if (customizer != null) { + customizer.customize(target, builder); + } + var channel = builder.build(); + var shutdownGracePeriod = options.shutdownGracePeriod(); + this.channels.add(new ManagedChannelWithShutdown(channel, shutdownGracePeriod)); + return channel; } /** - * Creates a new {@link ManagedChannelBuilder} instance for the given target path and - * credentials. - * @param path the target path for the channel - * @param creds the credentials for the channel - * @return a new {@link ManagedChannelBuilder} for the given path and credentials + * Creates a new {@link ManagedChannelBuilder} instance for the given target and + * credentials. The {@code target} is a valid nameresolver-compliant URI or an + * authority string as described in {@link Grpc#newChannelBuilder}. + * @param target the target of the channel + * @param credentials the credentials for the channel + * @return a new builder for the given target and credentials */ - protected ManagedChannelBuilder newChannelBuilder(String path, ChannelCredentials creds) { - return Grpc.newChannelBuilder(path, creds); - } - - private ManagedChannel buildAndRegisterChannel(String channelName, ManagedChannelBuilder channelBuilder) { - return this.channels.computeIfAbsent(channelName, (__) -> channelBuilder.build()); + @SuppressWarnings("unchecked") + protected T newChannelBuilder(String target, ChannelCredentials credentials) { + return (T) Grpc.newChannelBuilder(target, credentials); } @Override public void destroy() { - this.channels.values().forEach(ManagedChannel::shutdown); + this.channels.forEach((c) -> { + var shutdownGracePeriod = c.shutdownGracePeriod(); + var channel = c.channel(); + // TODO use grace period to do the magical shutdown here + channel.shutdown(); + }); } - /** - * A {@link ManagedChannelBuilder} wrapper that ensures the created channel is - * disposed of when no longer needed. - */ - class DisposableChannelBuilder extends ForwardingChannelBuilder2 { - - private final String authority; - - private final ManagedChannelBuilder delegate; - - DisposableChannelBuilder(String authority, ManagedChannelBuilder delegate) { - this.authority = authority; - this.delegate = delegate; - } - - @Override - protected ManagedChannelBuilder delegate() { - return this.delegate; - } - - @Override - public ManagedChannel build() { - return DefaultGrpcChannelFactory.this.buildAndRegisterChannel(this.authority, this.delegate); - } - + record ManagedChannelWithShutdown(ManagedChannel channel, Duration shutdownGracePeriod) { } } diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelBuilderCustomizer.java b/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelBuilderCustomizer.java index 3ac7c50..e3046e2 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelBuilderCustomizer.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelBuilderCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2023-2024 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. @@ -12,7 +12,7 @@ * 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.grpc.client; @@ -22,18 +22,29 @@ import io.grpc.ManagedChannelBuilder; * Callback interface that can be used to customize a {@link ManagedChannelBuilder} for a * specific authority. * + * @param type of builder * @author Dave Syer * @author Chris Bono */ @FunctionalInterface -public interface GrpcChannelBuilderCustomizer { +public interface GrpcChannelBuilderCustomizer> { /** - * Callback to customize a {@link ManagedChannelBuilder} instance for a specified - * authority. + * Callback to customize a {@link ManagedChannelBuilder channel builder} instance for + * a specific target authority. * @param authority the target authority for the channel * @param builder the builder to customize */ - void customize(String authority, ManagedChannelBuilder builder); + void customize(String authority, T builder); + + /** + * Used to indicate no customizations should be made to the builder. + * @param type of channel builder + * @return the default customizer + */ + static > GrpcChannelBuilderCustomizer defaults() { + return (__, ___) -> { + }; + } } diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelFactory.java b/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelFactory.java index 6c641c2..f2702d5 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelFactory.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/client/GrpcChannelFactory.java @@ -16,37 +16,48 @@ package org.springframework.grpc.client; -import java.util.List; - +import io.grpc.ChannelCredentials; import io.grpc.ClientInterceptor; -import io.grpc.ManagedChannelBuilder; +import io.grpc.Grpc; +import io.grpc.ManagedChannel; /** - * Factory interface for creating {@link ManagedChannelBuilder} instances for a given - * authority. + * Factory interface for creating {@link ManagedChannel} instances. * * @author Dave Syer - * @see ManagedChannelBuilder + * @author Chris Bono */ public interface GrpcChannelFactory { /** - * Creates a {@link ManagedChannelBuilder} for the given authority. - * @param authority the target authority for the channel - * @return a {@link ManagedChannelBuilder} configured for the given authority + * Creates a {@link ManagedChannel} for the given target string. The target can be + * either a valid nameresolver-compliant URI, an authority string as described in + * {@link Grpc#newChannelBuilder(String, ChannelCredentials)}, or a named channel + * which will return a builder that is based on a user-configured channel. + *

+ * The returned channel is configured to use all globally registered + * {@link ClientInterceptor interceptors}. + * @param target the target string as described in method javadocs + * @return a channel for the given target */ - ManagedChannelBuilder createChannel(String authority); + default ManagedChannel createChannel(String target) { + return this.createChannel(target, ChannelBuilderOptions.defaults()); + } /** - * Creates a {@link ManagedChannelBuilder} for the given authority and the provided - * client interceptors. - * @param authority the target authority for the channel - * @param interceptors the non-null list of interceptors to be applied to the channel - * @param mergeWithGlobalInterceptors whether the provided interceptors should be - * blended with the global interceptors. - * @return a channel builder conifgured with the provided values + * Creates a {@link ManagedChannel} for the given target string. The target can be + * either a valid nameresolver-compliant URI, an authority string as described in + * {@link Grpc#newChannelBuilder(String, ChannelCredentials)}, or a named channel + * which will return a builder that is based on a user-configured channel. + *

+ * The returned channel is configured to use all globally registered + * {@link ClientInterceptor interceptors} and any user-provided interceptor if + * specified in the {@link ChannelBuilderOptions#customizer() options}. + * @param target the target string as described in method javadocs + * @param options the options for building the created channel, or + * {@link ChannelBuilderOptions#defaults()} to use default options + * @return a channel for the given target */ - ManagedChannelBuilder createChannel(String authority, List interceptors, - boolean mergeWithGlobalInterceptors); + ManagedChannel createChannel(String target, ChannelBuilderOptions options); } diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/client/NettyGrpcChannelFactory.java b/spring-grpc-core/src/main/java/org/springframework/grpc/client/NettyGrpcChannelFactory.java new file mode 100644 index 0000000..6308317 --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/client/NettyGrpcChannelFactory.java @@ -0,0 +1,56 @@ +/* + * Copyright 2023-2024 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.grpc.client; + +import java.util.List; + +import io.grpc.ChannelCredentials; +import io.grpc.netty.NettyChannelBuilder; +import io.netty.channel.epoll.EpollDomainSocketChannel; +import io.netty.channel.epoll.EpollEventLoopGroup; +import io.netty.channel.unix.DomainSocketAddress; + +/** + * {@link GrpcChannelFactory} that creates Netty-based gRPC channels. + * + * @author Chris Bono + */ +public class NettyGrpcChannelFactory extends DefaultGrpcChannelFactory { + + /** + * Construct a channel factory instance. + * @param globalCustomizers the global customizers to apply to all created channels + * @param interceptorsConfigurer configures the client interceptors on the created + * channels + */ + public NettyGrpcChannelFactory(List> globalCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer) { + super(globalCustomizers, interceptorsConfigurer); + } + + @Override + protected NettyChannelBuilder newChannelBuilder(String target, ChannelCredentials credentials) { + if (target.startsWith("unix:")) { + target = target.substring(5); + return NettyChannelBuilder.forAddress(new DomainSocketAddress(target)) + .channelType(EpollDomainSocketChannel.class) + .eventLoopGroup(new EpollEventLoopGroup()); + } + return NettyChannelBuilder.forTarget(target, credentials); + } + +} diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/client/ShadedNettyGrpcChannelFactory.java b/spring-grpc-core/src/main/java/org/springframework/grpc/client/ShadedNettyGrpcChannelFactory.java new file mode 100644 index 0000000..094c943 --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/client/ShadedNettyGrpcChannelFactory.java @@ -0,0 +1,56 @@ +/* + * Copyright 2023-2024 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.grpc.client; + +import java.util.List; + +import io.grpc.ChannelCredentials; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.netty.channel.epoll.EpollDomainSocketChannel; +import io.grpc.netty.shaded.io.netty.channel.epoll.EpollEventLoopGroup; +import io.grpc.netty.shaded.io.netty.channel.unix.DomainSocketAddress; + +/** + * {@link GrpcChannelFactory} that creates shaded Netty-based gRPC channels. + * + * @author Chris Bono + */ +public class ShadedNettyGrpcChannelFactory extends DefaultGrpcChannelFactory { + + /** + * Construct a channel factory instance. + * @param globalCustomizers the global customizers to apply to all created channels + * @param interceptorsConfigurer configures the client interceptors on the created + * channels + */ + public ShadedNettyGrpcChannelFactory(List> globalCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer) { + super(globalCustomizers, interceptorsConfigurer); + } + + @Override + protected NettyChannelBuilder newChannelBuilder(String path, ChannelCredentials credentials) { + if (path.startsWith("unix:")) { + path = path.substring(5); + return NettyChannelBuilder.forAddress(new DomainSocketAddress(path)) + .channelType(EpollDomainSocketChannel.class) + .eventLoopGroup(new EpollEventLoopGroup()); + } + return NettyChannelBuilder.forTarget(path, credentials); + } + +} diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/client/ChannelBuilderOptionsTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/client/ChannelBuilderOptionsTests.java new file mode 100644 index 0000000..fb8c379 --- /dev/null +++ b/spring-grpc-core/src/test/java/org/springframework/grpc/client/ChannelBuilderOptionsTests.java @@ -0,0 +1,97 @@ +/* + * Copyright 2023-2024 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.grpc.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import io.grpc.ClientInterceptor; +import io.grpc.netty.NettyChannelBuilder; + +/** + * Tests for {@link ChannelBuilderOptions}. + */ +class ChannelBuilderOptionsTests { + + @Test + void defaultOptions() { + var options = ChannelBuilderOptions.defaults(); + assertThat(options.interceptors()).isEmpty(); + assertThat(options.mergeWithGlobalInterceptors()).isFalse(); + assertThat(options.shutdownGracePeriod()).isEqualTo(Duration.ofSeconds(30)); + assertThat(options.customizer()).isNotNull(); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + @Test + void userSpecifiedOptions() { + ClientInterceptor interceptor1 = mock(); + ClientInterceptor interceptor2 = mock(); + GrpcChannelBuilderCustomizer customizer = mock(); + var options = ChannelBuilderOptions.defaults() + .withInterceptors(List.of(interceptor1, interceptor2)) + .withInterceptorsMerge(true) + .withShutdownGracePeriod(Duration.ofMinutes(1)) + .withCustomizer(customizer); + assertThat(options.interceptors()).containsExactly(interceptor1, interceptor2); + assertThat(options.mergeWithGlobalInterceptors()).isTrue(); + assertThat(options.shutdownGracePeriod()).isEqualTo(Duration.ofMinutes(1)); + assertThat(options.customizer()).isSameAs(customizer); + } + + @Test + void defaultOptionsCustomizerDoesNotCustomizerBuilder() { + var customizer = ChannelBuilderOptions.defaults().customizer(); + var builder = mock(NettyChannelBuilder.class); + customizer.customize("localhost", builder); + verifyNoInteractions(builder); + } + + @SuppressWarnings("rawtypes") + @Nested + class WithCustomizerUsageTests { + + @Test + void rawBaseCustomizerLambda() { + var options = ChannelBuilderOptions.defaults().withCustomizer((__, b) -> b.userAgent("foo")); + assertThat(options.customizer()).isNotNull(); + } + + @Test + void wildcardBaseCustomizer() { + GrpcChannelBuilderCustomizer rawBaseCustomizer = (__, b) -> b.userAgent("foo"); + GrpcChannelBuilderCustomizer wildcardBaseCustomizer = (GrpcChannelBuilderCustomizer) rawBaseCustomizer; + var options = ChannelBuilderOptions.defaults().withCustomizer(wildcardBaseCustomizer); + assertThat(options.customizer()).isNotNull(); + } + + @Test + void specificBuilderCustomizerLambda() { + var options = ChannelBuilderOptions.defaults() + .withCustomizer((__, b) -> b.flowControlWindow(5).userAgent("foo")); + assertThat(options.customizer()).isNotNull(); + } + + } + +} diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/client/DefaultGrpcChannelFactoryTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/client/DefaultGrpcChannelFactoryTests.java deleted file mode 100644 index 349cae5..0000000 --- a/spring-grpc-core/src/test/java/org/springframework/grpc/client/DefaultGrpcChannelFactoryTests.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2023-2024 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.grpc.client; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -import java.util.List; - -import org.assertj.core.api.InstanceOfAssertFactories; -import org.junit.jupiter.api.Test; - -import org.springframework.grpc.client.DefaultGrpcChannelFactory.DisposableChannelBuilder; - -import io.grpc.ClientInterceptor; - -/** - * Tests for {@link DefaultGrpcChannelFactory}. - */ -class DefaultGrpcChannelFactoryTests { - - @Test - void createChannelWithoutClientSpecificInterceptorsInvokesInterceptorConfigurer() { - ClientInterceptorsConfigurer configurer = mock(); - var channelName = "testChannel"; - var channelFactory = new DefaultGrpcChannelFactory(List.of(), configurer); - channelFactory.createChannel(channelName); - // Get the actual builder that should be passed into the configurer - assertThat(channelFactory) - .extracting("builders", InstanceOfAssertFactories.map(String.class, DisposableChannelBuilder.class)) - .hasEntrySatisfying(channelName, - (builder) -> verify(configurer).configureInterceptors(builder.delegate(), List.of(), false)); - } - - @Test - void createChannelWithClientSpecificInterceptorsInvokesInterceptorConfigurer() { - ClientInterceptorsConfigurer configurer = mock(); - var channelName = "testChannel"; - var channelFactory = new DefaultGrpcChannelFactory(List.of(), configurer); - var interceptor = mock(ClientInterceptor.class); - var interceptors = List.of(interceptor); - channelFactory.createChannel(channelName, interceptors, true); - // Get the actual builder that should be passed into the configurer - assertThat(channelFactory) - .extracting("builders", InstanceOfAssertFactories.map(String.class, DisposableChannelBuilder.class)) - .hasEntrySatisfying(channelName, - (builder) -> verify(configurer).configureInterceptors(builder.delegate(), interceptors, true)); - } - -} diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/client/GrpcChannelFactoryTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/client/GrpcChannelFactoryTests.java new file mode 100644 index 0000000..9a2165e --- /dev/null +++ b/spring-grpc-core/src/test/java/org/springframework/grpc/client/GrpcChannelFactoryTests.java @@ -0,0 +1,155 @@ +/* + * Copyright 2023-2024 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.grpc.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.assertArg; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; + +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannelBuilder; +import io.grpc.netty.NettyChannelBuilder; + +/** + * Tests for the various {@link GrpcChannelFactory} implementations. + */ +@SuppressWarnings({ "unchecked", "rawtypes" }) +class GrpcChannelFactoryTests { + + @Nested + class CreateChannelApiWithCustomizers { + + @Test + void globalCustomizersInvokedInOrder() { + var channelName = "localhost"; + var customizer1 = mock(GrpcChannelBuilderCustomizer.class); + var customizer2 = mock(GrpcChannelBuilderCustomizer.class); + var channelFactory = new DefaultGrpcChannelFactory(List.of(customizer1, customizer2), mock()); + channelFactory.setVirtualTargets(path -> path); + var channel = channelFactory.createChannel(channelName, ChannelBuilderOptions.defaults()); + assertThat(channel).isNotNull(); + var inOrder = inOrder(customizer1, customizer2); + inOrder.verify(customizer1).customize(anyString(), any(ManagedChannelBuilder.class)); + inOrder.verify(customizer2).customize(anyString(), any(ManagedChannelBuilder.class)); + } + + @Test + void whenOptionsContainCustomizerThenCustomizerInvoked() { + var channelName = "localhost"; + var customizer1 = mock(GrpcChannelBuilderCustomizer.class); + var channelFactory = new DefaultGrpcChannelFactory(List.of(), mock()); + channelFactory.setVirtualTargets(path -> path); + var channel = channelFactory.createChannel(channelName, + ChannelBuilderOptions.defaults().withCustomizer(customizer1)); + assertThat(channel).isNotNull(); + verify(customizer1).customize(anyString(), any(ManagedChannelBuilder.class)); + } + + @Test + void globalCustomizersInvokedBeforeSpecificCustomizer() { + var channelName = "localhost"; + var customizer1 = mock(GrpcChannelBuilderCustomizer.class); + var customizer2 = mock(GrpcChannelBuilderCustomizer.class); + var channelFactory = new DefaultGrpcChannelFactory(List.of(customizer2), mock()); + channelFactory.setVirtualTargets(path -> path); + var channel = channelFactory.createChannel(channelName, + ChannelBuilderOptions.defaults().withCustomizer(customizer1)); + assertThat(channel).isNotNull(); + var inOrder = inOrder(customizer1, customizer2); + inOrder.verify(customizer2).customize(anyString(), any(ManagedChannelBuilder.class)); + inOrder.verify(customizer1).customize(anyString(), any(ManagedChannelBuilder.class)); + } + + } + + @Nested + class CreateChannelApiWithInterceptors { + + @Test + void whenOptionsContainNoInterceptorThenConfigurerInvokedWithNoInterceptor() { + ClientInterceptorsConfigurer configurer = mock(); + var channelName = "localhost"; + var channelFactory = new DefaultGrpcChannelFactory(List.of(), configurer); + channelFactory.setVirtualTargets(path -> path); + var channel = channelFactory.createChannel(channelName, ChannelBuilderOptions.defaults()); + assertThat(channel).isNotNull(); + verify(configurer).configureInterceptors(any(ManagedChannelBuilder.class), + assertArg((interceptors) -> assertThat(interceptors).isEmpty()), eq(false)); + } + + @Test + void whenOptionsContainInterceptorThenConfigurerInvokedWithInterceptor() { + ClientInterceptorsConfigurer configurer = mock(); + var channelName = "localhost"; + var channelFactory = new DefaultGrpcChannelFactory(List.of(), configurer); + channelFactory.setVirtualTargets(path -> path); + var interceptor = mock(ClientInterceptor.class); + var channel = channelFactory.createChannel(channelName, + ChannelBuilderOptions.defaults() + .withInterceptors(List.of(interceptor)) + .withInterceptorsMerge(true)); + assertThat(channel).isNotNull(); + verify(configurer).configureInterceptors(any(ManagedChannelBuilder.class), + assertArg((interceptors) -> assertThat(interceptors).containsExactly(interceptor)), eq(true)); + } + + } + + @Nested + class SpecificGrpcChannelFactoryTests { + + @Test + void nettyChannelFactoryUsesNettyChannelBuilder() { + var channelName = "localhost"; + var customizer1 = mock(GrpcChannelBuilderCustomizer.class); + var channelFactory = new NettyGrpcChannelFactory(List.of(), mock()); + channelFactory.setVirtualTargets(path -> path); + var channel = channelFactory.createChannel(channelName, + ChannelBuilderOptions.defaults().withCustomizer(customizer1)); + assertThat(channel).isNotNull(); + verify(customizer1).customize(anyString(), ArgumentMatchers.assertArg((builder) -> { + assertThat(builder).isInstanceOf(NettyChannelBuilder.class); + })); + } + + @Test + void shadedNettyChannelFactoryUsesShadedNettyChannelBuilder() { + var channelName = "localhost"; + var customizer1 = mock(GrpcChannelBuilderCustomizer.class); + var channelFactory = new ShadedNettyGrpcChannelFactory(List.of(), mock()); + channelFactory.setVirtualTargets(path -> path); + var channel = channelFactory.createChannel(channelName, + ChannelBuilderOptions.defaults().withCustomizer(customizer1)); + assertThat(channel).isNotNull(); + verify(customizer1).customize(anyString(), ArgumentMatchers.assertArg((builder) -> { + assertThat(builder).isInstanceOf(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class); + })); + } + + } + +} diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/client.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/client.adoc index fae8be7..f6e2f69 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/client.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/client.adoc @@ -16,12 +16,10 @@ To bind to this service on a local server: ---- @Bean SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channels) { - return SimpleGrpc.newBlockingStub(channels.createChannel("0.0.0.0:9090").build()); + return SimpleGrpc.newBlockingStub(channels.createChannel("0.0.0.0:9090")); } ---- -The `GrpcChannelFactory` creates a `ChannelBuilder` that you can customize before building the channel if necessary. - === Shaded Netty Client The default client implementation uses the Netty client. @@ -61,7 +59,53 @@ dependencies { ---- == Channel Configuration +The channel factory provides an API to create channels. +The channel creation process can be configured as follows. +=== Channel Builder Customizer +The `ManagedChannelBuilder` used by the factory to create the channel can be customized prior to channel creation. + +==== Global +To customize the builder used for all created channels you can register one more `GrpcChannelBuilderCustomizer` beans. +The customizers are applied to the auto-configured `GrpcChannelFactory` in order according to their bean natural ordering (i.e. `@Order`). + +[source,java] +---- +@Bean +@Order(100) +GrpcChannelBuilderCustomizer flowControlCustomizer() { + return (name, builder) -> builder.flowControlWindow(1024 * 1024); +} + +@Bean +@Order(200) +> GrpcChannelBuilderCustomizer retryChannelCustomizer() { + return (name, builder) -> builder.enableRetry().maxRetryAttempts(5); +} +---- + +In the preceding example, the `flowControlCustomizer` customizer is applied prior to the `retryChannelCustomizer`. +Furthermore, the `flowControlCustomizer` is only applied if the auto-configured channel factory is a `NettyGrpcChannelFactory`. + +==== Per-channel +To customize an individual channel you can specify a `GrpcChannelBuilderCustomizer` on the options passed to the factory during channel creation. +The per-channel customizer will be applied after any global customizers. + +[source,java] +---- +@Bean +SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channelFactory) { + ChannelBuilderOptions options = ChannelBuilderOptions.defaults() + .withCustomizer((__, b) -> b.disableRetry()); + ManagedChannel channel = channelFactory.createChannel("localhost", options); + return SimpleGrpc.newBlockingStub(channel); +} +---- +The above example disables retries for the single created channel only. + +WARNING: While the channel builder customizer gives you full access to the native channel builder, you should not call `build` on the customized builder as the channel factory handles the `build` call for you and doing so will create orphaned channels. + +=== Application Properties The default `GrpcChannelFactory` implementation can also create a "named" channel, which you can then use to extract the configuration to connect to the server. For example: @@ -69,7 +113,7 @@ For example: ---- @Bean SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channels) { - return SimpleGrpc.newBlockingStub(channels.createChannel("local").build()); + return SimpleGrpc.newBlockingStub(channels.createChannel("local")); } ---- @@ -82,9 +126,6 @@ spring.grpc.client.channels.local.address=0.0.0.0:9090 There is a default named channel that you can configure as `spring.grpc.client.default-channel.*`, and then it will be used by default if there is no channel with the name specified in the channel creation. -Beans of type `GrpcChannelBuilderCustomizer` can be used to customize the `ChannelBuilder` before the channel is built. -This can be useful for setting up security, for example. - == The Local Server Port If you are running a gRPC server locally as part of your application, you will often want to connect to it in an integration test. @@ -106,27 +147,52 @@ SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channels, @LocalGrpcPort i === Global To add a client interceptor to be applied to all created channels you can simply register a client interceptor bean and then annotate it with `@GlobalClientInterceptor`. -The interceptors are ordered according to their bean natural ordering (i.e. `@Order`). +When you register multiple interceptor beans they are ordered according to their bean natural ordering (i.e. `@Order`). [source,java] ---- @Bean @Order(100) @GlobalClientInterceptor -ClientInterceptor myGlobalLoggingInterceptor() { - return new MyLoggingInterceptor(); +ClientInterceptor globalLoggingInterceptor() { + return new LoggingInterceptor(); +} + +@Bean +@Order(200) +@GlobalClientInterceptor +ClientInterceptor globalExtraThingsInterceptor() { + return new ExtraThingsInterceptor(); } ---- -=== Per-Channel -To add one or more client interceptors to be applied to a single client channel you can simply pass in the interceptor instance(s) when invoking the channel factory to create the channel. +In the preceding example, the `globalLoggingInterceptor` customizer is applied prior to the `globalExtraThingsInterceptor`. + +=== Per-Channel +To add one or more client interceptors to be applied to a single client channel you can simply set the interceptor instance(s) on the options passed to the channel factory when creating the channel. + +[source,java] +---- +@Bean +SimpleGrpc.SimpleBlockingStub stub(GrpcChannelFactory channelFactory) { + ClientInterceptor interceptor1 = getChannelInterceptor1(); + ClientInterceptor interceptor2 = getChannelInterceptor2(); + ChannelBuilderOptions options = ChannelBuilderOptions.defaults() + .withInterceptors(List.of(interceptor1, interceptor2)); + ManagedChannel channel = channelFactory.createChannel("localhost", options); + return SimpleGrpc.newBlockingStub(channel); +} +---- +The above example applies `interceptor1` then `interceptor2` to the single created channel. + +WARNING: While the channel builder customizer gives you full access to the native channel builder, we recommend not calling `intercept` on the customized builder but rather set the per-channel interceptors using the `ChannelBuilderOptions` as described above. +If you do call `intercept` directly on the builder then those interceptors will be applied before the above described `global` and `per-channel` interceptors. -The interceptors are ordered according to their position in the specified list. === Blended -When a channel is constructed with both global and per-client interceptors, the global interceptors are first applied in their sorted order followed by the per-service interceptors in their sorted order. +When a channel is constructed with both global and per-channel interceptors, the global interceptors are first applied in their sorted order followed by the per-channel interceptors in their sorted order. -However, by setting the `mergeWithGlobalInterceptors` parameter on the channel factory to `"true"` you can change this behavior so that the interceptors are all combined and then sorted according to their bean natural ordering (i.e. `@Order` or `Ordered` interface). +However, by setting the `withInterceptorsMerge` parameter on the `ChannelBuilderOptions` passed to the channel factory to `"true"` you can change this behavior so that the interceptors are all combined and then sorted according to their bean natural ordering (i.e. `@Order` or `Ordered` interface). You can use this option if you want to add a per-client interceptor between global interceptors. diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc index 37b38f0..e68c763 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc @@ -2,48 +2,48 @@ |Name | Default | Description |spring.grpc.client.channels | | -|spring.grpc.client.default-channel.address | | The target address uri to connect to. -|spring.grpc.client.default-channel.default-load-balancing-policy | | The default load balancing policy the channel should use. -|spring.grpc.client.default-channel.enable-keep-alive | | Whether keep alive is enabled on the channel. -|spring.grpc.client.default-channel.health.enabled | | Whether to enable client-side health check for the channel. +|spring.grpc.client.default-channel.address | `+++static://localhost:9090+++` | The target address uri to connect to. +|spring.grpc.client.default-channel.default-load-balancing-policy | `+++round_robin+++` | The default load balancing policy the channel should use. +|spring.grpc.client.default-channel.enable-keep-alive | `+++false+++` | Whether keep alive is enabled on the channel. +|spring.grpc.client.default-channel.health.enabled | `+++false+++` | Whether to enable client-side health check for the channel. |spring.grpc.client.default-channel.health.service-name | | Name of the service to check health on. -|spring.grpc.client.default-channel.idle-timeout | | The duration without ongoing RPCs before going to idle mode. -|spring.grpc.client.default-channel.keep-alive-time | | The delay before sending a keepAlive. Note that shorter intervals increase the network burden for the server and this value can not be lower than 'permitKeepAliveTime' on the server. -|spring.grpc.client.default-channel.keep-alive-timeout | | The default timeout for a keepAlives ping request. -|spring.grpc.client.default-channel.keep-alive-without-calls | | Whether a keepAlive will be performed when there are no outstanding RPC on a connection. -|spring.grpc.client.default-channel.max-inbound-message-size | | Maximum message size allowed to be received by the channel (default 4MiB). Set to '-1' to use the highest possible limit (not recommended). -|spring.grpc.client.default-channel.max-inbound-metadata-size | | Maximum metadata size allowed to be received by the channel (default 8KiB). Set to '-1' to use the highest possible limit (not recommended). -|spring.grpc.client.default-channel.negotiation-type | | The negotiation type for the channel. -|spring.grpc.client.default-channel.secure | | Flag to say that strict SSL checks are not enabled (so the remote certificate could be anonymous). +|spring.grpc.client.default-channel.idle-timeout | `+++20s+++` | The duration without ongoing RPCs before going to idle mode. +|spring.grpc.client.default-channel.keep-alive-time | `+++5m+++` | The delay before sending a keepAlive. Note that shorter intervals increase the network burden for the server and this value can not be lower than 'permitKeepAliveTime' on the server. +|spring.grpc.client.default-channel.keep-alive-timeout | `+++20s+++` | The default timeout for a keepAlives ping request. +|spring.grpc.client.default-channel.keep-alive-without-calls | `+++false+++` | Whether a keepAlive will be performed when there are no outstanding RPC on a connection. +|spring.grpc.client.default-channel.max-inbound-message-size | `+++4194304B+++` | Maximum message size allowed to be received by the channel (default 4MiB). Set to '-1' to use the highest possible limit (not recommended). +|spring.grpc.client.default-channel.max-inbound-metadata-size | `+++8192B+++` | Maximum metadata size allowed to be received by the channel (default 8KiB). Set to '-1' to use the highest possible limit (not recommended). +|spring.grpc.client.default-channel.negotiation-type | `+++plaintext+++` | The negotiation type for the channel. +|spring.grpc.client.default-channel.secure | `+++true+++` | Flag to say that strict SSL checks are not enabled (so the remote certificate could be anonymous). |spring.grpc.client.default-channel.ssl.bundle | | SSL bundle name. |spring.grpc.client.default-channel.ssl.enabled | | Whether to enable SSL support. Enabled automatically if "bundle" is provided unless specified otherwise. |spring.grpc.client.default-channel.user-agent | | The custom User-Agent for the channel. |spring.grpc.client.observations.enabled | `+++true+++` | Whether to enable Observations on the client. |spring.grpc.server.address | | The address to bind to. could be a host:port combination or a pseudo URL like static://host:port. Can not be set if host or port are set independently. |spring.grpc.server.exception-handling.enabled | `+++true+++` | Whether to enable user-defined global exception handling on the gRPC server. -|spring.grpc.server.health.actuator.enabled | | Whether to adapt Actuator health indicators into gRPC health checks. +|spring.grpc.server.health.actuator.enabled | `+++true+++` | Whether to adapt Actuator health indicators into gRPC health checks. |spring.grpc.server.health.actuator.health-indicator-paths | | List of Actuator health indicator paths to adapt into gRPC health checks. -|spring.grpc.server.health.actuator.update-initial-delay | | The initial delay before updating the health status the very first time. -|spring.grpc.server.health.actuator.update-overall-health | | Whether to update the overall gRPC server health (the '' service) with the aggregate status of the configured health indicators. -|spring.grpc.server.health.actuator.update-rate | | How often to update the health status. -|spring.grpc.server.health.enabled | | Whether to auto-configure Health feature on the gRPC server. -|spring.grpc.server.host | | Server address to bind to. The default is any IP address ('*'). +|spring.grpc.server.health.actuator.update-initial-delay | `+++5s+++` | The initial delay before updating the health status the very first time. +|spring.grpc.server.health.actuator.update-overall-health | `+++true+++` | Whether to update the overall gRPC server health (the '' service) with the aggregate status of the configured health indicators. +|spring.grpc.server.health.actuator.update-rate | `+++5s+++` | How often to update the health status. +|spring.grpc.server.health.enabled | `+++true+++` | Whether to auto-configure Health feature on the gRPC server. +|spring.grpc.server.host | `+++*+++` | Server address to bind to. The default is any IP address ('*'). |spring.grpc.server.keep-alive.max-age | | Maximum time a connection may exist before being gracefully terminated (default infinite). |spring.grpc.server.keep-alive.max-age-grace | | Maximum time for graceful connection termination (default infinite). |spring.grpc.server.keep-alive.max-idle | | Maximum time a connection can remain idle before being gracefully terminated (default infinite). -|spring.grpc.server.keep-alive.permit-time | | Maximum keep-alive time clients are permitted to configure (default 5m). -|spring.grpc.server.keep-alive.permit-without-calls | | Whether clients are permitted to send keep alive pings when there are no outstanding RPCs on the connection (default false). -|spring.grpc.server.keep-alive.time | | Duration without read activity before sending a keep alive ping (default 2h). -|spring.grpc.server.keep-alive.timeout | | Maximum time to wait for read activity after sending a keep alive ping. If sender does not receive an acknowledgment within this time, it will close the connection (default 20s). -|spring.grpc.server.max-inbound-message-size | | Maximum message size allowed to be received by the server (default 4MiB). -|spring.grpc.server.max-inbound-metadata-size | | Maximum metadata size allowed to be received by the server (default 8KiB). +|spring.grpc.server.keep-alive.permit-time | `+++5m+++` | Maximum keep-alive time clients are permitted to configure (default 5m). +|spring.grpc.server.keep-alive.permit-without-calls | `+++false+++` | Whether clients are permitted to send keep alive pings when there are no outstanding RPCs on the connection (default false). +|spring.grpc.server.keep-alive.time | `+++2h+++` | Duration without read activity before sending a keep alive ping (default 2h). +|spring.grpc.server.keep-alive.timeout | `+++20s+++` | Maximum time to wait for read activity after sending a keep alive ping. If sender does not receive an acknowledgment within this time, it will close the connection (default 20s). +|spring.grpc.server.max-inbound-message-size | `+++4194304B+++` | Maximum message size allowed to be received by the server (default 4MiB). +|spring.grpc.server.max-inbound-metadata-size | `+++8192B+++` | Maximum metadata size allowed to be received by the server (default 8KiB). |spring.grpc.server.observations.enabled | `+++true+++` | Whether to enable Observations on the server. |spring.grpc.server.port | `+++9090+++` | Server port to listen on. When the value is 0, a random available port is selected. The default is 9090. |spring.grpc.server.reflection.enabled | `+++true+++` | Whether to enable Reflection on the gRPC server. -|spring.grpc.server.shutdown-grace-period | | Maximum time to wait for the server to gracefully shutdown. When the value is negative, the server waits forever. When the value is 0, the server will force shutdown immediately. The default is 30 seconds. +|spring.grpc.server.shutdown-grace-period | `+++30s+++` | Maximum time to wait for the server to gracefully shutdown. When the value is negative, the server waits forever. When the value is 0, the server will force shutdown immediately. The default is 30 seconds. |spring.grpc.server.ssl.bundle | | SSL bundle name. -|spring.grpc.server.ssl.client-auth | | Client authentication mode. +|spring.grpc.server.ssl.client-auth | `+++none+++` | Client authentication mode. |spring.grpc.server.ssl.enabled | | Whether to enable SSL support. Enabled automatically if "bundle" is provided unless specified otherwise. -|spring.grpc.server.ssl.secure | | Flag to indicate that client authentication is secure (i.e. certificates are checked). Do not set this to false in production. +|spring.grpc.server.ssl.secure | `+++true+++` | Flag to indicate that client authentication is secure (i.e. certificates are checked). Do not set this to false in production. |=== \ No newline at end of file diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/ChannelBuilderCustomizers.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/ChannelBuilderCustomizers.java new file mode 100644 index 0000000..5034fba --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/ChannelBuilderCustomizers.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2024 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.grpc.autoconfigure.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.boot.util.LambdaSafe; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +import io.grpc.ManagedChannelBuilder; + +/** + * Invokes the available {@link GrpcChannelBuilderCustomizer} instances for a given + * {@link ManagedChannelBuilder}. + * + * @author Chris Bono + */ +public class ChannelBuilderCustomizers { + + private final List> customizers; + + ChannelBuilderCustomizers(List> customizers) { + this.customizers = (customizers != null) ? new ArrayList<>(customizers) : Collections.emptyList(); + } + + /** + * Customize the specified {@link ManagedChannelBuilder}. Locates all + * {@link GrpcChannelBuilderCustomizer} beans able to handle the specified instance + * and invoke {@link GrpcChannelBuilderCustomizer#customize} on them. + * @param the type of channel builder + * @param authority the target authority of the channel + * @param channelBuilder the builder to customize + * @return the customized builder + */ + @SuppressWarnings("unchecked") + > T customize(String authority, T channelBuilder) { + LambdaSafe.callbacks(GrpcChannelBuilderCustomizer.class, this.customizers, channelBuilder) + .withLogger(ChannelBuilderCustomizers.class) + .invoke((customizer) -> customizer.customize(authority, channelBuilder)); + return channelBuilder; + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/ClientPropertiesChannelBuilderCustomizer.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/ClientPropertiesChannelBuilderCustomizer.java index 31b0162..22ef7b8 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/ClientPropertiesChannelBuilderCustomizer.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/ClientPropertiesChannelBuilderCustomizer.java @@ -32,10 +32,12 @@ import io.grpc.ManagedChannelBuilder; * A {@link GrpcChannelBuilderCustomizer} that maps {@link GrpcClientProperties client * properties} to a channel builder. * + * @param the type of the builder * @author David Syer * @author Chris Bono */ -class ClientPropertiesChannelBuilderCustomizer implements GrpcChannelBuilderCustomizer { +class ClientPropertiesChannelBuilderCustomizer> + implements GrpcChannelBuilderCustomizer { private final GrpcClientProperties properties; @@ -44,14 +46,16 @@ class ClientPropertiesChannelBuilderCustomizer implements GrpcChannelBuilderCust } @Override - public void customize(String authority, ManagedChannelBuilder builder) { + public void customize(String authority, T builder) { NamedChannel channel = this.properties.getChannels().get(authority); if (channel == null) { return; } PropertyMapper mapper = PropertyMapper.get().alwaysApplyingWhenNonNull(); mapper.from(channel.getUserAgent()).to(builder::userAgent); - mapper.from(channel.getDefaultLoadBalancingPolicy()).to(builder::defaultLoadBalancingPolicy); + if (!authority.startsWith("unix:")) { + mapper.from(channel.getDefaultLoadBalancingPolicy()).to(builder::defaultLoadBalancingPolicy); + } mapper.from(channel.getMaxInboundMessageSize()).asInt(DataSize::toBytes).to(builder::maxInboundMessageSize); mapper.from(channel.getMaxInboundMetadataSize()).asInt(DataSize::toBytes).to(builder::maxInboundMessageSize); mapper.from(channel.getKeepAliveTime()).to(durationProperty(builder::keepAliveTime)); diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcChannelFactoryConfigurations.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcChannelFactoryConfigurations.java new file mode 100644 index 0000000..73abc88 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcChannelFactoryConfigurations.java @@ -0,0 +1,83 @@ +/* + * Copyright 2024-2024 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.grpc.autoconfigure.client; + +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.autoconfigure.client.GrpcClientAutoConfiguration.NamedChannelVirtualTargets; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.client.NettyGrpcChannelFactory; +import org.springframework.grpc.client.ShadedNettyGrpcChannelFactory; + +import io.grpc.netty.NettyChannelBuilder; + +/** + * Configurations for {@link GrpcChannelFactory gRPC channel factories}. + * + * @author Chris Bono + */ +class GrpcChannelFactoryConfigurations { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class) + @ConditionalOnMissingBean(GrpcChannelFactory.class) + @EnableConfigurationProperties(GrpcClientProperties.class) + static class ShadedNettyChannelFactoryConfiguration { + + @Bean + ShadedNettyGrpcChannelFactory shadedNettyGrpcChannelFactory(ChannelBuilderCustomizers channelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, ChannelCredentialsProvider credentials, + GrpcClientProperties properties) { + List> builderCustomizers = List + .of(channelBuilderCustomizers::customize); + var factory = new ShadedNettyGrpcChannelFactory(builderCustomizers, interceptorsConfigurer); + factory.setCredentialsProvider(credentials); + factory.setVirtualTargets(new NamedChannelVirtualTargets(properties)); + return factory; + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(NettyChannelBuilder.class) + @ConditionalOnMissingBean(GrpcChannelFactory.class) + @EnableConfigurationProperties(GrpcClientProperties.class) + static class NettyChannelFactoryConfiguration { + + @Bean + NettyGrpcChannelFactory nettyGrpcChannelFactory(ChannelBuilderCustomizers channelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, ChannelCredentialsProvider credentials, + GrpcClientProperties properties) { + List> builderCustomizers = List + .of(channelBuilderCustomizers::customize); + var factory = new NettyGrpcChannelFactory(builderCustomizers, interceptorsConfigurer); + factory.setCredentialsProvider(credentials); + factory.setVirtualTargets(new NamedChannelVirtualTargets(properties)); + return factory; + } + + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfiguration.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfiguration.java index 4ec67a1..e07c179 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfiguration.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfiguration.java @@ -15,43 +15,31 @@ */ package org.springframework.grpc.autoconfigure.client; -import java.util.List; - +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.ssl.SslBundles; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.grpc.autoconfigure.client.GrpcClientProperties.NamedChannel; import org.springframework.grpc.autoconfigure.common.codec.GrpcCodecConfiguration; import org.springframework.grpc.client.ChannelCredentialsProvider; -import org.springframework.grpc.client.ClientInterceptorsConfigurer; -import org.springframework.grpc.client.DefaultGrpcChannelFactory; import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; -import org.springframework.grpc.client.GrpcChannelFactory; import org.springframework.grpc.client.VirtualTargets; import io.grpc.CompressorRegistry; import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; -@Configuration(proxyBeanMethods = false) +@AutoConfiguration @EnableConfigurationProperties(GrpcClientProperties.class) -@Import({ GrpcCodecConfiguration.class, ClientInterceptorsConfiguration.class }) +@Import({ GrpcCodecConfiguration.class, ClientInterceptorsConfiguration.class, + GrpcChannelFactoryConfigurations.ShadedNettyChannelFactoryConfiguration.class, + GrpcChannelFactoryConfigurations.NettyChannelFactoryConfiguration.class }) public class GrpcClientAutoConfiguration { - @Bean - @ConditionalOnMissingBean(GrpcChannelFactory.class) - DefaultGrpcChannelFactory defaultGrpcChannelFactory(List customizers, - ClientInterceptorsConfigurer interceptorsConfigurer, ChannelCredentialsProvider credentials, - GrpcClientProperties channels, SslBundles ignored) { - DefaultGrpcChannelFactory factory = new DefaultGrpcChannelFactory(customizers, interceptorsConfigurer); - factory.setCredentialsProvider(credentials); - factory.setVirtualTargets(new NamedChannelVirtualTargets(channels)); - return factory; - } - @Bean @ConditionalOnMissingBean(ChannelCredentialsProvider.class) NamedChannelCredentialsProvider channelCredentialsProvider(GrpcClientProperties channels, SslBundles bundles) { @@ -59,22 +47,31 @@ public class GrpcClientAutoConfiguration { } @Bean - GrpcChannelBuilderCustomizer clientPropertiesChannelCustomizer(GrpcClientProperties properties) { - return new ClientPropertiesChannelBuilderCustomizer(properties); + > GrpcChannelBuilderCustomizer clientPropertiesChannelCustomizer( + GrpcClientProperties properties) { + return new ClientPropertiesChannelBuilderCustomizer<>(properties); } @ConditionalOnBean(CompressorRegistry.class) @Bean - GrpcChannelBuilderCustomizer compressionClientCustomizer(CompressorRegistry registry) { + > GrpcChannelBuilderCustomizer compressionClientCustomizer( + CompressorRegistry registry) { return (name, builder) -> builder.compressorRegistry(registry); } @ConditionalOnBean(DecompressorRegistry.class) @Bean - GrpcChannelBuilderCustomizer decompressionClientCustomizer(DecompressorRegistry registry) { + > GrpcChannelBuilderCustomizer decompressionClientCustomizer( + DecompressorRegistry registry) { return (name, builder) -> builder.decompressorRegistry(registry); } + @ConditionalOnMissingBean + @Bean + ChannelBuilderCustomizers channelBuilderCustomizers(ObjectProvider> customizers) { + return new ChannelBuilderCustomizers(customizers.orderedStream().toList()); + } + static class NamedChannelVirtualTargets implements VirtualTargets { private final GrpcClientProperties channels; diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/ChannelBuilderCustomizersTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/ChannelBuilderCustomizersTests.java new file mode 100644 index 0000000..60135da --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/ChannelBuilderCustomizersTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2024-2024 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.grpc.autoconfigure.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.netty.NettyChannelBuilder; + +/** + * Tests for {@link ChannelBuilderCustomizers}. + * + * @author Chris Bono + */ +class ChannelBuilderCustomizersTests { + + private static final String DEFAULT_TARGET = "localhost"; + + @Test + void customizeWithNullCustomizersShouldDoNothing() { + ManagedChannelBuilder channelBuilder = mock(ManagedChannelBuilder.class); + new ChannelBuilderCustomizers(null).customize(DEFAULT_TARGET, channelBuilder); + then(channelBuilder).shouldHaveNoInteractions(); + } + + @Test + void customizeSimpleChannelBuilder() { + ChannelBuilderCustomizers customizers = new ChannelBuilderCustomizers( + List.of(new SimpleChannelBuilderCustomizer())); + NettyChannelBuilder channelBuilder = mock(NettyChannelBuilder.class); + customizers.customize(DEFAULT_TARGET, channelBuilder); + then(channelBuilder).should().flowControlWindow(100); + } + + @Test + void customizeShouldCheckGeneric() { + List> list = new ArrayList<>(); + list.add(new TestCustomizer<>()); + list.add(new TestNettyChannelBuilderCustomizer()); + list.add(new TestShadedNettyChannelBuilderCustomizer()); + ChannelBuilderCustomizers customizers = new ChannelBuilderCustomizers(list); + + customizers.customize(DEFAULT_TARGET, mock(ManagedChannelBuilder.class)); + assertThat(list.get(0).getCount()).isOne(); + assertThat(list.get(1).getCount()).isZero(); + assertThat(list.get(2).getCount()).isZero(); + + customizers.customize(DEFAULT_TARGET, mock(NettyChannelBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(2); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isZero(); + + customizers.customize(DEFAULT_TARGET, mock(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(3); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isOne(); + } + + static class SimpleChannelBuilderCustomizer implements GrpcChannelBuilderCustomizer { + + @Override + public void customize(String target, NettyChannelBuilder channelBuilder) { + channelBuilder.flowControlWindow(100); + } + + } + + /** + * Test customizer that will match any {@link GrpcChannelBuilderCustomizer}. + */ + static class TestCustomizer> implements GrpcChannelBuilderCustomizer { + + private int count; + + @Override + public void customize(String target, T channelBuilder) { + this.count++; + } + + int getCount() { + return this.count; + } + + } + + /** + * Test customizer that will match only {@link NettyChannelBuilder}. + */ + static class TestNettyChannelBuilderCustomizer extends TestCustomizer { + + } + + /** + * Test customizer that will match only + * {@link io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder}. + */ + static class TestShadedNettyChannelBuilderCustomizer + extends TestCustomizer { + + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfigurationTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfigurationTests.java index 6521d37..bb1e87a 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfigurationTests.java +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/client/GrpcClientAutoConfigurationTests.java @@ -18,13 +18,18 @@ package org.springframework.grpc.autoconfigure.client; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.mockito.Mockito; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -32,22 +37,28 @@ import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.grpc.autoconfigure.client.GrpcClientAutoConfiguration.NamedChannelVirtualTargets; import org.springframework.grpc.client.ChannelCredentialsProvider; -import org.springframework.grpc.client.DefaultGrpcChannelFactory; import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.client.NettyGrpcChannelFactory; +import org.springframework.grpc.client.ShadedNettyGrpcChannelFactory; import io.grpc.Codec; import io.grpc.CompressorRegistry; import io.grpc.DecompressorRegistry; import io.grpc.ManagedChannelBuilder; +import io.grpc.netty.NettyChannelBuilder; /** * Tests for {@link GrpcClientAutoConfiguration}. * * @author Chris Bono */ +@SuppressWarnings({ "unchecked", "rawtypes" }) class GrpcClientAutoConfigurationTests { private ApplicationContextRunner contextRunner() { @@ -55,23 +66,6 @@ class GrpcClientAutoConfigurationTests { .withConfiguration(AutoConfigurations.of(GrpcClientAutoConfiguration.class, SslAutoConfiguration.class)); } - @Test - void whenHasUserDefinedChannelFactoryDoesNotAutoConfigureBean() { - GrpcChannelFactory customChannelFactory = mock(GrpcChannelFactory.class); - this.contextRunner() - .withBean("customChannelFactory", GrpcChannelFactory.class, () -> customChannelFactory) - .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class).isSameAs(customChannelFactory)); - } - - @Test - void channelFactoryAutoConfiguredAsExpected() { - this.contextRunner() - .run((context) -> assertThat(context).getBean(DefaultGrpcChannelFactory.class) - .hasFieldOrPropertyWithValue("credentials", context.getBean(NamedChannelCredentialsProvider.class)) - .extracting("targets") - .isInstanceOf(NamedChannelVirtualTargets.class)); - } - @Test void whenHasUserDefinedCredentialsProviderDoesNotAutoConfigureBean() { ChannelCredentialsProvider customCredentialsProvider = mock(ChannelCredentialsProvider.class); @@ -166,4 +160,130 @@ class GrpcClientAutoConfigurationTests { }); } + @Test + void whenHasUserDefinedChannelBuilderCustomizersDoesNotAutoConfigureBean() { + ChannelBuilderCustomizers customCustomizers = mock(ChannelBuilderCustomizers.class); + this.contextRunner() + .withBean("customCustomizers", ChannelBuilderCustomizers.class, () -> customCustomizers) + .run((context) -> assertThat(context).getBean(ChannelBuilderCustomizers.class).isSameAs(customCustomizers)); + } + + @Test + void channelBuilderCustomizersAutoConfiguredAsExpected() { + this.contextRunner() + .withUserConfiguration(ChannelBuilderCustomizersConfig.class) + .run((context) -> assertThat(context).getBean(ChannelBuilderCustomizers.class) + .extracting("customizers", InstanceOfAssertFactories.list(GrpcChannelBuilderCustomizer.class)) + .contains(ChannelBuilderCustomizersConfig.CUSTOMIZER_BAR, + ChannelBuilderCustomizersConfig.CUSTOMIZER_FOO)); + } + + @Test + void whenHasUserDefinedChannelFactoryDoesNotAutoConfigureBean() { + GrpcChannelFactory customChannelFactory = mock(GrpcChannelFactory.class); + this.contextRunner() + .withBean("customChannelFactory", GrpcChannelFactory.class, () -> customChannelFactory) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class).isSameAs(customChannelFactory)); + } + + @Test + void whenShadedAndNonShadedNettyOnClasspathShadedNettyFactoryIsAutoConfigured() { + this.contextRunner() + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(ShadedNettyGrpcChannelFactory.class)); + } + + @Test + void whenOnlyNonShadedNettyOnClasspathNonShadedNettyFactoryIsAutoConfigured() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(NettyGrpcChannelFactory.class)); + } + + @Test + void shadedNettyChannelFactoryAutoConfiguredAsExpected() { + channelFactoryAutoConfiguredAsExpected(this.contextRunner(), ShadedNettyGrpcChannelFactory.class); + } + + @Test + void nettyChannelFactoryAutoConfiguredAsExpected() { + channelFactoryAutoConfiguredAsExpected(this.contextRunner() + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)), + NettyGrpcChannelFactory.class); + } + + @Test + void noChannelFactoryAutoConfiguredAsExpected() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcChannelFactory.class)); + } + + private void channelFactoryAutoConfiguredAsExpected(ApplicationContextRunner contextRunner, + Class expectedChannelFactoryType) { + contextRunner.withPropertyValues("spring.grpc.server.port=0") + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(expectedChannelFactoryType) + .hasFieldOrPropertyWithValue("credentials", context.getBean(NamedChannelCredentialsProvider.class)) + .extracting("targets") + .isInstanceOf(NamedChannelVirtualTargets.class)); + } + + @Test + void shadedNettyChannelFactoryAutoConfiguredWithCustomizers() { + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder builder = mock(); + channelFactoryAutoConfiguredWithCustomizers(this.contextRunner(), builder, ShadedNettyGrpcChannelFactory.class); + } + + @Test + void nettyChannelFactoryAutoConfiguredWithCustomizers() { + NettyChannelBuilder builder = mock(); + channelFactoryAutoConfiguredWithCustomizers(this.contextRunner() + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)), + builder, NettyGrpcChannelFactory.class); + } + + @SuppressWarnings("unchecked") + private > void channelFactoryAutoConfiguredWithCustomizers( + ApplicationContextRunner contextRunner, ManagedChannelBuilder mockChannelBuilder, + Class expectedChannelFactoryType) { + GrpcChannelBuilderCustomizer customizer1 = (__, b) -> b.keepAliveTime(40L, TimeUnit.SECONDS); + GrpcChannelBuilderCustomizer customizer2 = (__, b) -> b.keepAliveTime(50L, TimeUnit.SECONDS); + ChannelBuilderCustomizers customizers = new ChannelBuilderCustomizers(List.of(customizer1, customizer2)); + contextRunner.withPropertyValues("spring.grpc.server.port=0") + .withBean("channelBuilderCustomizers", ChannelBuilderCustomizers.class, () -> customizers) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(expectedChannelFactoryType) + .extracting("globalCustomizers", InstanceOfAssertFactories.list(GrpcChannelBuilderCustomizer.class)) + .satisfies((allCustomizers) -> { + allCustomizers.forEach((c) -> c.customize("channel1", mockChannelBuilder)); + InOrder ordered = inOrder(mockChannelBuilder); + ordered.verify(mockChannelBuilder).keepAliveTime(40L, TimeUnit.SECONDS); + ordered.verify(mockChannelBuilder).keepAliveTime(50L, TimeUnit.SECONDS); + })); + } + + @Configuration(proxyBeanMethods = false) + static class ChannelBuilderCustomizersConfig { + + static GrpcChannelBuilderCustomizer CUSTOMIZER_FOO = mock(); + + static GrpcChannelBuilderCustomizer CUSTOMIZER_BAR = mock(); + + @Bean + @Order(200) + GrpcChannelBuilderCustomizer customizerFoo() { + return CUSTOMIZER_FOO; + } + + @Bean + @Order(100) + GrpcChannelBuilderCustomizer customizerBar() { + return CUSTOMIZER_BAR; + } + + } + }