概述
Feign 客户端如果每个消费者各写一份,接口一改就得挨个服务改,pojo 也会慢慢和提供方对不上。这篇把 UserClient、User、默认配置整体抽到独立的 feign-api 模块,order-service 从"自己写"改成"引依赖",跟着做一遍就能跑通。
纲要
- 问题:Feign Client 与 pojo 散落在消费者工程里,重复、易漂移
- 方案选择:一句话交代为什么选"抽取方式"而不是"继承方式"
- 目标依赖方向 :
order-service → feign-api,user-service不反向依赖 - 动手改造六步
- 新建
feign-api模块并配 pom - 把 Client / pojo / 默认配置搬进新模块(含包名规划)
- order-service 引入
feign-api依赖,并删除本地旧类 - 启动类指定 Feign 扫描范围(
basePackages与clients两种写法) - 业务代码改用
cn.itcast.feign.*的接口 - 重启验证,访问订单接口看返回里有没有用户信息
- 新建
- 工程结构树 :
feign-api的真实目录 - 五个坑 :扫描不到 Client、别打成可执行 jar、
@Configuration重复加载、两份同名 bean、pojo 字段不一致 - 收益对比:改造前 vs 改造后
- API 速览 :
@FeignClient与@EnableFeignClients常用属性
选抽取,不选继承
讲义 2.4 最佳实践 给了两条路。继承方式是定义一个 API 接口,让 Feign Client 和 Controller 都去实现它------代码共享了,但服务提供方被拉进来一起耦合,而且 SpringMVC 的参数注解不在继承范围内,Controller 里还得把方法、参数列表、注解再抄一遍。
抽取方式反过来:Client、pojo、默认配置都挪到一个独立模块,谁要用谁引依赖。提供方完全不知道有这个模块存在。
本篇只做抽取方式的动手实现,继承方式的优缺点在同系列另一篇已经展开过,这里不再重复。
改造后的模块依赖方向
#mermaid-svg-6vobxgfxBMH9iRI2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6vobxgfxBMH9iRI2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6vobxgfxBMH9iRI2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6vobxgfxBMH9iRI2 .error-icon{fill:#552222;}#mermaid-svg-6vobxgfxBMH9iRI2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6vobxgfxBMH9iRI2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6vobxgfxBMH9iRI2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6vobxgfxBMH9iRI2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6vobxgfxBMH9iRI2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6vobxgfxBMH9iRI2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6vobxgfxBMH9iRI2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6vobxgfxBMH9iRI2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6vobxgfxBMH9iRI2 .marker.cross{stroke:#333333;}#mermaid-svg-6vobxgfxBMH9iRI2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6vobxgfxBMH9iRI2 p{margin:0;}#mermaid-svg-6vobxgfxBMH9iRI2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6vobxgfxBMH9iRI2 .cluster-label text{fill:#333;}#mermaid-svg-6vobxgfxBMH9iRI2 .cluster-label span{color:#333;}#mermaid-svg-6vobxgfxBMH9iRI2 .cluster-label span p{background-color:transparent;}#mermaid-svg-6vobxgfxBMH9iRI2 .label text,#mermaid-svg-6vobxgfxBMH9iRI2 span{fill:#333;color:#333;}#mermaid-svg-6vobxgfxBMH9iRI2 .node rect,#mermaid-svg-6vobxgfxBMH9iRI2 .node circle,#mermaid-svg-6vobxgfxBMH9iRI2 .node ellipse,#mermaid-svg-6vobxgfxBMH9iRI2 .node polygon,#mermaid-svg-6vobxgfxBMH9iRI2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6vobxgfxBMH9iRI2 .rough-node .label text,#mermaid-svg-6vobxgfxBMH9iRI2 .node .label text,#mermaid-svg-6vobxgfxBMH9iRI2 .image-shape .label,#mermaid-svg-6vobxgfxBMH9iRI2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-6vobxgfxBMH9iRI2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6vobxgfxBMH9iRI2 .rough-node .label,#mermaid-svg-6vobxgfxBMH9iRI2 .node .label,#mermaid-svg-6vobxgfxBMH9iRI2 .image-shape .label,#mermaid-svg-6vobxgfxBMH9iRI2 .icon-shape .label{text-align:center;}#mermaid-svg-6vobxgfxBMH9iRI2 .node.clickable{cursor:pointer;}#mermaid-svg-6vobxgfxBMH9iRI2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6vobxgfxBMH9iRI2 .arrowheadPath{fill:#333333;}#mermaid-svg-6vobxgfxBMH9iRI2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6vobxgfxBMH9iRI2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6vobxgfxBMH9iRI2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6vobxgfxBMH9iRI2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6vobxgfxBMH9iRI2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6vobxgfxBMH9iRI2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6vobxgfxBMH9iRI2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6vobxgfxBMH9iRI2 .cluster text{fill:#333;}#mermaid-svg-6vobxgfxBMH9iRI2 .cluster span{color:#333;}#mermaid-svg-6vobxgfxBMH9iRI2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6vobxgfxBMH9iRI2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6vobxgfxBMH9iRI2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-6vobxgfxBMH9iRI2 .icon-shape,#mermaid-svg-6vobxgfxBMH9iRI2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6vobxgfxBMH9iRI2 .icon-shape p,#mermaid-svg-6vobxgfxBMH9iRI2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6vobxgfxBMH9iRI2 .icon-shape .label rect,#mermaid-svg-6vobxgfxBMH9iRI2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6vobxgfxBMH9iRI2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6vobxgfxBMH9iRI2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6vobxgfxBMH9iRI2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 提供方
共享契约
消费方
依赖:接口 + pojo
编译期依赖 openfeign
HTTP GET /user/1
order-service
cn.itcast.order
feign-api
cn.itcast.feign
user-service
cn.itcast.user
spring-cloud-starter-openfeign
注意 user-service 那一侧没有任何指向 feign-api 的连线。
这张图里最要紧的是箭头方向。feign-api 只管"怎么调",user-service 只管"怎么实现",两边互不知情,中间靠 HTTP 路径和字段名对齐。以后再有别的服务要调用户接口,加一条 X → feign-api 就行,不用重新写 Client,也不会碰上 user-service。
对比继承方式就更清楚了:继承方式必然产生 提供方 → API 接口 这条线,提供方换实现、换 Controller 都得跟着动;抽取方式下 user-service 一点都不知道自己被抽取了。
改造前长什么样
改造前,order-service 里自己养着一套 Client 和 pojo,包名跟着 order 走。大致是这样(包名以你工程的实际情况为准):
java
package cn.itcast.order.clients;
import cn.itcast.order.pojo.User;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@FeignClient(value = "userservice")
public interface UserClient {
@GetMapping("/user/{id}")
User findById(@PathVariable("id") Long id);
}
User 也一样躺在 order-service 的 pojo 包里,DefaultFeignConfiguration 躺在配置包里。这三个东西的共同点是:它们描述的是用户服务的契约,跟订单业务没有半点关系,却占了订单工程的代码。
要搬的就是这三样:UserClient、User、DefaultFeignConfiguration。
步骤一:新建 feign-api 模块
在 cloud-demo 工程里新建一个 Maven module,名字 feign-api,然后先在父 pom 的 <modules> 里登记(不登记父工程就不会编译它):
xml
<modules>
<module>user-service</module>
<module>order-service</module>
<module>eureka-server</module>
<module>feign-api</module>
<module>gateway</module>
</modules>
feign-api 自己的 pom:
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>cloud-demo</artifactId>
<groupId>cn.itcast.demo</groupId>
<version>1.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>feign-api</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
</project>
依赖只写一个 spring-cloud-starter-openfeign,因为 @FeignClient、@GetMapping 这些注解全在它里头。版本号不用写------父 pom 的 <dependencyManagement> 里 import 了 spring-cloud-dependencies,版本统一托管。
@Data 需要 lombok,也不用在这里声明:父 pom 的 <dependencies>(不是 dependencyManagement)里已经放了 lombok,所有子模块直接继承。Jackson 是 openfeign → spring-web → jackson-databind 传递进来的,反序列化 pojo 时本来就要用到,同样不用手写。
注意别在 feign-api 里加 spring-boot-maven-plugin。这个模块是一个普通 jar 库,不是可执行程序,理由在后面的坑里细说。
步骤二:把 Client、pojo、默认配置搬进 feign-api
先建包。包名别跟着 order 走,这个模块不属于任何单一业务,所以用 cn.itcast.feign 打头,再按职责分层:
cn.itcast.feign.clients------ 放@FeignClient接口cn.itcast.feign.pojo------ 放契约实体cn.itcast.feign.config------ 放 Feign 默认配置
UserClient:
java
package cn.itcast.feign.clients;
import cn.itcast.feign.pojo.User;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@FeignClient(value = "userservice")
public interface UserClient {
@GetMapping("/user/{id}")
User findById(@PathVariable("id") Long id);
}
User:
java
package cn.itcast.feign.pojo;
import lombok.Data;
@Data
public class User {
private Long id;
private String username;
private String address;
}
DefaultFeignConfiguration:
java
package cn.itcast.feign.config;
import feign.Logger;
import org.springframework.context.annotation.Bean;
public class DefaultFeignConfiguration {
@Bean
public Logger.Level logLevel(){
return Logger.Level.BASIC;
}
}
这里有个细节,DefaultFeignConfiguration 故意不加 @Configuration。加了它就会被消费者启动类的组件扫描命中,变成全局默认配置------这个坑在后面单独讲。
契约怎么对齐的,看 user-service 那一侧就明白了:
java
package cn.itcast.user.web;
// 省略 import
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public User queryById(@PathVariable("id") Long id,
@RequestHeader(value = "Truth", required = false) String truth) {
System.out.println("truth: " + truth);
return userService.queryById(id);
}
}
@RequestMapping("/user") + @GetMapping("/{id}") 拼出 /user/{id},@FeignClient(value = "userservice") 里的 userservice 是提供方 spring.application.name,路径和方法签名就能对上。User 的字段 id / username / address 两边也一致,JSON 才反序列化得回来。
步骤三:order-service 引入 feign-api 依赖
先删东西:把 order-service 本地的 UserClient、User、DefaultFeignConfiguration 全部删掉。删完代码会一片红,正常,马上就好。
然后在 order-service/pom.xml 里加依赖:
xml
<!--引入feign的统一api-->
<dependency>
<groupId>cn.itcast.demo</groupId>
<artifactId>feign-api</artifactId>
<version>1.0</version>
</dependency>
groupId 和 version 跟父工程 cloud-demo 保持一致,artifactId 就是刚建的那个模块名。
删本地类这一步别省。留着的话,同一个 UserClient 接口在 cn.itcast.order 和 cn.itcast.feign.clients 下各有一份,两个 @FeignClient 注册出来的 bean 名都叫 userClient,Spring 启动时要么报 bean 名冲突,要么让你在 @Autowired 注入时挑一个------挑错了就是改了 A 处代码、跑的是 B 处行为。
步骤四:启动类指定 Feign 扫描范围
@EnableFeignClients 默认只扫它所在包及其子包 。order-service 的启动类在 cn.itcast.order 下,而 UserClient 现在在 cn.itcast.feign.clients 下,两条包路径没有父子关系,扫不到。
讲义给了两种写法。
方式一,指定扫描包:
java
package cn.itcast.order;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
@EnableFeignClients(basePackages = "cn.itcast.feign.clients")
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
/**
* 创建RestTemplate并注入Spring容器
*/
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
方式二,指定具体的 Client 接口:
java
package cn.itcast.order;
import cn.itcast.feign.clients.UserClient;
import cn.itcast.feign.config.DefaultFeignConfiguration;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
@EnableFeignClients(clients = UserClient.class, defaultConfiguration = DefaultFeignConfiguration.class)
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
/**
* 创建RestTemplate并注入Spring容器
*/
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
两个属性的差别:
| 属性 | 类型 | 范围 | 适用 |
|---|---|---|---|
basePackages |
String[] |
该包及子包 下的所有 @FeignClient 接口 |
包里 Client 多、且都确定要用 |
clients |
Class<?>[] |
只注册列出的这几个接口 | 精确控制,用哪个写哪个 |
工程里最终用的是第二种。clients 更精确:假设 cn.itcast.feign.clients 下以后堆了十个 Client(UserClient、OrderClient、PayClient......),basePackages 会把十个全注册成 bean,用不上的那些白白占用容器、还可能触发无关的配置;clients 只加你点名的,多一个不扫。
clients 的另一个好处是编译期就能发现拼错 ------写类名有 IDE 补全和编译检查,basePackages 写个字符串包名,拼错了要等启动时才炸。
顺带提醒:如果后面还要给别处指定 Feign 默认配置,用 defaultConfiguration 属性(如上面代码所示),别去给 DefaultFeignConfiguration 加 @Configuration。
步骤五:业务代码改成从 feign-api 导包
OrderService 里注入的是同一个 UserClient,只是 import 换了地方。
改造前:
java
package cn.itcast.order.service;
import cn.itcast.order.clients.UserClient;
import cn.itcast.order.mapper.OrderMapper;
import cn.itcast.order.pojo.Order;
import cn.itcast.order.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private UserClient userClient;
public Order queryOrderById(Long orderId) {
Order order = orderMapper.findById(orderId);
User user = userClient.findById(order.getUserId());
order.setUser(user);
return order;
}
}
改造后(只动了三行 import):
java
package cn.itcast.order.service;
import cn.itcast.feign.clients.UserClient;
import cn.itcast.feign.pojo.User;
import cn.itcast.order.mapper.OrderMapper;
import cn.itcast.order.pojo.Order;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private UserClient userClient;
public Order queryOrderById(Long orderId) {
// 1.查询订单
Order order = orderMapper.findById(orderId);
// 2.用Feign远程调用
User user = userClient.findById(order.getUserId());
// 3.封装user到Order
order.setUser(user);
// 4.返回
return order;
}
}
业务逻辑一行没改。这也是抽取方式的性价比所在:收益在"下一批消费者"身上------它们连这三行 import 都不用写。
IDEA 里删完本地类之后,把光标放在红色的 UserClient 上按 Alt+Enter → Import class,选 cn.itcast.feign.clients.UserClient 就行;User 同理。启动类上如果之前 import 了本地的 DefaultFeignConfiguration,也要换成 cn.itcast.feign.config.DefaultFeignConfiguration。
改完自查一下 order-service 里还有没有 Feign 相关的代码。正常情况下只剩启动类那一行 @EnableFeignClients------那是开关,必须留着。
步骤六:重启验证
先启动 Nacos、user-service,再启动 order-service。浏览器访问订单接口:
bash
curl http://localhost:8088/order/101
返回里带上 user 字段,就算通了:
text
{"id":101,"price":699900,"userId":1,"name":"Apple 手机",
"user":{"id":1,"username":"柳岩","address":"湖南省衡阳市"}}
user 为 null、或者整个请求 500,说明 Client 压根没注册上,回头看步骤四的扫描范围。
工程结构树
改造完成后 feign-api 的真实结构:
tree
feign-api
├── pom.xml
└── src
└── main
├── java
│ └── cn
│ └── itcast
│ └── feign
│ ├── clients
│ │ └── UserClient.java
│ ├── config
│ │ └── DefaultFeignConfiguration.java
│ └── pojo
│ └── User.java
└── resources
注意它没有 src/main/resources/application.yml,也没有启动类。它是个库,不启动、不占端口、没有自己的配置,被打成 jar 塞进消费者的 BOOT-INF/lib 里。
五个坑
坑一:启动报找不到 Client 的 bean
不指定扫描范围就重启,最典型的报错长这样:
text
***************************
APPLICATION FAILED TO START
***************************
Description:
Field userClient in cn.itcast.order.service.OrderService required a bean of type
'cn.itcast.feign.clients.UserClient' that could not be found.
The injection point has the following annotations:
- @org.springframework.beans.factory.annotation.Autowired(required=true)
Action:
Consider defining a bean of type 'cn.itcast.feign.clients.UserClient' in your configuration.
关键在"编译不报错,注入失败"。类明明存在(依赖也引进来了),但容器里没有它的实例。归因就一句话:@EnableFeignClients 只扫自己所在的包及子包,cn.itcast.order 扫描不到 cn.itcast.feign.clients,.class 被扫到才会生成代理 bean,扫不到就没有。
反过来,别为了这件事把 @SpringBootApplication 的 scanBasePackages 改大。启动类放在哪就扫哪是惯例,扩大业务组件的扫描范围会顺带把一堆无关的 bean 拉进来,副作用比问题本身大。要解决的只是 Feign 客户端的注册范围,用 @EnableFeignClients(basePackages = ...) 或 clients = ... 就够了。
坑二:feign-api 的 pom 里别加 spring-boot-maven-plugin
给 feign-api 的 pom 加上这个插件,mvn package 出来的就不是普通 jar 了:
xml
<!-- 不要这么做!feign-api 是库,不是可执行程序 -->
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
repackage 之后,jar 里的结构从 cn/itcast/feign/... 变成 BOOT-INF/classes/cn/itcast/feign/...,还多出一堆 org/springframework/boot/loader/ 的启动器类。消费者把它当依赖引进来时,JVM 按常规 jar 的方式找类,在 BOOT-INF/classes 下面找不到 cn.itcast.feign.clients.UserClient,直接 NoClassDefFoundError 或 ClassNotFoundException。
这个坑在 MyBatis 的实体模块、公共工具模块上都踩过无数次,判断标准很简单:这个模块自己有没有 main 方法和启动类。有→可以加插件;没有→一定不加。
坑三:feign-api 里的 @Configuration 会被重复加载
如果给 DefaultFeignConfiguration 加上 @Configuration 注解放进 feign-api,消费者启动时组件扫描会命中外面的 cn.itcast.feign.config 包(引进依赖后这类扫描场景很常见),这个配置就被当成消费者自己的配置注册进容器。里面那个 Logger.Level.BASIC 随即生效,对消费者所有的 Feign 客户端生效,而不只是你原来想指定的那一个。
所以工程里的 DefaultFeignConfiguration 是裸类,只能通过显式引用生效:
java
@EnableFeignClients(clients = UserClient.class,
defaultConfiguration = DefaultFeignConfiguration.class)
或者挂在单个客户端上:
java
@FeignClient(value = "userservice", configuration = DefaultFeignConfiguration.class)
一个影响全局、一个只影响当前 Client,选哪个看需求。想深挖的话跟自定义 Feign 配置那篇的作用域问题是同一回事。
坑四:本地和 feign-api 各留一份同名接口
删本地类没删干净,或者从 Git 拉了个旧分支带回来一份,就会出现两个 @FeignClient(value = "userservice") 接口。默认 bean 名按类名首字母小写生成,两边都是 userClient,注册时冲突。即使靠 @Primary 之类的手段绕过去,也容易出现"改了 feign-api 里的路径、请求还是打到老接口上"这种更恶心的问题。
排查办法:往 OrderService 的 import 上看一眼,是 cn.itcast.feign.clients.UserClient 还是 cn.itcast.order.clients.UserClient;再在 IDEA 里 Ctrl+Alt+Shift+N 搜 UserClient.class,只应该命中一处。
坑五:pojo 字段不一致导致反序列化丢字段
User 在 feign-api 和 user-service 里各有一份拷贝(这是抽取方式的固有代价,契约靠两边同步)。如果 feign-api 里的 User 少写了字段,比如漏了 address:
java
@Data
public class User {
private Long id;
private String username;
// 少了一个 address
}
user-service 返回的 JSON 里 address 有值,Jackson 反序列化时找不到对应属性,默认策略是静默忽略 。结果不报错,但 user.getAddress() 一直是 null,排查起来相当费劲。
反过来的方向更糟,如果消费方多写了提供方没有的字段(FAIL_ON_UNKNOWN_PROPERTIES 开着的话),直接抛 UnrecognizedPropertyException。
值得记一句的是:user-service 那边给 User 加字段时,feign-api 要同步加。抽取方式用"两份 pojo"换来了模块解耦,代价就是这种人工同步------字段多的时候,或者团队里服务数量多的时候,就得考虑用 springdoc 生成的契约来兜底了。
改造收益对比
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 重复代码量 | 每个消费者各写一份 Client + pojo | 全工程只此一份,消费者零代码 |
| 契约一致性 | 靠各服务自觉,容易漂移 | 单点维护,改动集中在一处 |
| 改接口的影响面 | 改 N 个服务,逐个发版 | 改 feign-api 一处,消费者升个依赖版本 |
| 依赖方向 | 提供方与消费方无共享契约,各写各的 | 提供方完全不知道 feign-api;只有消费方依赖它 |
| 新增服务的成本 | 新服务要用用户接口,重写一遍 | 引依赖 + 一行 @EnableFeignClients(clients = ...) |
| 模块/构建成本 | 无 | 多一个 Maven 模块,多一次发版 |
| 排查成本 | Client 分散,路径对不对要在多个工程里找 | 统一在 feign-api 一处,改哪找哪 |
API 速览
| 注解 / 属性 | 作用 | 要点 |
|---|---|---|
@FeignClient(value = "userservice") |
声明一个 Feign 客户端,value/name 是服务名 |
服务名必须等于提供方的 spring.application.name |
@FeignClient(path = "/user") |
给该客户端所有方法加统一路径前缀 | 与接口里的 @GetMapping 拼接 |
@FeignClient(configuration = XxxConfig.class) |
只对当前客户端生效的配置类 | 该配置类不要加 @Configuration,避免全局生效 |
@EnableFeignClients(basePackages = "cn.itcast.feign.clients") |
指定要扫描的包(含子包) | 批量注册,字符串包名编译期不校验 |
@EnableFeignClients(clients = UserClient.class) |
指定要注册的接口 | 推荐,精确、IDE 可补全、编译期可校验 |
@EnableFeignClients(defaultConfiguration = XxxConfig.class) |
所有客户端共享的默认配置 | 与 configuration 的区别是作用域 |
官方文档
- Spring Cloud OpenFeign
- Spring Cloud OpenFeign - Creating Feign Clients Manually / @EnableFeignClients
总结
- 抽取方式把"怎么调"和"怎么实现"彻底分开:
feign-api只依赖 openfeign,user-service完全不知道它的存在,依赖是单向的。 - 动手顺序别乱:新建模块 → 搬 Client/pojo/配置 → 消费者引依赖 → 删本地旧类 → 指定扫描范围 → 改 import → 重启验证。
@EnableFeignClients(clients = UserClient.class)比basePackages好用,包数量和拼写风险都小。- 最容易卡住的两个点:扫描包忘了指定(编译过了但注入失败),以及给库模块加了
spring-boot-maven-plugin(依赖进来找不到类)。 - 抽取的代价是 pojo 有两份、需要人工同步,字段一变就要记得两边都改。