news 2026/10/15 15:04:20

OpenFeign 学习笔记(下):公共模块与上下文透传,从能跑到用好

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OpenFeign 学习笔记(下):公共模块与上下文透传,从能跑到用好

一、同样的接口,为什么要写三遍

上篇把 RestTemplate 改成了 Feign,调用确实清爽了。但新问题跟着出现:ItemClient 这个接口,cart-service 里写了一份,trade-service 要调 item-service,同样的接口再写一份。项目里三个服务调 item-service,就有三份几乎一样的接口定义,外加各自维护的 DTO。

重复带来的问题比"代码难看"更实际:

  • 改一处要改三处:item-service 的路径从/items/{id}改掉,三个调用方挨个改,漏一个就是线上 404
  • 版本不一致:A 服务还在用 v1 的接口定义,B 服务已经升到 v2,同一个 item-service 在不同调用方眼里像两个服务
  • DTO 字段不同步:这边的 ItemDTO 多了个字段,那边还是旧定义,序列化时字段对不上,排查半天发现是两边的类不一样

问题的根源是:Feign 接口是"服务间契约",契约却以私有代码的形式散落在每个调用方仓库里。契约散落,就注定会漂移。

解法方向很自然:把所有服务间共享的东西集中到一个地方,谁要用谁依赖它。这就是下篇第一条主线——抽取公共 API 模块。


二、契约该放在哪:hm-api 的边界

做法是在父工程下新建一个模块hm-api,专门存放服务间共享的内容。

什么该放,什么不该放

放进去的:

  • Feign 接口:ItemClient、CartClient 这类"怎么调别人"的定义
  • 服务间传输的 DTO:ItemDTO、OrderFormDTO,双方用同一份类做序列化
  • 公共常量、枚举:错误码、状态值这类双方都要认同的约定

不放进去的:

  • 业务逻辑:各服务自己的编排和规则,放进来就变成互相绑架
  • 数据源、缓存这类基础设施配置:那是服务私有的部署问题,和共享无关

判断标准其实就一条:这东西是不是"两个服务都必须认同的约定"。是,就进 hm-api;只是某个服务自己的实现,就留在原地。"怎么放"是操作问题,"放什么"才是工程判断——模块边界画错了,集中管理反而会变成大泥球。

其他服务怎么用

两步。第一步,调用方引入依赖:

<dependency><groupId>com.hmall</groupId><artifactId>hm-api</artifactId><version>1.0-SNAPSHOT</version></dependency>

第二步,启动类指定扫描范围:

@SpringBootApplication@EnableFeignClients(basePackages="com.hmall.api.client")publicclassCartApplication{publicstaticvoidmain(String[]args){SpringApplication.run(CartApplication.class,args);}}

包结构大概长这样:

com.hmall ├── api ← hm-api 模块 │ ├── client ← Feign 接口:ItemClient、CartClient │ └── dto ← 共享 DTO:ItemDTO、OrderFormDTO ├── cart ← cart-service │ └── CartApplication ← 启动类 └── trade ← trade-service └── TradeApplication

这里有三个容易踩的地方:hm-api 要 install 到本地仓库、启动类要写basePackages、依赖要真正引进来。为什么容易踩,下一节展开。

补充提醒:三个服务都依赖同一个1.0-SNAPSHOT,谁改了 hm-api 谁就要重新 install,但其他服务本地可能还是旧的。生产里通常靠私服(Nexus)+ 正式版本号解决,学习阶段可以先记住这个隐患。


三、模块抽出去了,Bean 却找不到了

模块抽出去之后,我启动 cart-service 直接报错:

无法自动装配。找不到 'ItemClient' 类型的 Bean。

启动没到业务代码就挂了。这个报错的表面意思是"容器里没有 ItemClient 这个 Bean",但 ItemClient 明明写在 hm-api 里、注解也齐,Bean 为什么没生成?

排查按三个方向依次走。

方向一:hm-api 依赖有没有真正引进调用方

最容易漏的一步。hm-api 是新模块,如果 cart-service 的 pom.xml 没加它的 dependency,编译期就会挂——但这次能编译通过,说明依赖是引了的。这条排除。

方向二:hm-api 有没有 install 到本地仓库

