BeanPostProcessor:包装 Feign Client
作者:苏渡苇
项目地址 :github.com/iweidujiang...(感谢 Star !)
本文要干什么
上一篇把 进站 HTTP 拦住了:请求打进 MVC 或 Gateway,开一个 SERVER Span。
而我们的项目还需要画出拓扑图,它要的不是「谁被访问了」,而是 谁调用了谁。
微服务之间最常见的出站方式是 OpenFeign:
text
浏览器 → Gateway → order --Feign--> user
--Feign--> product
进站拦截器看不到这次 Feign 调用。你也不会指望每个同事在 Feign 接口上自己埋点。
所以这篇的知识点是 BeanPostProcessor :容器把别人的 Bean 初始化完之后,再包一层我们自己的实现。包上去的那层叫装饰器 ,Insight 里就是 TracingFeignClient。
源码在此:
io.github.iweidujiang.springinsight.feign.TracingFeignClient
所以本文的目的就是讲明白:
不改业务代码,把 Feign 的真实 Client 套一层,出站时记下 CLIENT Span,并填上
remoteService,拓扑边才有东西可画。
一、为啥要动 Feign Client
Feign 对业务是接口:
java
@FeignClient("sca-user")
public interface UserFeignClient {
@GetMapping("/user/{id}")
User get(@PathVariable Long id);
}
真正发 HTTP 的是 feign.Client#execute。Spring Cloud 里常见的是带负载均衡的那只(类名里有 LoadBalancer)。
监测要插的位置就是这里:调用发出去之前开 Span,回来(或抛错)之后关 Span。这叫 装饰器 ------接口不变,里面先干活再 delegate.execute(...)。

马甲从哪来?不能让用户 new TracingFeignClient,得在 Spring 把原始 Client 造好之后,悄悄换掉,这就是 BeanPostProcessor。
二、BeanPostProcessor:Bean 出厂后再包一层
BeanPostProcessor 可以拦住容器里每一个 Bean 的创建。Insight 只用了初始化之后那一刀:
java
@Bean
public static BeanPostProcessor insightFeignTracingBeanPostProcessor(...) {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (!(bean instanceof Client) || bean instanceof TracingFeignClient) {
return bean;
}
// 只包装 LoadBalancer,避免把 Default 也包了导致打两遍
if (!bean.getClass().getSimpleName().contains("LoadBalancer")) {
return bean;
}
return new TracingFeignClient((Client) bean, ...);
}
};
}
本段代码:
1. 不是 Feign 的 Client,原样返回。
别的 Bean 从这里过,动都不动。
2. 已经是 TracingFeignClient,不再包。
防止后置处理器跑两次,套成洋葱。
3. 只认类名里带 LoadBalancer 的。
Spring Cloud 里往往是「负载均衡 Client 里面再握着一个 Default Client」。两个都是 Client,两个都包,一次 Feign 调用会记两条 CLIENT Span。只包外层那只,Default 继续当内部运输队。
4. 方法写成 static @Bean。
让这个后置处理器尽早注册。普通 @Bean 有时会晚半拍,想包的 Client 已经创建完了,就套不上。
5. 依赖用 ObjectProvider 注入。
后置处理器自己创建得很早,那时 InsightProperties、上报监听器未必就绪。先拿个「以后再取」的盒子,真正 execute 的时候再 getIfAvailable()。启动顺序打架,这是土办法。
之前的文章 加个依赖就生效?一行搞定 Spring Boot Starter 自动装配 我们提过:这段配置单独挂在 .imports 第二行,还加了 @ConditionalOnClass(Client.class)。项目里没有 Feign,这段 BPP 根本不会注册,零打扰。
三、马甲里面干什么
TracingFeignClient 自己也实现 feign.Client,核心就一个 execute:
java
@Override
public Response execute(Request request, Options options) throws IOException {
if (!追踪开着) {
return delegate.execute(request, options);
}
Optional<TraceSpan> parent = TraceContext.currentSpan();
if (parent.isEmpty()) {
return delegate.execute(request, options); // 没进站请求,不瞎记
}
TraceSpan clientSpan = new TraceSpan(parent.getTraceId(), parent.getSpanId());
clientSpan.setSpanKind("CLIENT");
clientSpan.setComponent("OpenFeign");
clientSpan.setRemoteService(resolveRemoteService(url)); // 拓扑用这个
try {
Response response = delegate.execute(request, options);
clientSpan.finish();
listener.reportSpan(clientSpan);
return response;
} catch (IOException e) {
clientSpan.finish("IO_ERROR", e.getMessage());
listener.reportSpan(clientSpan);
throw e;
}
}
和进站拦截器是同一件事,换了个方向:
| 进站 Interceptor | 出站 TracingFeignClient | |
|---|---|---|
| 拦的位置 | 请求进来 | Feign 发出去 |
| Span 类型 | SERVER |
CLIENT |
| 父 Span | 自己当根(或以后做头传播) | 从 TraceContext 取当前栈顶 |
| 失败怎么记 | HTTP 状态码 / 异常 | 响应码 / IOException |
有两处故意「不记」:
- 追踪开关关了 → 原样转发,零开销
TraceContext里没有父 Span → 也原样转发
第二种很常见:定时任务、启动时预热、有人在非 Web 线程里调了 Feign。没有进站请求,就不要凭空造一条 CLIENT 边,拓扑会被这些野点弄脏。
这也解释了上一篇文章 HandlerInterceptor 和 WebFilter:两套 HTTP 请求拦截 为啥把 SERVER Span 放进 ThreadLocal:同一条 Tomcat 线程里,Controller 接着调 Feign,栈顶正好是刚才那个入口 Span,父子就能串上。

