Spring Framework 7 HTTP Interface 实战:声明式 RestClient、超时与测试

Java 项目对接多个外部 HTTP 服务时,常常在业务代码中重复拼 URL、设置请求头、解析 JSON、处理状态码。Spring Framework 的 HTTP Service Client 允许使用接口注解声明请求,再由代理执行实际 HTTP 调用。本文用商品服务示例解释它和 RestClient 的关系、如何配置、怎样测试,以及从旧客户端迁移时的边界。

技术栈:Java 17+、Spring Framework 7、Spring Boot 4.x、RestClient、JUnit 5

一、先分清四种 HTTP 客户端

HTTP Interface 并非 Spring Framework 7 才首次提供的功能;本文讨论其在 Spring Framework 7 中的工程化使用方式,而非当天新发布的特性。

技术 调用模型 主要用途
RestClient 同步、流式 API 常规阻塞 HTTP 请求
WebClient 响应式、非阻塞 Reactor 链路
HTTP Interface 注解接口与动态代理 统一声明外部服务契约
RestTemplate 传统同步模板 旧代码维护与迁移

HTTP Interface 不是新的传输协议,也不是自动熔断或重试框架。它通过底层 HTTP 客户端适配器发起请求,主要价值在于减少重复的调用代码、集中接口契约。

二、从普通 RestClient 代码开始

假设第三方商品接口:

http 复制代码
GET /api/products/42
Accept: application/json

返回:

json 复制代码
{
  "id": 42,
  "name": "Keyboard",
  "price": 199.00
}

定义响应类型:

java 复制代码
public record ProductDto(
        Long id,
        String name,
        BigDecimal price
) {
}

普通调用:

java 复制代码
@Service
public class ProductGateway {

    private final RestClient client;

    public ProductGateway(RestClient.Builder builder) {
        this.client = builder
                .baseUrl("https://catalog.example.invalid")
                .build();
    }

    public ProductDto find(long id) {
        return client.get()
                .uri("/api/products/{id}", id)
                .retrieve()
                .body(ProductDto.class);
    }
}

example.invalid 是故意不可访问的示例域名。实际项目需要替换为真实下游地址。

只有一两个接口时,这种直接使用 RestClient 的写法已经很清楚。外部接口数量变多,才更有理由引入声明式接口。

三、声明 HTTP Interface

java 复制代码
@HttpExchange(
        url = "/api/products",
        accept = "application/json"
)
public interface ProductHttpApi {

    @GetExchange("/{id}")
    ProductDto getProduct(@PathVariable long id);

    @GetExchange
    List<ProductDto> search(
            @RequestParam String keyword,
            @RequestParam int page
    );
}

这里的接口不需要手写实现类。Spring 使用代理把 Java 方法调用转换为 HTTP 请求。

常用注解包括:

  • @HttpExchange:声明通用路径、请求头和交换信息。
  • @GetExchange / @PostExchange:声明 HTTP 方法。
  • @PathVariable:绑定路径变量。
  • @RequestParam:绑定查询参数。
  • @RequestBody:绑定请求体。

写入接口也能这样定义:

java 复制代码
public record CreateProductRequest(
        String name,
        BigDecimal price
) {
}

@HttpExchange("/api/products")
public interface ProductWriteApi {

    @PostExchange
    ProductDto create(
            @RequestBody CreateProductRequest request
    );
}

但声明式接口不意味着 POST 自动幂等。订单创建、支付等动作仍要依靠业务请求 ID 和下游幂等设计。

四、手动创建代理:RestClientAdapter + HttpServiceProxyFactory

java 复制代码
@Configuration
public class CatalogClientConfiguration {

    @Bean
    ProductHttpApi productHttpApi(RestClient.Builder builder) {
        RestClient client = builder
                .baseUrl("https://catalog.example.invalid")
                .build();

        RestClientAdapter adapter =
                RestClientAdapter.create(client);

        HttpServiceProxyFactory factory =
                HttpServiceProxyFactory
                        .builderFor(adapter)
                        .build();

        return factory.createClient(ProductHttpApi.class);
    }
}

