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 里照常可用。

参考

— 感谢阅读 —

一起交流

分享你的思考,让讨论更进一步。