一、每加一个接口,就要重写一遍调用逻辑
Nacos 那篇里,cart-service 调 item-service 的完整流程是:拿到服务名、查实例列表、挑一个、拼 URL、发请求。当时手写一遍是为了看清楚底层在做什么,跑通之后也没觉得有多麻烦。
真正的问题出现在业务代码开始变多之后。每加一个远程调用,就要复制一遍那段逻辑:
ServiceInstanceinstance=loadBalancer.choose("item-service");Stringurl="http://"+instance.getHost()+":"+instance.getPort()+"/items/"+id;ItemDTOitem=restTemplate.getForObject(url,ItemDTO.class);写到第十个接口的时候,三个痛点堆在一起了。
URL 全靠手拼,少一个斜杠、多一个字母,编译不报错,跑起来才发现 404,排查半天才定位到是路径写错。服务发现 + 负载均衡 + HTTP 调用这三段式,每个服务、每个接口都要来一遍,重复得厉害。更要命的是接口定义和调用是分离的:item-service 把/items/{id}改成/product/{id},编译器不会提醒任何调用方,直到线上请求报错才知道。
于是问题变成:远程调用能不能写得像本地方法调用一样——我只管声明"调哪个服务的哪个接口",剩下的事情框架去做?Feign 回答的就是这个问题。
二、Feign 把什么藏起来了
OpenFeign 的定位一句话就能说完:声明式 HTTP 客户端。你只写一个 Java 接口、配上注解,框架负责在运行时生成实现,把方法调用翻译成真实的 HTTP 请求。
和 RestTemplate 放在一起对比,最核心的差别其实只有两处:
| 对比项 | RestTemplate | OpenFeign |
|---|---|---|
| 写法 | 手写 URL、参数手动拼 | 接口 + 注解,声明式 |
| 接口变更感知 | 调用代码与接口定义分离 | 接口即契约,改动集中可见 |
其余几点——负载均衡内置、URL 不散落在业务代码里——说到底都是这两点的延伸。
“声明式"和"命令式"的区别值得体会一下:RestTemplate 是命令式,告诉它每一步怎么做,拼这个 URL、发那个请求;Feign 是声明式,只说结果要什么,调这个接口,过程交给框架。代码从"描述过程"变成"描述意图”,可读性就是从这里来的。
容易搞混的是 Feign 和 LoadBalancer 的关系,这里说清楚:两者不是竞争,是分工。LoadBalancer 解决"一堆实例里选哪个",Feign 解决"怎么把方法调用变成 HTTP 请求"。选实例这一步,Feign 自己不做,还是交给 LoadBalancer——上篇那条"服务名 → 实例"的链路原样保留,Feign 只是在它前后各包了一层。
三、第一次用:一个空接口就能干活
用起来的步骤很短,四步走完。
第一步,加依赖:
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>第二步,启动类开启 Feign 扫描:
@SpringBootApplication@EnableFeignClientspublicclassCartApplication{publicstaticvoidmain(String[]args){SpringApplication.run(CartApplication.class,args);}}第三步,声明 Feign 接口。@FeignClient里填目标服务名,方法上的注解写路径:
@FeignClient(name="item-service")publicinterfaceItemClient{@GetMapping("/items/{id}")ItemDTOgetItem(@PathVariable("id")Longid);}小细节:
@FeignClient("item-service")里的字符串实际是name属性的简写,官方推荐显式写name = "item-service"。将来和contextId、多客户端场景配合时更清晰。
这三个注解其实和 Spring MVC 是同一套东西——@FeignClient指定服务名,@GetMapping写 HTTP 方法和路径,@PathVariable标参数。会写 Controller,就会写 Feign 接口,几乎没有学习成本。
第四步,和普通 Service 一样注入使用:
@ServicepublicclassCartServiceImplimplementsCartService{privatefinalItemClientitemClient;publicCartServiceImpl(ItemClientitemClient){this.itemClient=itemClient;}publicCartVOgetCartDetail(LongskuId){ItemDTOitem=itemClient.getItem(skuId);// 组装购物车数据……returnbuildCartVO(item);}}对比改造前的 RestTemplate 写法,变化一目了然:原先"拿实例、拼 URL、发请求"的十几行,现在只剩一行itemClient.getItem(skuId)。服务名写在类上,HTTP 方法和路径写在方法上,参数注解标明变量——远程调用的全部信息都集中在接口里,业务代码看不到任何 URL。
写法确实变简单了。但"为什么写个空方法的接口就能调通",下一节拆开看。
四、空接口为什么能跑:动态代理拆开看
Feign 用起来像魔法:接口里一个方法体都没有,注入进来却能干活。把这层魔法拆开,核心就两样东西:动态代理 + HTTP 客户端。
启动时,Spring 扫描到@FeignClient注解的接口,通过JDK 动态代理生成它的实现类——注意是代理,不是谁手写的实现。这个生成的实现类被注册成 Bean,所以能注入使用。
更具体一点:JDK 动态代理生成的类实现了 ItemClient 接口,本身不写任何业务逻辑,所有方法调用都被转发到InvocationHandler的invoke方法。Feign 的整个"翻译"工作——从注解到 HTTP 请求——都发生在那个 invoke 里。这也顺带解释了一个约束:JDK 代理要求目标是接口,所以@FeignClient只能注在接口上,注在类上不生效。
一次调用的完整路径是这样的:
业务代码 │ itemClient.getItem(1L) ▼ JDK 动态代理 │ 读取注解:GET /items/{id},id=1 → GET /items/1 ▼ LoadBalancer(复用上篇那条链路) │ item-service → 192.168.10.3:8081 ▼ HTTP 客户端(默认 HttpURLConnection) │ 发出 GET http://192.168.10.3:8081/items/1 ▼ 响应 JSON ── 反序列化 ──▶ ItemDTO,返回给业务代码逐步拆开看:
- 业务代码调用接口方法,控制权进入动态代理
- 代理按方法上的注解构造请求模板:HTTP 方法、路径、参数各就各位
- 请求带着服务名,交给 LoadBalancer 选一个具体实例,把服务名换成 host 和 port——这一步就是上篇 Nacos 讲过的"服务名 → 真实 IP"
- 底层 HTTP 客户端(默认 JDK 的 HttpURLConnection)发出真实请求
- 响应经过反序列化(默认 Jackson),转成方法声明的返回值,回到业务代码
对照来看,Feign 省掉的是三个"手动":手动拼 URL、手动做负载均衡、手动处理序列化和反序列化。它没有发明新的服务发现机制——"服务名怎么变成 IP"这一段完全沿用 Nacos + LoadBalancer 的既有流程,Feign 做的是把这段流程整个包进代理里,让业务代码感知不到它的存在。
这也是理解 Feign 的关键:它是一层封装,不是一套替代。
五、迁移没做彻底:提供方删了,使用方还在找
改造 CartServiceImpl 的过程中我踩了一个很典型的坑,和 Feign 本身没关系,但很能说明"迁移类问题"该怎么排查。
启动 cart-service 时直接报错:
Parameter 0 of constructor in com.hmall.cart.service.impl.CartServiceImpl required a bean of type 'org.springframework.web.client.RestTemplate' that could not be found.错误信息很明确:CartServiceImpl 的构造函数需要一个 RestTemplate 类型的 Bean,但容器里没有。
第一反应是"RestTemplate 的 Bean 是不是被删了?"去看启动类,确实之前注册 RestTemplate 的@Bean方法已经被我删掉了——因为迁移到 Feign 之后,这个 Bean 本来就不需要了。
既然 Bean 已经删了,为什么还有人在要它?回头看报错的地方:CartServiceImpl 的构造函数。打开这个类,发现里面还留着private final RestTemplate restTemplate;字段和对应的构造参数,虽然业务方法里已经改用 itemClient 了,但这个依赖还挂在那里。
一句话概括当时的处境:提供方删了,使用方没删。
原因很清楚:迁移做得不彻底。@Bean RestTemplate删掉了,ItemClient 也接进来了,但 CartServiceImpl 里旧字段没清。Spring 在启动时按构造函数注入依赖,一看容器里没有 RestTemplate Bean,直接抛 NoSuchBeanDefinitionException,应用启动失败。
解决办法是把 CartServiceImpl 里所有 restTemplate 相关的残留清掉:删掉字段、去掉构造函数参数、全局搜索确认整个模块没有残留调用。改完后启动正常,RestTemplate 在项目里彻底退场,全部走 Feign。
这个坑小,但暴露了一类问题的通用排查思路——迁移类问题,先理清"谁依赖谁",再决定删什么。
迁移的常见流程是先加新的、再删旧的,但中间那一刻代码是"新老混用"的。如果只删"提供方"不删"使用方",就会像这次一样:旧的 Bean 已经不存在,依赖它的人还在找它。
具体到 Spring 项目,收到required a bean of type 'XXX' that could not be found这类报错时,按三步走:
- 谁在找这个 Bean?从报错的类名入手,找到构造参数或
@Autowired字段。 - 谁应该提供这个 Bean?是
@Bean方法、还是@Component扫描、还是 Feign 生成的代理——确认它为什么没有生效。 - 这两者能不能对上?如果提供方已经合理删除(比如换成了 Feign),那使用方也应该同步改掉;如果提供方意外丢失,就把提供方补回来。
这次属于第一种情况,使用方该删。判断的关键不是"能不能让它跑起来",而是"这个依赖在新架构里还需不需要"。迁移到 Feign 之后,RestTemplate 是被替代的角色,正确的处理是彻底送走,而不是为了让编译通过再补一个@Bean回去。
六、它伪装了什么,又藏起了什么
Feign 做的抽象可以概括成一句话:把"网络调用"伪装成"本地方法调用"。
"伪装"这个词是中性的。好处前面已经看得很清楚:调用方不用关心协议是 HTTP 还是别的、JSON 怎么序列化、实例怎么挑——这些全部下沉到框架里,业务代码里只剩一行方法调用。
代价藏在"本地"两个字里。本地方法调用的失败模式很单纯:要么正常返回,要么抛异常,而且异常基本是确定性的——参数错了、逻辑错了,重跑一次还是同样的结果。网络调用的失败模式完全是另一回事:请求可能超时,响应可能只回来一半;超时的请求服务端也许已经处理成功,客户端再发一次就成了重复提交。这些情况,写惯了单体代码的人一开始根本意识不到。
所以用 Feign,不能只享受"写起来像本地"的方便,必须同时接受"它本质上是网络"的现实——超时、重试、熔断这些机制不是可选项,是配套项。没有它们,一行优雅的itemClient.getItem(id)背后可能是无限转圈的线程。这块是下篇搭配 Sentinel 要处理的主题。
把视野拉远一点:所有 RPC 框架做的都是同一件事,把远程调用伪装成本地调用。Dubbo、gRPC、Thrift 都在这个抽象层级上做文章,区别在于协议选型(HTTP 还是自定义二进制)、序列化方式(JSON 还是 Protobuf)以及各自的性能优化。Feign 选了 HTTP 这条最通用的路——生态最广、调试最方便,代价是文本协议的性能上限。选哪条路没有标准答案,但至少要清楚自己站在哪一级抽象上,以及这级抽象替你挡了什么、又藏了什么。
方法好写了,新问题也跟着来:接口散落在各个服务里怎么管理、调用日志去哪看、超时重试怎么配、登录用户的上下文怎么透传。这些留给下篇,从抽取公共模块开始。