这个是当时真正卡住我的地方。hm-api 是独立模块,改了内容之后必须mvn install到本地仓库,调用方才能拉到最新版本。我第一次建模块的时候漏了这步,cart-service 引的是本地仓库里旧版本的 hm-api,那个旧版本里根本没有 ItemClient——所以编译期一切正常,运行时容器里扫不到这个类,自然也就没有它的 Bean。

在 IDEA 右侧 Maven 面板里,展开 hm-api → Lifecycle → 双击 install,把最新内容推到本地仓库,这个方向的坑就排掉了。

方向三:启动类的扫描范围

如果前两步都没问题,那多半是@EnableFeignClients没指定basePackages。Feign 默认只扫启动类所在包及子包,ItemClient 现在在com.hmall.api.client下,不在com.hmall.cart下,扫不到。加上basePackages之后,Bean 才真正被注册进来。

原因小结

这次报错本身不复杂,但排查顺序值得记一下。三步走下来——先看依赖有没有引,再看模块有没有 install,最后看扫描路径有没有对——从外到内一层层排除,比乱猜快得多。其中"模块抽出来后忘了 install"是最容易漏的,因为它不报编译错,只在运行时冒出来。

延伸

这也是抽取模块类问题的通用排查套路:依赖 → 安装 → 扫描路径。三条都是静态检查就能确认的东西,不需要开 debug。以后把某个类挪进公共模块后发现"找不到",先跑一遍这三步再往下查。


四、调用失败看不到请求?先开日志

Feign 默认什么都不打:调用失败只能看到异常堆栈,看不到实际发出的 HTTP 请求——什么方法、什么 URL、返回什么状态码,一概没有。排查问题时先开日志,几乎是条件反射。

Feign 用 Logger.Level 控制打多详细,一共四档:

  • NONE:什么都不打(默认)
  • BASIC:请求方法、URL、响应状态码、耗时
  • HEADERS:BASIC 基础上加请求/响应头
  • FULL:HEADERS 基础上再加 body 和元数据

第一步,声明 Level:

@ConfigurationpublicclassFeignConfig{@BeanpublicLogger.LevelfeignLoggerLevel(){returnLogger.Level.BASIC;}}

配完这一句,很多人发现日志还是没出来。还需要第二步:

logging:level:com.hmall.api.client:debug

为什么两步缺一不可,把两层机制拆开就清楚了:

Logger.Level → 决定「有没有内容」(Feign 层) logging.level → 决定「内容出不出去」(Spring 日志层)

第一层,Logger.Level回答"Feign 要不要生成日志内容"。默认 NONE,Feign 根本不组装请求详情,这时候光调logging.level也没用——没内容可打。

第二层,logging.level回答"Spring 的日志体系要不要输出"。Feign 的日志走它所在包(这里是com.hmall.api.client)的 logger,Spring 默认级别 INFO,debug 内容直接被过滤。只有Logger.Level没有logging.level,内容生成了也在输出环节被丢弃。

两层各管一段。配置不生效类的问题以后都可以套这个思路:把链路每一层过一遍,确认是不是哪一层没放行,而不是只盯着自己改的那一步。

日常排查开 BASIC 看请求行就够了,FULL 会把 body 全打出来,排查序列化问题时再开。


五、默认客户端够用吗

上篇提过,Feign 默认的 HTTP 客户端是 JDK 的 HttpURLConnection。它的问题不在功能,在连接管理:没有连接池,每次调用都新建 TCP 连接,用完就断。低并发下感知不到,QPS 一上来,握手和挥手的开销就占了大头。

生产环境的常规做法是换成带连接池的客户端,OkHttp 是常见选择——连接复用、支持 HTTP/2、API 也顺手。配置两步,依赖:

<dependency><groupId>io.github.openfeign</groupId><artifactId>feign-okhttp</artifactId></dependency><dependency><groupId>com.squareup.okhttp3</groupId><artifactId>okhttp</artifactId></dependency>

开关:

spring:cloud:openfeign:okhttp:enabled:true

换客户端对业务代码完全透明——接口、注解一行都不用动,这层抽象的好处在这里也体现出来了。

但我学习阶段没有换。原因很简单:链路都没跑通、瓶颈在哪都不知道,换了也只是心理安慰。真正需要动它的信号是压测数据或线上指标——响应时间随并发上涨明显变陡、连接数持续走高,这时候再换,收益能被数据验证。"为了性能而性能"是另一类坑:链路还没跑通就先上连接池,出问题反而多了排查维度。