这里有两个层次:

  1. RestClient 负责网络请求、序列化和状态码处理。
  2. HttpServiceProxyFactory 负责根据接口方法构造调用。

真实运行前需要补齐相关 import、模型转换器、网络配置和测试环境;本次没有独立编译这段示例。

不要在每次业务请求中重复创建代理。 更适合将代理注册为 Spring Bean,统一配置基础地址、鉴权和超时。

五、多个下游如何管理配置?

例如接入商品、库存、用户三个外部服务,建议分别定义客户端配置:

yaml 复制代码
clients:
  catalog:
    base-url: https://catalog.example.invalid
  inventory:
    base-url: https://inventory.example.invalid

用配置属性对象承载地址:

java 复制代码
@ConfigurationProperties(prefix = "clients.catalog")
public record CatalogClientProperties(String baseUrl) {
}

再注入客户端工厂:

java 复制代码
@Bean
ProductHttpApi productHttpApi(
        RestClient.Builder builder,
        CatalogClientProperties props
) {
    RestClient client = builder
            .baseUrl(props.baseUrl())
            .build();

    return HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(client))
            .build()
            .createClient(ProductHttpApi.class);
}

需要通过 @ConfigurationPropertiesScan 或 @EnableConfigurationProperties 注册属性类,否则无法保证 Bean 自动存在。

Spring Framework 7 文档也介绍了多 HTTP Interface 客户端注册能力,适合接口数量较多的项目。但具体注册 DSL、扫描能力和 Boot 自动配置随版本而异,不能假设所有项目自动扫描所有接口。

六、超时和连接池不能靠代理自动解决

以 JDK HTTP Client 为例:

java 复制代码
HttpClient jdkClient = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(2))
        .build();

JdkClientHttpRequestFactory requestFactory =
        new JdkClientHttpRequestFactory(jdkClient);

requestFactory.setReadTimeout(Duration.ofSeconds(5));

RestClient client = RestClient.builder()
        .requestFactory(requestFactory)
        .baseUrl("https://catalog.example.invalid")
        .build();

连接超时、读取超时、业务总超时不是同一个概念。底层客户端的连接复用、超时和取消语义也可能不同,需要针对所用版本测试。

还要考虑:

  • 并发上限:下游变慢时不能耗尽本服务线程。
  • 请求总预算:不能无限等待重试。
  • 连接复用:避免每个请求都创建新的底层连接资源。
  • 调用链取消:用户取消请求不一定立刻取消底层网络调用。
  • 观测指标:按下游区分错误率、延迟与超时次数。

七、404、429、500 的处理不能相同

RestClient.retrieve() 默认会对 4xx、5xx 进行状态码错误处理。HTTP Interface 代理会受底层客户端配置影响。

例如可以集中处理 5xx:

java 复制代码
RestClient client = RestClient.builder()
        .baseUrl("https://catalog.example.invalid")
        .defaultStatusHandler(
                HttpStatusCode::is5xxServerError,
                (request, response) -> {
                    throw new IllegalStateException(
                            "catalog unavailable");
                })
        .build();

真实项目应定义业务异常类型和错误码,不要把下游响应中的敏感字段直接返回给前端。

场景 处理建议
GET 临时网络错误 可考虑有限重试
429 尊重 Retry-After 与限流预算
400 优先修复请求参数
401 / 403 排查认证和权限
POST 创建订单 先确认幂等能力
超时但可能执行成功 查询下游结果,避免盲目重发

HTTP 方法语义是重试判断的重要依据,但还要结合真实业务契约。不能因为用了接口代理就自动重试所有请求。

八、如何测试 HTTP Interface?

可以使用 Spring Test 的 MockRestServiceServer 绑定 RestClient.Builder,验证路径、方法和 JSON 解析:

java 复制代码
RestClient.Builder builder = RestClient.builder()
        .baseUrl("https://catalog.example.invalid");

MockRestServiceServer server =
        MockRestServiceServer.bindTo(builder).build();

RestClient client = builder.build();

ProductHttpApi api = HttpServiceProxyFactory
        .builderFor(RestClientAdapter.create(client))
        .build()
        .createClient(ProductHttpApi.class);