四、remoteService:拓扑边不是前端画的
CLIENT Span 上最要紧的字段不是花活,是这一句:
java
clientSpan.setRemoteService(resolveRemoteService(url));
当前实现很土:从 Feign 的 URL 里抠 host。Nacos + LoadBalancer 常见形态是:
text
http://sca-user/user/1
↑
这个 host 往往就是服务名
于是 remoteService = "sca-user"。
Server 聚合拓扑时,不看调用树,只扫 Span:
text
谁调用谁 = (span.serviceName) → (span.remoteService)
serviceName 是调用方(order),remoteService 是被调方(user)。有这两头,前端才能画箭头、数次数。
没有 Feign 这层马甲,进站 SERVER Span 上通常没有 remoteService,拓扑就是一堆孤点。
这里的设计其实有局限:
- host 不一定永远是服务名,直连 IP 时图上会出现 IP 节点
- 跨进程还没有传同一个
traceId(上一篇说过),拓扑是「调用关系」,不是一条 Zipkin 大树 - 只包了 Servlet 栈 + LoadBalancer Feign;纯 WebClient、RestTemplate 出站现在没套
五、自己做类似能力时可以记住的
- 先找真正发请求的那一层接口 (Feign 就是
Client#execute),用装饰器包它,不要去改每个@FeignClient。 - 用
BeanPostProcessor换 Bean,业务无感。 记得防重复包装,也要搞清楚包外层还是内层。 - 出站 Span 尽量挂在已有的进站上下文上。 没有父 Span 就别硬记,脏数据比漏记更烦。
- 拓扑要的是「从哪到哪」,记得填一个稳定的对端名字(服务名优于 IP)。
六、小结
| 你以为的 | 实际是 |
|---|---|
| 拦了进站 HTTP,拓扑自然有边 | 边来自出站 Span 的 remoteService |
| 要改 Feign 接口才能埋点 | 包装 feign.Client 即可 |
所有 Client Bean 都该包 |
只包 LoadBalancer 外层,避免打两遍 |
| 装饰器要业务自己 new | BeanPostProcessor 在出厂后替换 |
进站拦截器解决「有请求进来」,后置处理器解决「它又调了别人」。两头都有 Span,控制台才像一张网,而不是几个点。
下一篇我们看看数据怎么离开业务进程:请求线程里别打 HTTP,队列里攒一攒再上报。
🌟 最后:欢迎围观 Spring Insight
如果你对「轻量监测 / Spring Boot Starter / 链路埋点」感兴趣,欢迎看看这个还在打磨的小项目:
🔗 GitHub :github.com/iweidujiang...
当前形态很朴素:业务服务加 spring-insight-agent-starter,旁边单独跑 insight-server 看拓扑和链路。能力有限,代码也还糙,适合当练手和对照。
你的 Star 是对我最大的鼓励。