Spring Framework 7 把 HTTP 接口客户端的注册做成了 HTTP Service Registry,Spring Boot 4 在它上面加了按组绑定的配置属性。现在只要在启动类上写 @ImportHttpServices 声明分组,再在 spring.http.serviceclient.<组名> 下配 base-url 和超时,就能直接注入接口代理,不用再一个个手写 RestClient、HttpServiceProxyFactory 和 @Bean。下面用 Spring Boot 4.1.1(Framework 7.0.9)建了一个小项目,两个组连两个本地 stub,一个组读超时 3 秒,一个组 500 毫秒,用慢接口实测了按组超时。
接口还是 @HttpExchange
接口的写法和 Framework 6 一样。类型上的 @HttpExchange 给公共路径,方法上用 @GetExchange、@PostExchange 这些变体。示例里有两个接口,分别属于两个下游服务:
@HttpExchange("/books")
public interface CatalogClient {
@GetExchange("/{id}")
Book book(@PathVariable String id);
@GetExchange("/{id}/slow")
Book slowBook(@PathVariable String id);
}
@HttpExchange("/prices")
public interface PricingClient {
@GetExchange("/{bookId}")
Price price(@PathVariable String bookId);
@GetExchange("/{bookId}/slow")
Price slowPrice(@PathVariable String bookId);
}
接口上不写主机地址。地址交给分组配置,这也是 Boot 文档推荐的做法。
用 @ImportHttpServices 分组
@ImportHttpServices 是可重复注解,在 Framework 7.0.9 源码里有 types(别名 value)、group、basePackageClasses、basePackages、clientType 几个属性。group 不写时归到名为 default 的组。示例把两个接口放进两个组,一个按类型列,一个按包扫描:
@SpringBootApplication
@ImportHttpServices(group = "catalog", types = CatalogClient.class)
@ImportHttpServices(group = "pricing", basePackageClasses = PricingClient.class)
public class HttpDemoApplication {
public static void main(String[] args) {
SpringApplication.run(HttpDemoApplication.class, args);
}
@Bean
RestClientHttpServiceGroupConfigurer groupNameHeader() {
return groups -> groups.forEachClient((group, builder) ->
builder.defaultHeader("X-Client-Group", group.name()));
}
}
同一个组里的接口共用一个客户端和一个 HttpServiceProxyFactory,每个接口代理都注册成 Bean,按类型注入就行。属性管不到的定制写在 RestClientHttpServiceGroupConfigurer 里。上面这个 Bean 给每个组的请求加了一个带组名的请求头,stub 那边能收到 catalog 和 pricing 两个值。用 WebClient 的项目对应的是 WebClientHttpServiceGroupConfigurer。
依赖方面,Boot 4 把 HTTP 客户端拆成了独立模块,示例只引了 spring-boot-starter-restclient 和 spring-boot-starter-jackson,实际解析到 spring-boot-restclient、spring-boot-http-client 4.1.1 和 spring-web 7.0.9。
按组配 base-url 和超时
Boot 4.1.1 的 HttpServiceClientProperties 把 spring.http.serviceclient 绑定成 Map<String, HttpClientProperties>,键就是组名。每个组可以配 base-url、default-header、apiversion、connect-timeout、read-timeout、redirects、ssl.bundle 等。spring.http.clients 下是对所有 HTTP 客户端生效的全局值,组里没写的项会用它。示例配置:
spring:
http:
clients:
connect-timeout: 1s
serviceclient:
catalog:
base-url: http://localhost:8081
read-timeout: 3s
pricing:
base-url: http://localhost:8082
read-timeout: 500ms
属性的应用在 PropertiesRestClientHttpServiceGroupConfigurer 里,它按组名取属性,给该组的 RestClient.Builder 设 request factory、base-url、默认请求头和 API 版本。分组属性里的 apiversion 管的是客户端发请求时带哪个版本。服务端的 API 版本控制在 Spring Boot 4.0 的 API 版本功能那篇里讲过,两边能对上。
实际跑一遍
环境是 Debian 13、OpenJDK 21.0.12.1、Maven 3.9.9。写作时 Maven Central 上 Spring Boot 最新 GA 是 4.1.1,更新的 4.2.0 还在里程碑阶段。
下游用 JDK 自带的 com.sun.net.httpserver.HttpServer 写了个 stub,随机端口启动,路径以 /slow 结尾时先睡 2 秒再返回 JSON。测试里起两个 stub,用 @DynamicPropertySource 把两个组的 base-url 指过去:
@DynamicPropertySource
static void groupUrls(DynamicPropertyRegistry registry) throws Exception {
catalogStub = new StubServer("catalog", "{\"id\":\"42\",\"title\":\"Spring in Practice\"}");
pricingStub = new StubServer("pricing", "{\"bookId\":\"42\",\"amount\":39.90,\"currency\":\"CNY\"}");
registry.add("spring.http.serviceclient.catalog.base-url", catalogStub::baseUrl);
registry.add("spring.http.serviceclient.pricing.base-url", pricingStub::baseUrl);
}
超时的两个测试分别调两个组的慢接口:
@Test
void catalogGroupWaitsForSlowAnswer() {
long start = System.nanoTime();
assertThat(catalog.slowBook("42").id()).isEqualTo("42");
Duration took = Duration.ofNanos(System.nanoTime() - start);
assertThat(took).isGreaterThanOrEqualTo(Duration.ofMillis(1900));
}
@Test
void pricingGroupTimesOutAfterHalfASecond() {
long start = System.nanoTime();
assertThatThrownBy(() -> pricing.slowPrice("42"))
.isInstanceOf(ResourceAccessException.class);
Duration took = Duration.ofNanos(System.nanoTime() - start);
assertThat(took).isBetween(Duration.ofMillis(400), Duration.ofMillis(1500));
}
mvn test 的输出:
stub catalog listening on http://localhost:46529
stub pricing listening on http://localhost:43275
catalog slow call took 2180 ms
pricing slow call failed: org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:43275/prices/42/slow": Request cancelled
caused by: java.net.http.HttpTimeoutException: Request cancelled
pricing slow call gave up after 506 ms
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.521 s -- in com.example.httpdemo.HttpServiceRegistryTest
manual pricing slow call gave up after 501 ms
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.514 s -- in com.example.httpdemo.ManualProxyFactoryTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
同样等 2 秒的下游,catalog 组等完拿到了结果,pricing 组 506 毫秒就放弃了。为了确认这个 500 毫秒确实来自配置,又用系统属性把 pricing 组改成 3 秒单独跑那个测试,结果变成没有抛异常,测试失败:
$ mvn test -Dtest='HttpServiceRegistryTest#pricingGroupTimesOutAfterHalfASecond' -Dspring.http.serviceclient.pricing.read-timeout=3s
Expecting code to raise a throwable.
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
跑的过程中有几处和预想不一样。classpath 上没有 Apache HttpClient、Jetty 或 Reactor Netty,Boot 自动选了 JDK 的 HttpClient,读超时报出来的是 HttpTimeoutException: Request cancelled,外层包成 ResourceAccessException,消息里没有 timeout 字样,真正的原因在 cause 里。stub 在客户端放弃以后再写响应会抛 IOException,所以处理器里接住了这个异常。Maven 的 -D 参数会被 Surefire 传成测试 JVM 的系统属性,优先级高于 application.yaml,上面的对照就是靠这一点做的。
对照:手工用 HttpServiceProxyFactory
没有 Registry 之前,每个下游都要自己拼 RestClient、适配器和工厂,超时也要自己落到 request factory 上。下面是同一个 PricingClient 的手工版本,也在项目里跑过,501 毫秒超时:
var httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1)).build();
var requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofMillis(500));
RestClient restClient = RestClient.builder()
.baseUrl(stub.baseUrl())
.requestFactory(requestFactory)
.defaultHeader("X-Client-Group", "pricing")
.build();
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build();
PricingClient pricing = factory.createClient(PricingClient.class);
效果一样,差别在规模上。一个下游一套这样的代码,再包成 @Bean,下游一多配置类就很长,base-url 和超时也散在 Java 代码里。换成分组以后,这些值进了配置文件,按组名改就行,代码里只剩 @ImportHttpServices 一行。Framework 文档在介绍分组时也提到了这个动机:用工厂创建代理很简单,但把它们声明成 Bean 会带来大量重复配置。
手工写法并没有被废弃,HttpServiceProxyFactory.builderFor(...) 在 7.0 里照常可用。
参考
- Spring Framework 参考文档,REST Clients 的 HTTP Interface 与 HTTP Service Groups 两节:rest-http-service-client、rest-http-service-client-group-config
- Spring Boot 参考文档,HTTP Service Interface Clients:io.rest-client.httpservice
- Spring Framework 源码 v7.0.9:ImportHttpServices.java
- Spring Boot 源码 v4.1.1:HttpServiceClientProperties.java、HttpClientProperties.java、PropertiesRestClientHttpServiceGroupConfigurer.java
一起交流
分享你的思考,让讨论更进一步。