六、userId 为什么丢了:拦截器与 ThreadLocal

先还原场景。外部请求带着 JWT 进来,网关校验后解析出 userId,放进请求头传给 cart-service——到这里都正常。cart-service 处理业务时用 Feign 调 trade-service,trade-service 拿到的请求头里,userId 没了。

为什么不会自动带

Feign 发出的是一个全新的 HTTP 请求:重新建连接、重新组头,和上游那个请求没有任何继承关系。请求头不会自动穿越一次方法调用——除非有人显式地把它带过去。每个方法手动传 userId 也不现实:调用点一多必然遗漏,业务方法的签名还会被一个和业务无关的参数污染。上下文就该走上下文的通道。

RequestInterceptor:每次请求发出前插一脚

上下文的存储先用一个简单的 ThreadLocal 工具承载:

publicclassUserContext{privatestaticfinalThreadLocal<String>HOLDER=newThreadLocal<>();publicstaticvoidset(StringuserId){HOLDER.set(userId);}publicstaticStringget(){returnHOLDER.get();}publicstaticvoidclear(){HOLDER.remove();}}

Feign 留了拦截器接口,每次请求发出前都会执行:

@ComponentpublicclassFeignAuthInterceptorimplementsRequestInterceptor{@Overridepublicvoidapply(RequestTemplatetemplate){StringuserId=UserContext.get();if(userId!=null){template.header("user-id",userId);}}}

从 ThreadLocal 取出 userId,塞进这次 Feign 请求的头部。cart-service 收到上游请求后把 userId 存进 UserContext 是各服务自己的约定,拦截器提供的是"发请求前统一补头"的位置。

和 Gateway 的分工

透传这件事,网关和 Feign 拦截器各管一段,是接力不是重叠:

外部请求 │ ① 网关:解析 JWT,把 userId 写入请求头 ▼ cart-service ────── Feign 调用 ──────▶ trade-service ② 拦截器:从上下文取 userId, 补进这个全新的请求头 ▼ trade-service 读到完整的 user-id

网关负责第一跳(外部 → 第一个微服务),Feign 拦截器负责后续每一跳(微服务 → 微服务)。这个分工要理清:只配网关,服务间调用会丢用户;只配拦截器,请求刚进来时根本没人写 userId。

ThreadLocal 用完要清理

userId 存在 ThreadLocal 里,而 Tomcat、Feign 底层都有线程池。线程用完归还,下一个请求复用这个线程——如果上一个请求的 userId 没清掉,新请求就读到了别人的数据,轻则串号,重则是越权隐患。所以 set 要和 remove 成对出现,通常放在请求结束的 finally 或 afterCompletion 里。

这个点也是面试常挖的方向:跨进程传上下文,写进去只是第一步,收干净才是完整实现。


七、这类问题的排查清单

用户信息透传这条链路,从入口到出口会经过好几个环节,任何一环掉链子都会表现为"下游拿不到 userId"。真遇到时,按下面的顺序过一遍,比乱猜快:

位置检查点验证手段
网关过滤器是否注册成 Bean + 是否被执行log.debug("写入 user-id = {}", userId)+ 看网关日志
Feign 拦截器@Component是否被扫到 +apply()是否调用log.debug(...)+ 看调用方日志
ThreadLocal是否在线程切换中丢失异步场景单独验证(@Async、线程池)

几个补充说明:

入口:第一跳的写入在网关的 GlobalFilter 里。如果日志里没有"写入"这行,说明请求被排除列表放行了,或者过滤器根本没注册成 Bean。

中间:拦截器要满足两个条件才会执行——加了@Component且被启动类所在包扫到,实现了RequestInterceptor接口。不满足任何一个,apply()就不会被调用。这里也提醒一句:拦截器属于全局配置,会作用于所有 Feign 调用,写的时候要注意是不是"所有调用都需要带这个头"。

出口:ThreadLocal 有个常见的丢数据场景——异步线程里取不到值。如果 Feign 调用发生在@Async、CompletableFuture或线程池里,ThreadLocal 里存的 userId 在新线程里是空的,因为 ThreadLocal 绑定的是"当前线程",换线程就断链了。这种场景要么用 InheritableThreadLocal(有局限:线程池场景下子线程创建时机导致继承不可靠),要么用阿里的 TransmittableThreadLocal(TTL,专门解决线程池透传),要么显式地把上下文传到新线程里。

