Skip to content

Commit 09eac5f

Browse files
committed
Polish 'Support virtual threading with JDK HTTP clients'
See gh-46404
1 parent e32e335 commit 09eac5f

File tree

9 files changed

+118
-24
lines changed

9 files changed

+118
-24
lines changed

module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import java.net.http.HttpClient;
2020
import java.util.Collection;
2121
import java.util.List;
22-
import java.util.concurrent.Executors;
22+
import java.util.concurrent.Executor;
2323
import java.util.function.Consumer;
2424

2525
import org.jspecify.annotations.Nullable;
@@ -35,6 +35,7 @@
3535
* @author Phillip Webb
3636
* @author Andy Wilkinson
3737
* @author Scott Frederick
38+
* @author Sangmin Park
3839
* @since 3.4.0
3940
*/
4041
public final class JdkClientHttpRequestFactoryBuilder
@@ -63,6 +64,17 @@ public JdkClientHttpRequestFactoryBuilder withCustomizers(
6364
return new JdkClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientBuilder);
6465
}
6566

67+
/**
68+
* Return a new {@link JdkClientHttpRequestFactoryBuilder} uses the given executor
69+
* with the underlying {@link java.net.http.HttpClient.Builder}.
70+
* @param executor the executor to use
71+
* @return a new {@link JdkClientHttpRequestFactoryBuilder} instance
72+
* @since 4.0.0
73+
*/
74+
public JdkClientHttpRequestFactoryBuilder withExecutor(Executor executor) {
75+
return new JdkClientHttpRequestFactoryBuilder(getCustomizers(), this.httpClientBuilder.withExecutor(executor));
76+
}
77+
6678
/**
6779
* Return a new {@link JdkClientHttpRequestFactoryBuilder} that applies additional
6880
* customization to the underlying {@link java.net.http.HttpClient.Builder}.
@@ -76,12 +88,6 @@ public JdkClientHttpRequestFactoryBuilder withHttpClientCustomizer(
7688
this.httpClientBuilder.withCustomizer(httpClientCustomizer));
7789
}
7890

79-
public JdkClientHttpRequestFactoryBuilder enableVirtualThreadExecutor() {
80-
return this.withHttpClientCustomizer(builder ->
81-
builder.executor(Executors.newVirtualThreadPerTaskExecutor())
82-
);
83-
}
84-
8591
@Override
8692
protected JdkClientHttpRequestFactory createClientHttpRequestFactory(ClientHttpRequestFactorySettings settings) {
8793
HttpClient httpClient = this.httpClientBuilder.build(asHttpClientSettings(settings.withReadTimeout(null)));

module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.net.http.HttpClient;
2020
import java.net.http.HttpClient.Redirect;
21+
import java.util.concurrent.Executor;
2122
import java.util.function.Consumer;
2223

2324
import javax.net.ssl.SSLParameters;
@@ -49,6 +50,18 @@ private JdkHttpClientBuilder(Consumer<HttpClient.Builder> customizer) {
4950
this.customizer = customizer;
5051
}
5152

53+
/**
54+
* Return a new {@link JdkHttpClientBuilder} uses the given executor with the
55+
* underlying {@link java.net.http.HttpClient.Builder}.
56+
* @param executor the executor to use
57+
* @return a new {@link JdkHttpClientBuilder} instance
58+
* @since 4.0.0
59+
*/
60+
public JdkHttpClientBuilder withExecutor(Executor executor) {
61+
Assert.notNull(executor, "'executor' must not be null");
62+
return withCustomizer((httpClient) -> httpClient.executor(executor));
63+
}
64+
5265
/**
5366
* Return a new {@link JdkHttpClientBuilder} that applies additional customization to
5467
* the underlying {@link java.net.http.HttpClient.Builder}.

module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.java

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,26 @@
2424
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
2525
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
2626
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
27-
import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading;
2827
import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
29-
import org.springframework.boot.autoconfigure.thread.Threading;
3028
import org.springframework.boot.context.properties.EnableConfigurationProperties;
3129
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
3230
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings;
3331
import org.springframework.boot.http.client.JdkClientHttpRequestFactoryBuilder;
3432
import org.springframework.boot.ssl.SslBundles;
33+
import org.springframework.boot.thread.Threading;
3534
import org.springframework.boot.util.LambdaSafe;
3635
import org.springframework.context.annotation.Bean;
3736
import org.springframework.context.annotation.Conditional;
37+
import org.springframework.core.env.Environment;
38+
import org.springframework.core.task.VirtualThreadTaskExecutor;
3839
import org.springframework.http.client.ClientHttpRequestFactory;
3940

4041
/**
4142
* {@link EnableAutoConfiguration Auto-configuration} for
4243
* {@link ClientHttpRequestFactoryBuilder} and {@link ClientHttpRequestFactorySettings}.
4344
*
4445
* @author Phillip Webb
46+
* @author Sangmin Park
4547
* @since 4.0.0
4648
*/
4749
@SuppressWarnings("removal")
@@ -53,10 +55,14 @@ public final class HttpClientAutoConfiguration implements BeanClassLoaderAware {
5355

5456
private final ClientHttpRequestFactories factories;
5557

58+
private final Environment environment;
59+
5660
@SuppressWarnings("NullAway.Init")
5761
private ClassLoader beanClassLoader;
5862

59-
HttpClientAutoConfiguration(ObjectProvider<SslBundles> sslBundles, HttpClientProperties properties) {
63+
HttpClientAutoConfiguration(Environment environment, ObjectProvider<SslBundles> sslBundles,
64+
HttpClientProperties properties) {
65+
this.environment = environment;
6066
this.factories = new ClientHttpRequestFactories(sslBundles, properties);
6167
}
6268

@@ -67,21 +73,11 @@ public void setBeanClassLoader(ClassLoader classLoader) {
6773

6874
@Bean
6975
@ConditionalOnMissingBean
70-
@ConditionalOnThreading(Threading.PLATFORM)
7176
ClientHttpRequestFactoryBuilder<?> clientHttpRequestFactoryBuilderOnPlatform(
7277
ObjectProvider<ClientHttpRequestFactoryBuilderCustomizer<?>> clientHttpRequestFactoryBuilderCustomizers) {
7378
ClientHttpRequestFactoryBuilder<?> builder = this.factories.builder(this.beanClassLoader);
74-
return customize(builder, clientHttpRequestFactoryBuilderCustomizers.orderedStream().toList());
75-
}
76-
77-
@Bean
78-
@ConditionalOnMissingBean
79-
@ConditionalOnThreading(Threading.VIRTUAL)
80-
ClientHttpRequestFactoryBuilder<?> clientHttpRequestFactoryBuilderOnVirtual(
81-
ObjectProvider<ClientHttpRequestFactoryBuilderCustomizer<?>> clientHttpRequestFactoryBuilderCustomizers) {
82-
ClientHttpRequestFactoryBuilder<?> builder = this.factories.builder(this.beanClassLoader);
83-
if (builder instanceof JdkClientHttpRequestFactoryBuilder jdk) {
84-
return customize(jdk.enableVirtualThreadExecutor(), clientHttpRequestFactoryBuilderCustomizers.orderedStream().toList());
79+
if (builder instanceof JdkClientHttpRequestFactoryBuilder jdk && Threading.VIRTUAL.isActive(this.environment)) {
80+
builder = jdk.withExecutor(new VirtualThreadTaskExecutor("httpclient-"));
8581
}
8682
return customize(builder, clientHttpRequestFactoryBuilderCustomizers.orderedStream().toList());
8783
}
@@ -91,7 +87,7 @@ private ClientHttpRequestFactoryBuilder<?> customize(ClientHttpRequestFactoryBui
9187
List<ClientHttpRequestFactoryBuilderCustomizer<?>> customizers) {
9288
ClientHttpRequestFactoryBuilder<?>[] builderReference = { builder };
9389
LambdaSafe.callbacks(ClientHttpRequestFactoryBuilderCustomizer.class, customizers, builderReference[0])
94-
.invoke((customizer) -> builderReference[0] = customizer.customize(builderReference[0]));
90+
.invoke((customizer) -> builderReference[0] = customizer.customize(builderReference[0]));
9591
return builderReference[0];
9692
}
9793

module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/reactive/ClientHttpConnectorAutoConfiguration.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,20 @@
3030
import org.springframework.boot.context.properties.EnableConfigurationProperties;
3131
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
3232
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
33+
import org.springframework.boot.http.client.reactive.JdkClientHttpConnectorBuilder;
3334
import org.springframework.boot.http.client.reactive.ReactorClientHttpConnectorBuilder;
3435
import org.springframework.boot.reactor.netty.autoconfigure.ReactorNettyConfigurations;
3536
import org.springframework.boot.ssl.SslBundles;
37+
import org.springframework.boot.thread.Threading;
3638
import org.springframework.boot.util.LambdaSafe;
3739
import org.springframework.context.annotation.Bean;
3840
import org.springframework.context.annotation.Conditional;
3941
import org.springframework.context.annotation.Configuration;
4042
import org.springframework.context.annotation.Import;
4143
import org.springframework.context.annotation.Lazy;
4244
import org.springframework.core.annotation.Order;
45+
import org.springframework.core.env.Environment;
46+
import org.springframework.core.task.VirtualThreadTaskExecutor;
4347
import org.springframework.http.client.ReactorResourceFactory;
4448
import org.springframework.http.client.reactive.ClientHttpConnector;
4549

@@ -58,11 +62,14 @@ public final class ClientHttpConnectorAutoConfiguration implements BeanClassLoad
5862

5963
private final ClientHttpConnectors connectors;
6064

65+
private final Environment environment;
66+
6167
@SuppressWarnings("NullAway.Init")
6268
private ClassLoader beanClassLoader;
6369

64-
ClientHttpConnectorAutoConfiguration(ObjectProvider<SslBundles> sslBundles,
70+
ClientHttpConnectorAutoConfiguration(Environment environment, ObjectProvider<SslBundles> sslBundles,
6571
HttpReactiveClientProperties properties) {
72+
this.environment = environment;
6673
this.connectors = new ClientHttpConnectors(sslBundles, properties);
6774
}
6875

@@ -76,6 +83,9 @@ public void setBeanClassLoader(ClassLoader classLoader) {
7683
ClientHttpConnectorBuilder<?> clientHttpConnectorBuilder(
7784
ObjectProvider<ClientHttpConnectorBuilderCustomizer<?>> clientHttpConnectorBuilderCustomizers) {
7885
ClientHttpConnectorBuilder<?> builder = this.connectors.builder(this.beanClassLoader);
86+
if (builder instanceof JdkClientHttpConnectorBuilder jdk && Threading.VIRTUAL.isActive(this.environment)) {
87+
builder = jdk.withExecutor(new VirtualThreadTaskExecutor("httpclient-"));
88+
}
7989
return customize(builder, clientHttpConnectorBuilderCustomizers.orderedStream().toList());
8090
}
8191

module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.net.http.HttpClient;
2020
import java.util.Collection;
2121
import java.util.List;
22+
import java.util.concurrent.Executor;
2223
import java.util.function.Consumer;
2324

2425
import org.jspecify.annotations.Nullable;
@@ -59,6 +60,17 @@ public JdkClientHttpConnectorBuilder withCustomizers(Collection<Consumer<JdkClie
5960
return new JdkClientHttpConnectorBuilder(mergedCustomizers(customizers), this.httpClientBuilder);
6061
}
6162

63+
/**
64+
* Return a new {@link JdkClientHttpConnectorBuilder} uses the given executor with the
65+
* underlying {@link java.net.http.HttpClient.Builder}.
66+
* @param executor the executor to use
67+
* @return a new {@link JdkClientHttpConnectorBuilder} instance
68+
* @since 4.0.0
69+
*/
70+
public JdkClientHttpConnectorBuilder withExecutor(Executor executor) {
71+
return new JdkClientHttpConnectorBuilder(getCustomizers(), this.httpClientBuilder.withExecutor(executor));
72+
}
73+
6274
/**
6375
* Return a new {@link JdkClientHttpConnectorBuilder} that applies additional
6476
* customization to the underlying {@link java.net.http.HttpClient.Builder}.

module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@
1818

1919
import java.net.http.HttpClient;
2020
import java.time.Duration;
21+
import java.util.concurrent.Executor;
2122

2223
import org.junit.jupiter.api.Test;
2324

25+
import org.springframework.core.task.SimpleAsyncTaskExecutor;
2426
import org.springframework.http.client.JdkClientHttpRequestFactory;
2527
import org.springframework.test.util.ReflectionTestUtils;
2628

29+
import static org.assertj.core.api.Assertions.assertThat;
30+
2731
/**
2832
* Tests for {@link JdkClientHttpRequestFactoryBuilder} and {@link JdkHttpClientBuilder}.
2933
*
@@ -48,6 +52,14 @@ void withCustomizers() {
4852
httpClientCustomizer2.assertCalled();
4953
}
5054

55+
@Test
56+
void withExecutor() {
57+
Executor executor = new SimpleAsyncTaskExecutor();
58+
JdkClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jdk().withExecutor(executor).build();
59+
HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(factory, "httpClient");
60+
assertThat(httpClient.executor()).containsSame(executor);
61+
}
62+
5163
@Override
5264
protected long connectTimeout(JdkClientHttpRequestFactory requestFactory) {
5365
HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(requestFactory, "httpClient");

module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfigurationTests.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@
1616

1717
package org.springframework.boot.http.client.autoconfigure;
1818

19+
import java.net.http.HttpClient;
1920
import java.time.Duration;
2021
import java.util.ArrayList;
2122
import java.util.List;
2223

2324
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.api.condition.EnabledForJreRange;
26+
import org.junit.jupiter.api.condition.JRE;
2427

2528
import org.springframework.boot.autoconfigure.AutoConfigurations;
2629
import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
@@ -37,7 +40,9 @@
3740
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
3841
import org.springframework.context.annotation.Bean;
3942
import org.springframework.context.annotation.Configuration;
43+
import org.springframework.core.task.VirtualThreadTaskExecutor;
4044
import org.springframework.http.client.ClientHttpRequestFactory;
45+
import org.springframework.test.util.ReflectionTestUtils;
4146

4247
import static org.assertj.core.api.Assertions.assertThat;
4348

@@ -146,6 +151,17 @@ void clientHttpRequestFactoryBuilderCustomizersAreApplied() {
146151
});
147152
}
148153

154+
@Test
155+
@EnabledForJreRange(min = JRE.JAVA_21)
156+
void whenVirtualThreadsEnabledAndUsingJdkHttpClientUsesVirtualThreadExecutor() {
157+
this.contextRunner.withPropertyValues("spring.http.client.factory=jdk", "spring.threads.virtual.enabled=true")
158+
.run((context) -> {
159+
ClientHttpRequestFactory factory = context.getBean(ClientHttpRequestFactoryBuilder.class).build();
160+
HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(factory, "httpClient");
161+
assertThat(httpClient.executor().get()).isInstanceOf(VirtualThreadTaskExecutor.class);
162+
});
163+
}
164+
149165
@Configuration(proxyBeanMethods = false)
150166
static class ClientHttpRequestFactoryBuilderCustomizersConfiguration {
151167

module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/reactive/ClientHttpConnectorAutoConfigurationTests.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
2424
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.api.condition.EnabledForJreRange;
26+
import org.junit.jupiter.api.condition.JRE;
2527
import reactor.netty.http.client.HttpClient;
2628
import reactor.netty.resources.LoopResources;
2729

@@ -38,8 +40,10 @@
3840
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
3941
import org.springframework.context.annotation.Bean;
4042
import org.springframework.context.annotation.Configuration;
43+
import org.springframework.core.task.VirtualThreadTaskExecutor;
4144
import org.springframework.http.client.ReactorResourceFactory;
4245
import org.springframework.http.client.reactive.ClientHttpConnector;
46+
import org.springframework.test.util.ReflectionTestUtils;
4347

4448
import static org.assertj.core.api.Assertions.assertThat;
4549
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
@@ -171,6 +175,19 @@ void shouldBeConditionalOnAtLeastOneHttpConnectorClass() {
171175
.run((context) -> assertThat(context).doesNotHaveBean(ClientHttpConnectorSettings.class));
172176
}
173177

178+
@Test
179+
@EnabledForJreRange(min = JRE.JAVA_21)
180+
void whenVirtualThreadsEnabledAndUsingJdkHttpClientUsesVirtualThreadExecutor() {
181+
this.contextRunner
182+
.withPropertyValues("spring.http.reactiveclient.connector=jdk", "spring.threads.virtual.enabled=true")
183+
.run((context) -> {
184+
ClientHttpConnector connector = context.getBean(ClientHttpConnectorBuilder.class).build();
185+
java.net.http.HttpClient httpClient = (java.net.http.HttpClient) ReflectionTestUtils.getField(connector,
186+
"httpClient");
187+
assertThat(httpClient.executor().get()).isInstanceOf(VirtualThreadTaskExecutor.class);
188+
});
189+
}
190+
174191
private List<String> sslPropertyValues() {
175192
List<String> propertyValues = new ArrayList<>();
176193
String location = "classpath:org/springframework/boot/autoconfigure/ssl/";

module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@
1818

1919
import java.net.http.HttpClient;
2020
import java.time.Duration;
21+
import java.util.concurrent.Executor;
2122

2223
import org.junit.jupiter.api.Test;
2324

2425
import org.springframework.boot.http.client.JdkHttpClientBuilder;
26+
import org.springframework.core.task.SimpleAsyncTaskExecutor;
2527
import org.springframework.http.client.reactive.JdkClientHttpConnector;
2628
import org.springframework.test.util.ReflectionTestUtils;
2729

30+
import static org.assertj.core.api.Assertions.assertThat;
31+
2832
/**
2933
* Tests for {@link JdkClientHttpConnectorBuilder} and {@link JdkHttpClientBuilder}.
3034
*
@@ -48,6 +52,14 @@ void withCustomizers() {
4852
httpClientCustomizer2.assertCalled();
4953
}
5054

55+
@Test
56+
void withExecutor() {
57+
Executor executor = new SimpleAsyncTaskExecutor();
58+
JdkClientHttpConnector connector = ClientHttpConnectorBuilder.jdk().withExecutor(executor).build();
59+
HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient");
60+
assertThat(httpClient.executor()).containsSame(executor);
61+
}
62+
5163
@Override
5264
protected long connectTimeout(JdkClientHttpConnector connector) {
5365
HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient");

0 commit comments

Comments
 (0)