server.expect(requestTo(
        "https://catalog.example.invalid/api/products/42"))
        .andExpect(method(HttpMethod.GET))
        .andRespond(withSuccess("""
                {"id":42,"name":"Keyboard","price":199.00}
                """, MediaType.APPLICATION_JSON));

ProductDto result = api.getProduct(42);

assertEquals(42L, result.id());
server.verify();

上面是测试方法主体片段,需要 JUnit、spring-test 和静态导入;未在本次环境独立编译。

建议补充的契约测试:

  1. 路径变量、查询参数编码是否正确。
  2. 请求体字段和 Content-Type 是否符合契约。
  3. 404、429、500 是否转换成预期异常。
  4. JSON 缺失字段是否触发业务校验。
  5. 超时是否能在预算内返回。
  6. 鉴权头是否正确传递且不会写入敏感日志。

Mock 测试不会验证真实 DNS、TLS、网关、负载均衡和下游接口版本;上线前仍需要测试环境集成验证。

九、从 RestTemplate 或 Feign 迁移,怎么选?

不要为了追新一次性重写全部 HTTP 调用。先盘点下游接口:

  • 是否同步还是响应式。
  • 是否依赖服务发现和负载均衡。
  • 是否有特殊拦截器、鉴权和请求签名。
  • 是否使用重试、熔断、指标和链路追踪。
  • 是否需要流式下载、文件上传或特殊编码。

建议先选择一个只读、接口简单的下游,抽出 HTTP Interface,补齐 Mock 与集成测试,核对错误码、超时、请求头和序列化行为,再逐步迁移。

如果原项目使用 Spring Cloud OpenFeign 的生态能力,不能只比较注解数量。迁移时需要按实际 Spring Cloud 版本核对服务发现、负载均衡、拦截器和容错方案。

十、常见问题

HTTP Interface 是 Spring 7 才有的吗?

不是。它在更早的 Spring Framework 版本中已经存在。本文关注的是 Spring 7 生态下的使用与治理。

HTTP Interface 能代替 RestClient 吗?

它提供声明式调用抽象,底层仍需要 RestClient、WebClient 等适配器。

会自动处理认证和重试吗?

不会。应通过底层客户端、拦截器和业务策略实现,避免泄露凭据或错误地跨租户复用认证信息。

可以在 WebFlux 项目中使用吗?

可以评估基于 WebClient 的适配方式,但不能在响应式事件循环里随意执行阻塞调用。

所有 Feign 接口都值得迁移吗?

不一定。要比较现有生态能力、迁移成本和可测试性,避免为了减少几行代码而引入运行风险。

总结

Spring HTTP Interface 的价值是集中声明 HTTP 契约,减少重复调用代码,并统一客户端配置。它不是自动提升性能或自动提供熔断的工具。

小型项目继续直接使用 RestClient 完全合理;当外部接口数量较多时,声明式接口与 Spring 7 的客户端注册能力值得评估。

官方参考资料

相关推荐
吴名氏.24 分钟前
Java转AI Agent教程(第十二周)检索与切分
java·开发语言·人工智能·ai·ai agent
瑞雪兆丰年兮1 小时前
JavaWeb-day10-SpringBootWeb案例
java·开发语言·javaweb·web
Rain的Java大神之路1 小时前
你背的Spring答案,全是5.x时代的:7.0.8源码一翻,80%的博客都过时了
java·后端·面试
这个昵称不容易2 小时前
考试报名系统的设计与实现(SpringBoot + MySQL + Vue 三角色平台)
java·springboot·毕设
Adellle2 小时前
设计模式指南 (Java 版) ㊁
java·开发语言·设计模式
CodeYp20193 小时前
第11讲:应用层协议 HTTP
linux·http
特立独行的猫A3 小时前
cj-tauri:用仓颉写桌面与鸿蒙 PC 应用
java·后端·python
Lonely丶墨轩3 小时前
从一个 404 到负数 ID:第三方接口开发中的几次排错
java
聪明的笨猪猪3 小时前
实际工程问题:AI生成MyBatis批量操作的SQL超限与性能雪崩
java·经验分享·面试·ai编程