完整排查思路

把这三个位置串起来就一句话:入口有没有写、中间有没有丢、出口有没有带。三处都加日志,一次调用跑下来,哪一环是空的立刻就看出来了。这种"跨进程传上下文"的问题以后还会遇到(traceId、tenantId、灰度标签),排查套路是同一套。


八、契约、上下文、边界:Feign 的位置

两篇学完,我对 Feign 的定位有了个更具体的说法:它不只是 HTTP 客户端,更是"服务间契约"的载体。

契约在哪,代码就在哪。上篇的接口定义分散在各服务时,契约实际上是模糊的——同一份调用在三个仓库里有三份拷贝,谁都不是权威。抽到 hm-api 之后,接口和 DTO 从"各个服务的私有代码"提升为"团队共享的资产":改契约等于改一处,编译器立刻让所有依赖方显形。工程上这一步比"少写几行代码"重要得多,它把口头约定变成了编译期约束。

顺着这条线往大了看,"上下文在服务间传递"是分布式系统的通用问题,用户信息只是其中一个实例。链路追踪要传 traceId、多租户要传 tenantId、灰度调度要传路由标签——都是同一类需求:第一个进程建立的上下文,要在后续每一跳都活着。Feign 拦截器是这类需求在 HTTP 调用层面的标准解法,以后学 Sleuth 或 SkyWalking,看到的还是同一套机制:入口写入、每跳接力、出口清理。

最后是边界。Feign 适合服务间通用的、稳定的、数量不太多的调用——查商品、扣库存这类典型场景正合适。需要极致性能或自定义协议的场景,该让 Dubbo、gRPC 上;调用关系复杂到需要按流量比例切分的,也超出 Feign 的能力范围。知道它擅长什么,比记住它的注解更有用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 15:04:17

工业级三维装箱求解器:Guillotine+BFD启发式实现

简介&#xff1a;本资源是面向算法学习者与Java开发者的三维装箱问题&#xff08;3D-Bin-Packing&#xff09;实战实现&#xff0c;聚焦经典NP难组合优化问题在物流、仓储等场景中的工程落地。项目采用Java语言实现“最佳适应”&#xff08;BestFit&#xff09;启发式算法&…

作者头像 李华
网站建设 2026/10/15 15:03:50

SpringBoot+Vue全栈开发田园系统实战

1. 项目概述&#xff1a;乐享田园系统的技术架构与核心价值乐享田园系统是一款面向现代都市人群设计的线上田园体验平台。这个项目采用了当前主流的前后端分离架构&#xff0c;前端基于Vue.js框架实现响应式用户界面&#xff0c;后端采用SpringBootMyBatis技术栈构建RESTful AP…

作者头像 李华
网站建设 2026/10/15 15:03:07

Codex 安装配置与项目实战:从环境准备到前后端接入的完整指南

1. 从零认识 Codex&#xff1a;它到底能帮你做什么第一次接触 Codex 的人&#xff0c;最容易犯的错就是把它当成一个“更聪明的聊天窗口”。我刚开始也这么想&#xff0c;结果折腾了一整天才发现&#xff0c;它真正的价值在于把自然语言直接翻译成可执行的代码、命令和配置&…

作者头像 李华
网站建设 2026/10/15 15:03:06

自定义类型:枚举与联合类型的核心原理与应用

1. 类型系统进阶&#xff1a;为什么需要自定义类型在编程语言设计中&#xff0c;类型系统就像建筑的地基&#xff0c;决定了代码的结构安全性和表达灵活性。基础数据类型&#xff08;如整型、字符串&#xff09;虽然能满足简单需求&#xff0c;但当面对复杂业务逻辑时&#xff…

作者头像 李华
网站建设 2026/10/15 15:02:33

Spring Boot 4.X(2025发布)与MyBatis-Plus 3.5.7不兼容报错

踩坑记录&#xff1a;如题&#xff0c;会报Property sqlSessionFactory or sqlSessionTemplate are required。4.x使用spring-boot-starter-webmvc和spring-boot-starter-webmvc-test。3.x使用spring-boot-starter-web和spring-boot-starter-webmvc-test。解决办法&#xff1…

作者头像 李华