声明式 HTTP 客户端怎样把接口变成请求
订单服务调用用户服务,改成了声明式客户端:写一个接口、加几个注解,调用就像本地方法一样。上线后发现,用户名里带斜杠的请求全部 404;某次下游变慢,调用方的线程一个个被挂住,过了好几分钟才恢复。
声明式 HTTP 客户端(Spring 的 HTTP Interface、OpenFeign、Retrofit 等)把远程调用写成一个 Java 接口:方法签名描述请求,注解说明路径、方法和参数的位置,框架在运行时生成实现。它确实把「怎样构造 HTTP 请求」从业务代码里拿走了,但并没有把远程调用变成本地调用:编码、错误、超时这些问题仍然存在,只是从业务代码移到了框架的默认值里。不了解这些默认值,问题就会在开头那样的时刻出现。
本文用 Spring Framework 7.0.9 的 HTTP Interface 对着一个本地回显服务逐项验证,并用一个十来行的手写动态代理做对照,看框架替我们做了哪些事。实验见文末。
一、机制:注解元数据加一个代理
public interface UserClient {
@GetExchange("/users/{id}") String get(@PathVariable("id") String id);
@GetExchange("/search") String search(@RequestParam("q") String q, @RequestParam("tag") List<String> tags);
@PostExchange("/orders") String create(@RequestBody String body);
}
UserClient c = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)).build().createClient(UserClient.class);createClient 返回的对象是 JDK 动态代理:true框架在创建客户端时做两件事:
- 解析元数据:读取每个方法上的注解,记下 HTTP 方法、路径模板、每个参数是路径变量、查询参数还是请求体。这一步通常在创建时完成,注解写错可以尽早发现。
- 生成代理:用 JDK 动态代理实现接口,每次方法调用都交给一个处理器,按元数据把参数填进请求,交给底层的 HTTP 客户端(这里是
RestClient)发送,再把响应转换成返回类型。
这和包装器:适配器、装饰器与代理里的代理是同一个模式:接口是调用方看到的契约,代理负责把调用翻译成另一种协议。也因为是代理,有些行为和「普通对象」不同:
调用接口默认方法 twice("7"):GET rawPath=/users/7 path=/users/7 | GET rawPath=/users/7 path=/users/7;服务端收到 2 个请求接口里的默认方法不会被拦截,它直接执行,里面调用的两个接口方法各发一个请求。在默认方法里组合调用很方便,但读代码的人要知道每个接口方法调用都是一次网络往返。
还有一个和编译方式有关的细节:
@PathVariable 不写名字(单文件启动没有 -parameters):java.lang.IllegalArgumentException:Name for argument of type [java.lang.String] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.框架靠反射读取参数名来匹配 {id}。Spring Boot 的构建插件默认打开 -parameters,所以平时感觉不到;换一种编译方式(命令行、某些 IDE 配置、单文件启动)就会在调用时报错。在注解里显式写出名字,契约就不依赖编译参数。
二、编码:框架替你做的那部分
get("a/b c"):GET rawPath=/users/a%2Fb%20c path=/users/a/b c
search("x&y+z", ["a","b"]):GET rawPath=/search path=/search rawQuery=q=x%26y%2Bz&tag=a&tag=b路径变量里的 / 和空格被编码成 %2F 和 %20,整个值仍然是一个路径段;查询参数里的 & 和 + 也被编码,集合参数展开成重复的键。服务端按原样解码,就能拿回 a/b c 和 x&y+z。
对照一个手写的代理,问题就很清楚了:
String path = annotation.value().replace("{id}", value); // 直接替换手写代理直接替换 {id},get("a/b"):GET rawPath=/users/a/b path=/users/a/b
手写代理直接替换 {id},get("a b"):java.lang.IllegalArgumentException:Illegal character in path at index 30: http://127.0.0.1:<port>/users/a b
手写代理用 URLEncoder 编码路径,get("a b"):GET rawPath=/users/a+b path=/users/a+b直接替换时,a/b 变成了两层路径,请求打到了另一个资源上,这正是开头「带斜杠的用户名全部 404」的原因;空格让 URI 根本构造不出来。换成 URLEncoder 也不对:它做的是表单编码(application/x-www-form-urlencoded),把空格变成 +,而在路径里 + 就是字面的加号,服务端拿到的是 a+b。路径段、查询参数、表单体各有编码规则,用错一种就会出问题。
即使用了框架,服务端也要注意:一些服务器和代理默认拒绝或改写路径里的 %2F,用户输入的值放进路径变量之前,先确认这一层是否放行。
三、错误状态:默认抛异常
返回类型 String,服务端 404:HttpClientErrorException$NotFound:404 Not Found: "error 404"
返回类型 ResponseEntity<String>,服务端 404:HttpClientErrorException$NotFound:404 Not Found: "error 404"
服务端 500:HttpServerErrorException$InternalServerError:500 Internal Server Error: "error 500"RestClient 默认把 4xx 和 5xx 都变成异常,即使返回类型是 ResponseEntity,也拿不到 404 的响应对象。这对多数调用是合理的:错误不会被悄悄当成正常结果。但对于「查不到是正常情况」的接口,调用方要么捕获 NotFound,要么配置自己的状态处理器,把 404 转成 Optional.empty() 之类的领域结果。
更重要的是在接口一侧把错误语义写清楚:哪些状态是业务结果(未找到、冲突),哪些是故障(超时、5xx),故障能否重试。把所有异常都变成 null 或空集合的降级处理,会把依赖故障伪装成「没有数据」,见重试掩盖了失败。
四、超时:默认没有
默认请求工厂,服务端 3 秒后才响应:等了约 3 秒,结果「GET rawPath=/slow path=/slow」
读取超时设为 1 秒:约 1 秒后「ResourceAccessException:I/O error on GET request for "http://127.0.0.1:<port>/slow": Reques…」默认配置下没有读取超时,下游多慢,调用方就等多久。开头那个线程被挂住几分钟的事故就是这样:每个调用都在等,线程池很快被占满。
接口写得像本地方法,最容易让人忘记这件事。每个声明式客户端在创建时都应该显式设置连接超时和读取超时,数值来自调用链的时间预算,而不是一个随手写的常量,见超时预算与重试放大和一次请求有几层超时。读取超时也不等于整个调用的截止时间:重试、排队和多次往返都要算进去。
五、接口放在哪里
声明式客户端的接口常常被做成一个共享的 jar,由服务提供方发布、调用方引用。这样省去了重复定义,代价是调用方和提供方的发布被绑在了一起:提供方给 DTO 加一个字段、改一个枚举值,调用方就要升级依赖。
另一种做法是每个调用方自己定义接口,只声明自己用到的方法和字段,把它当作调用方一侧的端口,翻译成自己领域里的概念。这就是上下文集成里防腐层的做法:远程模型的变化先停在这一层。两种做法没有绝对的优劣,取决于双方的团队边界和变更频率;但无论哪种,超时、错误映射、认证和追踪上下文的传递都要在客户端一侧配置,共享接口并不会带来这些。
六、检查清单
- 路径变量与参数在注解里写出名字,不依赖
-parameters。 - 不要自己拼接路径;路径段、查询参数、表单体分别按各自的规则编码。
- 明确哪些状态码是业务结果、哪些是故障;不要把故障降级成空结果。
- 每个客户端显式设置连接与读取超时,数值来自调用链的时间预算。
- 记住接口默认方法里的每个调用都是一次网络请求。
配套实验
- codesphere-labs/design/declarative-http-client:Spring Framework 7.0.9、JDK 25.0.4.1:代理类型、参数名、路径与查询编码、默认方法、404/500、默认与设置后的读取超时,对照手写 JDK 动态代理(验证记录)
参考资料