资讯详情

资讯详情

Feign动态Header透传实战:基于RequestInterceptor实现请求头无感注入

开头先说说这次的场景。微服务之间用Feign做HTTP调用几乎是标配但一旦涉及鉴权信息、链路追踪ID、灰度标识这类需要动态变化的请求头就会踩到“加头容易、取头难”的坑。普通的写法无非是在FeignClient的注解里写死一个Header值或者每个方法都手动加参数前者不够灵活后者代码冗余到你想砸键盘。这篇文章就围绕一次实际开发中遇到的需求展开——在Feign请求发出前把当前登录用户的信息、traceId、来源标识等动态Header注入到每一次调用里并且要做到对业务代码无感。我先说明白这个内容适合谁正在用SpringCloud搭建微服务的Java开发尤其是被Feign调用链路上的Header透传问题困扰过的人。如果你还没遇到那更建议提前看等你在凌晨收到线上告警说“下游服务拿不到用户ID”的时候再回来翻文章代价就不一样了。1. 需求梳理与常规方案的问题1.1 动态Header到底是什么场景Feign的动态Header和静态Header是两回事。静态的比如Content-Type: application/json全局都一样直接用Headers注解或者配置类写死就完事。但动态Header指的是每一次调用发生时请求头的值都可能不一样。我这次的需求是三个HeaderX-User-Id当前用户ID、X-Trace-Id全链路追踪ID、X-Gray-Tag灰度标识。这几个值的共同点是它们都来自当前请求的上下文Feign调用方本身不直接知道必须从某个“上下文容器”里取。如果服务A收到用户请求处理过程中通过Feign调服务B那么服务B收到的Header应该原样传承用户请求里的信息而不是每个Feign Client自己的固定配置。这里有一个关键矛盾Feign Client通常被定义成Spring的单例Bean而Header的值是请求级的、多实例并发的。你不能把Header存到Bean字段里否则并发场景直接串数据。所以方案设计的第一原则是Header值必须从请求作用域获取不能在Feign Client内部存储可变状态。1.2 常规做法为什么都差点意思先看几种常见写法其实都有人用但各有问题。第一种注解写死。在FeignClient接口方法上直接加RequestHeader(X-User-Id) String userId把Header变成方法参数。这种做法的问题很直接Feign接口的每个方法都要加参数调用方也要额外传参本来只是想透传上下文结果变成了全链路改签名。我数了一下一个订单服务的Feign接口有8个方法每个方法加两个Header参数改动点有16处这还是一对一的情况多服务多接口那就是指数级的改动了。第二种使用Feign.Builder的自定义拦截器但直接在拦截器里requestTemplate.header(X-User-Id, 固定值)。这种和注解写死没有本质区别无法拿到当前用户信息。第三种写一个Spring MVC的HandlerInterceptor在进入Controller的时候把Header存到ThreadLocalFeign拦截器再取。这一步思路对了但不能直接抄作业。没处理好线程生命周期就会内存泄漏没弄懂不同Feign底层HTTP客户端OkHttp vs Apache HttpClient是否把请求包装到了新线程就会取不到值。1.3 必须理解Feign的请求链路Feign并不是一个HTTP客户端它是Spring Cloud上层的一个声明式REST客户端封装。真正发HTTP请求的底层客户端有三个选项HttpURLConnectionJDK自带默认、Apache HttpClient、OkHttp。无论选哪个Feign的统一入口都是feign.RequestInterceptor接口在请求模板构建完成之后、实际发送之前执行。用一张简化的时序来理解的话就是调用方执行Feign Client接口方法。Feign动态代理将方法参数嵌入RequestTemplate。遍历所有RequestInterceptor实例调用其apply方法。RequestTemplate带着Header信息交给Client实现类发HTTP请求。关键在于第3步RequestInterceptor.apply()是我们介入的唯一干净位置。这里拿到的RequestTemplate可以直接添加Header且不影响方法签名。所以真正的技术突破口不是“动态Header怎么加”而是“怎么在apply()执行的这一刻安全地拿到当前请求的上下文数据”。2. 方案选型为什么选“上下文寄存器 RequestInterceptor”2.1 备选方案对比我在设计时对比了四条路线各有取舍。方案实现思路优点缺点A. 方法参数显式传递每个Feign方法加RequestHeader参数最直观无黑魔法侵入性极强接口签名臃肿B. 静态ThreadLocal直取Controller层存ThreadLocal拦截器读取简单粗暴改动少线程复用会串值异步调用直接丢C. Spring RequestContextHolder使用RequestContextHolder获取当前HttpServletRequest官方工具支持异步改造高并发下ThreadLocal不清理同样有泄漏风险且与底层线程模型强耦合D. 自定义上下文寄存器 RequestInterceptor自己控制上下文生命周期拦截器统一注入无侵入灵活可扩展需要自己写一点代码需注意清理逻辑我最终选择了D。原因是这个方案在“代码可维护性”和“运行时可控性”之间取得了平衡。自定义上下文寄存器听起来高大上本质上就是个带ThreadLocal的管理器但比直接用RequestContextHolder多了两项控制权一是背靠背清理用完即删二是可以在异步调用场景中手动传递上下文不依赖Spring的实现细节。2.2 为什么RequestInterceptor才是正解Feign官方文档里自定义请求头一共有三种姿势除了RequestInterceptor还有RequestTemplate的匿名内部类以及FeignClient的configuration属性指定一个RequestInterceptor的Bean。前两者都不适合动态场景因为没法在构建模板时拿到上下文。RequestInterceptor接口只有一个方法public interface RequestInterceptor { void apply(RequestTemplate template); }没有上下文参数没有返回值。正因为它的“单纯”它成了最佳切入点——你可以在这个方法里做任何事查数据库、调远程配置中心、读取缓存、拿ThreadLocal然后把结果放进template.header(...)。这个设计本质是把“怎么拿数据”和“怎么发请求”解耦。业务代码不感知Header的存在Feign接口保持纯净而自定义拦截器掌握所有动态注入逻辑。后面如果新增一个Header只在拦截器里加一行所有Feign调用都生效。2.3 上下文容器的设计细节既然走“上下文容器”这条路容器的设计就直接决定了方案是否健壮。我在具体实现中分了三个部分FeignHeaderContext普通POJO持有userId、traceId、grayTag等字段构造时从当前HttpServletRequest里提取。FeignHeaderContextHolder负责将上下文绑定到当前线程ThreadLocal提供set、get、clear方法。FeignHeaderInterceptor实现了RequestInterceptor从FeignHeaderContextHolder.get()取上下文放入RequestTemplate。这里的核心机制是生命周期闭环。用户请求进来时Spring拦截器创建上下文并放入Holder请求处理完成时Spring拦截器的afterCompletion调用Holder的clear。这个顺序必须保证否则线程池里的线程会带着上一次请求的用户信息跑到下一次调用轻则数据错乱重则发生越权漏洞不是危言耸听。3. 核心实现从零编写可复用的动态Header组件3.1 定义上下文对象和Holder先定义一个纯粹的数据载体不加任何逻辑public class FeignHeaderContext { private String userId; private String traceId; private String grayTag; public FeignHeaderContext(String userId, String traceId, String grayTag) { this.userId userId; this.traceId traceId; this.grayTag grayTag; } public String getUserId() { return userId; } public String getTraceId() { return traceId; } public String getGrayTag() { return grayTag; } }然后是Holder注意我用的是TransmittableThreadLocal而非原生ThreadLocal。为什么如果你后续在代码里启用了Async或者使用线程池普通的ThreadLocal在子线程里是拿不到的而TransmittableThreadLocal配合TtlRunnable可以将父线程的上下文快照传给子线程。如果你确定代码里没有任何异步操作用原生ThreadLocal也可以但多一份保障总没有坏处。import com.alibaba.ttl.TransmittableThreadLocal; public class FeignHeaderContextHolder { private static final TransmittableThreadLocalFeignHeaderContext CONTEXT new TransmittableThreadLocal(); public static void set(FeignHeaderContext context) { CONTEXT.set(context); } public static FeignHeaderContext get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }注意如果项目里没有引入transmittable-thread-local依赖使用普通ThreadLocal是等效的。但一旦未来接入异步链路你就要回过头来改这里。出于“一次到位”的考虑我会建议直接引入这个包很小没有额外的运行时成本。3.2 写RequestInterceptor注入Headerimport feign.RequestInterceptor; import feign.RequestTemplate; public class FeignHeaderInterceptor implements RequestInterceptor { private static final String HEADER_USER_ID X-User-Id; private static final String HEADER_TRACE_ID X-Trace-Id; private static final String HEADER_GRAY_TAG X-Gray-Tag; Override public void apply(RequestTemplate template) { FeignHeaderContext context FeignHeaderContextHolder.get(); if (context null) { return; } template.header(HEADER_USER_ID, context.getUserId()); template.header(HEADER_TRACE_ID, context.getTraceId()); template.header(HEADER_GRAY_TAG, context.getGrayTag()); } }这里有一个细节特别值得说template.header(key, value)对已存在同名Header的处理方式是覆盖还是追加Feign的RequestTemplate.header()方法如果Header已经存在会追加成为多值Header而不是替换。如果同一请求里下游服务还设置了别的值就可能出现两个X-User-Id这对服务端解析是不友好的。正确做法是先template.removeHeader(key)再template.header(key, value)确保覆盖而不是追加。我最初的版本就少了这步结果网关那边拿到两个Header头直接解析异常这个坑记一下。修正后的写法Override public void apply(RequestTemplate template) { FeignHeaderContext context FeignHeaderContextHolder.get(); if (context null) { return; } applyHeader(template, HEADER_USER_ID, context.getUserId()); applyHeader(template, HEADER_TRACE_ID, context.getTraceId()); applyHeader(template, HEADER_GRAY_TAG, context.getGrayTag()); } private void applyHeader(RequestTemplate template, String name, String value) { template.removeHeader(name); template.header(name, value); }3.3 在Spring配置中启用拦截器这里有个常见的迷思是不是在FeignClient的configuration里指定了拦截器类就万事大吉只对了一半。这种姿势的问题是FeignClient的configuration指定的类不会被Spring Boot的自动配置扫描它是Feign自己维护的一套FeignContext子容器。如果你把拦截器配到了主Spring容器里同时又在configuration里指定了另一个两边都会生效顺序不确定。我的推荐做法是明确指定到你需要的Feign Client上如果所有Feign都要加再考虑全局Configuration类。假设我要给某个订单服务的Feign Client加FeignClient( name order-service, configuration OrderFeignConfig.class ) public interface OrderFeignClient { GetMapping(/order/detail) OrderDetailDTO getOrderDetail(RequestParam(orderId) String orderId); }public class OrderFeignConfig { Bean public RequestInterceptor feignHeaderInterceptor() { return new FeignHeaderInterceptor(); } }如果你希望所有Feign调用都带上这些Header可以在主启动类或任意Configuration类里加Bean这样Spring会把它注册到所有FeignContext中。值得注意的是两种方式同时存在时全局Bean优先configuration指定在后。顺序对结果没有影响因为我们用的同一个实现。3.4 从HttpServletRequest建立上下文到了最后一步怎么在请求进来的时候把Header塞进Holder。我用一个Spring MVC拦截器搞定public class FeignHeaderContextInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String userId request.getHeader(X-User-Id); String traceId request.getHeader(X-Trace-Id); String grayTag request.getHeader(X-Gray-Tag); FeignHeaderContext context new FeignHeaderContext(userId, traceId, grayTag); FeignHeaderContextHolder.set(context); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { FeignHeaderContextHolder.clear(); } }再把它注册到WebMvc配置里Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new FeignHeaderContextInterceptor()) .addPathPatterns(/**); } }这整套组合拳下来调用链是这样的用户请求到达Controller前置拦截器从请求Header中取出用户信息存入FeignHeaderContextHolder。业务代码调用Feign接口Feign拦截器从Holder中获取上下文把Header写入请求模板。下游服务收到Header处理如果需要继续调用下游重复此过程。用户请求返回afterCompletion清理Holder。整个过程业务代码完全无感Feign接口方法签名清爽动态Header的注入逻辑集中在一个类里说一声“优雅”并不过分。4. 真实开发中遇到的坑与排查实录4.1 第一个坑.header()不会覆盖同名Header这个前面提到了但值得单独提出来再展开一下。Feign的RequestTemplate.header(String name, String... values)内部调用的是MapString, ListString headers所以当你调用两次header(X-User-Id, 123)结果是X-User-Id对应一个List里面有“123”再调用header(X-User-Id, 456)结果变成了List里有“123”和“456”。如果你在下游用request.getHeader(X-User-Id)取会得到“123, 456”这种逗号拼接的字符串。处理办法就两种要么在放入前先removeHeader要么把一次传入多个值用在同一行里。我在代码里选择了先移除再添加简单明确。4.2 第二个坑异步线程池中上下文神秘丢失如果应用中某个服务使用了Async方法在这个异步线程里调用Feign你会发现在自定义拦截器里拿到了null上下文。原因是普通的ThreadLocal天然只属于当前线程异步方法会被线程池调度到别的线程中原线程的ThreadLocal根本不会跟着走。而TransmittableThreadLocal能在创建异步任务时把父线程的上下文值绑定到子线程前提是你使用了它配套的TtlExecutors.getTtlExecutorService()包装线程池或者使用TtlRunnable装饰Runnable。我这次的项目里主要是同步链路所以直接用了TransmittableThreadLocal图个心安。如果你的代码里有跨线程调用Feign的场景必须先保证线程切换时上下文能带过去否则光有拦截器也白搭。4.3 第三个坑Feign重试导致Header失效在Feign开启重试的情况下第一次请求失败后Feign可能会对同一个请求模板重新执行。因为拦截器只有一次机会介入模板里的Header在构建时已经写进去了重试会沿用上一轮的Header吗实测下来会沿用但有一个隐患如果你在拦截器里读取了一个每次获取都会变化的值比如动态生成的traceId重试时用的还是第一次生成的那个这会让你排查问题时分不清哪一次是真正的原始请求。所以我在生成traceId时特别做了处理如果请求头里已有traceId就用原有的没有才创建新的。这样重试、链路追踪、日志归因都能保持一致性。4.4 第四个坑Feign日志打印会把Header打出来Feign支持通过Logger.Level配置输出请求与响应的详细日志其中FULL级别会打印Header。如果Header里有敏感信息比如用户的私有标识或者内部token日志泄露的后果可大可小。我的做法是加一层日志脱敏——在全局的日志配置里把X-User-Id这些Header的value替换成掩码输出或者将Logger.Level设为BASIC只记录URL和耗时。这一点如果你是在公司内部系统里可能无感但一旦涉及对外业务还是要留个心眼。4.5 排查工具与思路清单真遇到Feign Header问题时建议大家按以下顺序排查现象排查方向下游拿不到Header先看Feign拦截器是否执行配置是否被正确加载下游拿到多个同名Header检查是否有重复的拦截器或者header()重复调用Header值被别人请求覆盖立刻排查ThreadLocal是否在请求完成后被清理异步场景下游拿不到Header检查是否使用了TransmittableThreadLocal及其配套线程池包装Header存在但日志与数据库不一致检查Feign重试的调用次数是否走了缓存模板还有一个实用的办法在RequestInterceptor.apply()里加上一个极简日志输出当前线程名、目标URL、注入的Header名然后在下游服务打印收到的Header。两边一对比问题往往就水落石出。有一次我排查了很久最后发现是两个不同的Feign Client配置了不同名字的Header上游注入的是X-UserId下游期待的是X-User-Id纯属命名不一致。这种问题用日志对比法一分钟就能看出来。5. 从这次实践中沉淀的经验5.1 一种更工程化的扩展姿势上面的方案解决了“动态Header注入”的90%问题剩下的10%是如果你的Feign接口方法很多而且每个方法都只对某一个下游服务传递部分Header全局统一注入就不够精确了。可以考虑自定义一个注解来解决Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface FeignHeaderScope { String[] value() default {}; }在RequestInterceptor.apply()里通过template.methodMetadata().method()获取到当前请求对应的Method对象再读取方法上的FeignHeaderScope注解决定哪些Header需要注入。这样一个服务里既有普通调用、又有需要特殊Header的调用时就能做到细粒度控制了。这是我的扩展思路如果你确实遇到类似场景可以自己动手改一改。5.2 从需求出发最终得到的经验写代码几年下来我最大的体会是技术方案的天花板往往不取决于用了多高级的组件而取决于对源码细节的理解。Feign的动态Header看起来是个小需求但它牵涉到了HTTP客户端的选择、线程模型的切换、请求上下文的生命周期管理。如果你只停留在“能用”的层面大概率会在某次大促或者链路追踪时被坑一把。现在的成果是一个结构清晰的组件FeignHeaderContext装数据、FeignHeaderContextHolder管绑定、FeignHeaderInterceptor做注入、FeignHeaderContextInterceptor做入口四个类各司其职。复盘下来改动量不大但解决的是所有服务间调用Header透传的通用问题后续再加需求点只要往里塞字段就行代价极低。5.3 再分享一个不容易注意的细节最后分享一个小技巧如果多个微服务都引入了这套组件为了避免每个服务都写一份可以把FeignHeaderContext、FeignHeaderContextHolder、FeignHeaderInterceptor放到一个单独的公共模块里比如common-spring-boot-starter。这样下游服务只需要引入依赖并配置拦截器Header就自动透传了。我在这次项目里就是这么做的服务端零业务代码改动就接入了统一的Header透传逻辑。如果你的团队有多个微服务都面临同样的诉求建议趁早抽成公共组件别各自为政地复制粘贴。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →