From 09eac5f7b4e6784c29ec4e26dad1b5007714ac25 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Tue, 19 Aug 2025 21:32:51 -0700 Subject: [PATCH] Polish 'Support virtual threading with JDK HTTP clients' See gh-46404 --- .../JdkClientHttpRequestFactoryBuilder.java | 20 ++++++++----- .../http/client/JdkHttpClientBuilder.java | 13 +++++++++ .../HttpClientAutoConfiguration.java | 28 ++++++++----------- .../ClientHttpConnectorAutoConfiguration.java | 12 +++++++- .../JdkClientHttpConnectorBuilder.java | 12 ++++++++ ...kClientHttpRequestFactoryBuilderTests.java | 12 ++++++++ .../HttpClientAutoConfigurationTests.java | 16 +++++++++++ ...ntHttpConnectorAutoConfigurationTests.java | 17 +++++++++++ .../JdkClientHttpConnectorBuilderTests.java | 12 ++++++++ 9 files changed, 118 insertions(+), 24 deletions(-) diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java index d971d47a334..78f0f26a96a 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java @@ -19,7 +19,7 @@ package org.springframework.boot.http.client; import java.net.http.HttpClient; import java.util.Collection; import java.util.List; -import java.util.concurrent.Executors; +import java.util.concurrent.Executor; import java.util.function.Consumer; import org.jspecify.annotations.Nullable; @@ -35,6 +35,7 @@ import org.springframework.util.ClassUtils; * @author Phillip Webb * @author Andy Wilkinson * @author Scott Frederick + * @author Sangmin Park * @since 3.4.0 */ public final class JdkClientHttpRequestFactoryBuilder @@ -63,6 +64,17 @@ public final class JdkClientHttpRequestFactoryBuilder return new JdkClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientBuilder); } + /** + * Return a new {@link JdkClientHttpRequestFactoryBuilder} uses the given executor + * with the underlying {@link java.net.http.HttpClient.Builder}. + * @param executor the executor to use + * @return a new {@link JdkClientHttpRequestFactoryBuilder} instance + * @since 4.0.0 + */ + public JdkClientHttpRequestFactoryBuilder withExecutor(Executor executor) { + return new JdkClientHttpRequestFactoryBuilder(getCustomizers(), this.httpClientBuilder.withExecutor(executor)); + } + /** * Return a new {@link JdkClientHttpRequestFactoryBuilder} that applies additional * customization to the underlying {@link java.net.http.HttpClient.Builder}. @@ -76,12 +88,6 @@ public final class JdkClientHttpRequestFactoryBuilder this.httpClientBuilder.withCustomizer(httpClientCustomizer)); } - public JdkClientHttpRequestFactoryBuilder enableVirtualThreadExecutor() { - return this.withHttpClientCustomizer(builder -> - builder.executor(Executors.newVirtualThreadPerTaskExecutor()) - ); - } - @Override protected JdkClientHttpRequestFactory createClientHttpRequestFactory(ClientHttpRequestFactorySettings settings) { HttpClient httpClient = this.httpClientBuilder.build(asHttpClientSettings(settings.withReadTimeout(null))); diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java index 5e42cbb181b..ded55622ee4 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java @@ -18,6 +18,7 @@ package org.springframework.boot.http.client; import java.net.http.HttpClient; import java.net.http.HttpClient.Redirect; +import java.util.concurrent.Executor; import java.util.function.Consumer; import javax.net.ssl.SSLParameters; @@ -49,6 +50,18 @@ public final class JdkHttpClientBuilder { this.customizer = customizer; } + /** + * Return a new {@link JdkHttpClientBuilder} uses the given executor with the + * underlying {@link java.net.http.HttpClient.Builder}. + * @param executor the executor to use + * @return a new {@link JdkHttpClientBuilder} instance + * @since 4.0.0 + */ + public JdkHttpClientBuilder withExecutor(Executor executor) { + Assert.notNull(executor, "'executor' must not be null"); + return withCustomizer((httpClient) -> httpClient.executor(executor)); + } + /** * Return a new {@link JdkHttpClientBuilder} that applies additional customization to * the underlying {@link java.net.http.HttpClient.Builder}. diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.java index b25d4f827d7..8a5b7e5827a 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.java @@ -24,17 +24,18 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; -import org.springframework.boot.autoconfigure.thread.Threading; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; import org.springframework.boot.http.client.JdkClientHttpRequestFactoryBuilder; import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.thread.Threading; import org.springframework.boot.util.LambdaSafe; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; +import org.springframework.core.env.Environment; +import org.springframework.core.task.VirtualThreadTaskExecutor; import org.springframework.http.client.ClientHttpRequestFactory; /** @@ -42,6 +43,7 @@ import org.springframework.http.client.ClientHttpRequestFactory; * {@link ClientHttpRequestFactoryBuilder} and {@link ClientHttpRequestFactorySettings}. * * @author Phillip Webb + * @author Sangmin Park * @since 4.0.0 */ @SuppressWarnings("removal") @@ -53,10 +55,14 @@ public final class HttpClientAutoConfiguration implements BeanClassLoaderAware { private final ClientHttpRequestFactories factories; + private final Environment environment; + @SuppressWarnings("NullAway.Init") private ClassLoader beanClassLoader; - HttpClientAutoConfiguration(ObjectProvider sslBundles, HttpClientProperties properties) { + HttpClientAutoConfiguration(Environment environment, ObjectProvider sslBundles, + HttpClientProperties properties) { + this.environment = environment; this.factories = new ClientHttpRequestFactories(sslBundles, properties); } @@ -67,21 +73,11 @@ public final class HttpClientAutoConfiguration implements BeanClassLoaderAware { @Bean @ConditionalOnMissingBean - @ConditionalOnThreading(Threading.PLATFORM) ClientHttpRequestFactoryBuilder clientHttpRequestFactoryBuilderOnPlatform( ObjectProvider> clientHttpRequestFactoryBuilderCustomizers) { ClientHttpRequestFactoryBuilder builder = this.factories.builder(this.beanClassLoader); - return customize(builder, clientHttpRequestFactoryBuilderCustomizers.orderedStream().toList()); - } - - @Bean - @ConditionalOnMissingBean - @ConditionalOnThreading(Threading.VIRTUAL) - ClientHttpRequestFactoryBuilder clientHttpRequestFactoryBuilderOnVirtual( - ObjectProvider> clientHttpRequestFactoryBuilderCustomizers) { - ClientHttpRequestFactoryBuilder builder = this.factories.builder(this.beanClassLoader); - if (builder instanceof JdkClientHttpRequestFactoryBuilder jdk) { - return customize(jdk.enableVirtualThreadExecutor(), clientHttpRequestFactoryBuilderCustomizers.orderedStream().toList()); + if (builder instanceof JdkClientHttpRequestFactoryBuilder jdk && Threading.VIRTUAL.isActive(this.environment)) { + builder = jdk.withExecutor(new VirtualThreadTaskExecutor("httpclient-")); } return customize(builder, clientHttpRequestFactoryBuilderCustomizers.orderedStream().toList()); } @@ -91,7 +87,7 @@ public final class HttpClientAutoConfiguration implements BeanClassLoaderAware { List> customizers) { ClientHttpRequestFactoryBuilder[] builderReference = { builder }; LambdaSafe.callbacks(ClientHttpRequestFactoryBuilderCustomizer.class, customizers, builderReference[0]) - .invoke((customizer) -> builderReference[0] = customizer.customize(builderReference[0])); + .invoke((customizer) -> builderReference[0] = customizer.customize(builderReference[0])); return builderReference[0]; } diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/reactive/ClientHttpConnectorAutoConfiguration.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/reactive/ClientHttpConnectorAutoConfiguration.java index 4a6adea0907..8431945866a 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/reactive/ClientHttpConnectorAutoConfiguration.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/reactive/ClientHttpConnectorAutoConfiguration.java @@ -30,9 +30,11 @@ import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings; +import org.springframework.boot.http.client.reactive.JdkClientHttpConnectorBuilder; import org.springframework.boot.http.client.reactive.ReactorClientHttpConnectorBuilder; import org.springframework.boot.reactor.netty.autoconfigure.ReactorNettyConfigurations; import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.thread.Threading; import org.springframework.boot.util.LambdaSafe; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; @@ -40,6 +42,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Lazy; import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; +import org.springframework.core.task.VirtualThreadTaskExecutor; import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.client.reactive.ClientHttpConnector; @@ -58,11 +62,14 @@ public final class ClientHttpConnectorAutoConfiguration implements BeanClassLoad private final ClientHttpConnectors connectors; + private final Environment environment; + @SuppressWarnings("NullAway.Init") private ClassLoader beanClassLoader; - ClientHttpConnectorAutoConfiguration(ObjectProvider sslBundles, + ClientHttpConnectorAutoConfiguration(Environment environment, ObjectProvider sslBundles, HttpReactiveClientProperties properties) { + this.environment = environment; this.connectors = new ClientHttpConnectors(sslBundles, properties); } @@ -76,6 +83,9 @@ public final class ClientHttpConnectorAutoConfiguration implements BeanClassLoad ClientHttpConnectorBuilder clientHttpConnectorBuilder( ObjectProvider> clientHttpConnectorBuilderCustomizers) { ClientHttpConnectorBuilder builder = this.connectors.builder(this.beanClassLoader); + if (builder instanceof JdkClientHttpConnectorBuilder jdk && Threading.VIRTUAL.isActive(this.environment)) { + builder = jdk.withExecutor(new VirtualThreadTaskExecutor("httpclient-")); + } return customize(builder, clientHttpConnectorBuilderCustomizers.orderedStream().toList()); } diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java index be2dc95f2b1..63f3011fb4e 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java @@ -19,6 +19,7 @@ package org.springframework.boot.http.client.reactive; import java.net.http.HttpClient; import java.util.Collection; import java.util.List; +import java.util.concurrent.Executor; import java.util.function.Consumer; import org.jspecify.annotations.Nullable; @@ -59,6 +60,17 @@ public final class JdkClientHttpConnectorBuilder extends AbstractClientHttpConne return new JdkClientHttpConnectorBuilder(mergedCustomizers(customizers), this.httpClientBuilder); } + /** + * Return a new {@link JdkClientHttpConnectorBuilder} uses the given executor with the + * underlying {@link java.net.http.HttpClient.Builder}. + * @param executor the executor to use + * @return a new {@link JdkClientHttpConnectorBuilder} instance + * @since 4.0.0 + */ + public JdkClientHttpConnectorBuilder withExecutor(Executor executor) { + return new JdkClientHttpConnectorBuilder(getCustomizers(), this.httpClientBuilder.withExecutor(executor)); + } + /** * Return a new {@link JdkClientHttpConnectorBuilder} that applies additional * customization to the underlying {@link java.net.http.HttpClient.Builder}. diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java index 87218d95987..24ef0a5452b 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java @@ -18,12 +18,16 @@ package org.springframework.boot.http.client; import java.net.http.HttpClient; import java.time.Duration; +import java.util.concurrent.Executor; import org.junit.jupiter.api.Test; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.assertThat; + /** * Tests for {@link JdkClientHttpRequestFactoryBuilder} and {@link JdkHttpClientBuilder}. * @@ -48,6 +52,14 @@ class JdkClientHttpRequestFactoryBuilderTests httpClientCustomizer2.assertCalled(); } + @Test + void withExecutor() { + Executor executor = new SimpleAsyncTaskExecutor(); + JdkClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jdk().withExecutor(executor).build(); + HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(factory, "httpClient"); + assertThat(httpClient.executor()).containsSame(executor); + } + @Override protected long connectTimeout(JdkClientHttpRequestFactory requestFactory) { HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(requestFactory, "httpClient"); diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfigurationTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfigurationTests.java index d845d1728d1..8b13592bab1 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfigurationTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfigurationTests.java @@ -16,11 +16,14 @@ package org.springframework.boot.http.client.autoconfigure; +import java.net.http.HttpClient; import java.time.Duration; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; @@ -37,7 +40,9 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.VirtualThreadTaskExecutor; import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -146,6 +151,17 @@ class HttpClientAutoConfigurationTests { }); } + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void whenVirtualThreadsEnabledAndUsingJdkHttpClientUsesVirtualThreadExecutor() { + this.contextRunner.withPropertyValues("spring.http.client.factory=jdk", "spring.threads.virtual.enabled=true") + .run((context) -> { + ClientHttpRequestFactory factory = context.getBean(ClientHttpRequestFactoryBuilder.class).build(); + HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(factory, "httpClient"); + assertThat(httpClient.executor().get()).isInstanceOf(VirtualThreadTaskExecutor.class); + }); + } + @Configuration(proxyBeanMethods = false) static class ClientHttpRequestFactoryBuilderCustomizersConfiguration { diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/reactive/ClientHttpConnectorAutoConfigurationTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/reactive/ClientHttpConnectorAutoConfigurationTests.java index 04fc0bf5ec2..a8d7b8948df 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/reactive/ClientHttpConnectorAutoConfigurationTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/reactive/ClientHttpConnectorAutoConfigurationTests.java @@ -22,6 +22,8 @@ import java.util.List; import org.apache.hc.client5.http.impl.async.HttpAsyncClients; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.JRE; import reactor.netty.http.client.HttpClient; import reactor.netty.resources.LoopResources; @@ -38,8 +40,10 @@ 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.task.VirtualThreadTaskExecutor; import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; @@ -171,6 +175,19 @@ class ClientHttpConnectorAutoConfigurationTests { .run((context) -> assertThat(context).doesNotHaveBean(ClientHttpConnectorSettings.class)); } + @Test + @EnabledForJreRange(min = JRE.JAVA_21) + void whenVirtualThreadsEnabledAndUsingJdkHttpClientUsesVirtualThreadExecutor() { + this.contextRunner + .withPropertyValues("spring.http.reactiveclient.connector=jdk", "spring.threads.virtual.enabled=true") + .run((context) -> { + ClientHttpConnector connector = context.getBean(ClientHttpConnectorBuilder.class).build(); + java.net.http.HttpClient httpClient = (java.net.http.HttpClient) ReflectionTestUtils.getField(connector, + "httpClient"); + assertThat(httpClient.executor().get()).isInstanceOf(VirtualThreadTaskExecutor.class); + }); + } + private List sslPropertyValues() { List propertyValues = new ArrayList<>(); String location = "classpath:org/springframework/boot/autoconfigure/ssl/"; diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java index 206fe86ab6f..bd1790c41b1 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java @@ -18,13 +18,17 @@ package org.springframework.boot.http.client.reactive; import java.net.http.HttpClient; import java.time.Duration; +import java.util.concurrent.Executor; import org.junit.jupiter.api.Test; import org.springframework.boot.http.client.JdkHttpClientBuilder; +import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.http.client.reactive.JdkClientHttpConnector; import org.springframework.test.util.ReflectionTestUtils; +import static org.assertj.core.api.Assertions.assertThat; + /** * Tests for {@link JdkClientHttpConnectorBuilder} and {@link JdkHttpClientBuilder}. * @@ -48,6 +52,14 @@ class JdkClientHttpConnectorBuilderTests extends AbstractClientHttpConnectorBuil httpClientCustomizer2.assertCalled(); } + @Test + void withExecutor() { + Executor executor = new SimpleAsyncTaskExecutor(); + JdkClientHttpConnector connector = ClientHttpConnectorBuilder.jdk().withExecutor(executor).build(); + HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); + assertThat(httpClient.executor()).containsSame(executor); + } + @Override protected long connectTimeout(JdkClientHttpConnector connector) { HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient");