概述
上一篇用RestTemplate+@LoadBalanced打通了 order-service 到 user-service 的远程调用,能用,但写法笨重。本文用 Feign 把远程调用改造成"调本地方法"的体验,四步完成改造,并顺手把底层原理和常见的启动坑讲清楚。
纲要
- RestTemplate 的问题
- URL 硬编码、字符串拼接易错
- 复杂参数难以维护
- 编程体验不统一(写的是 HTTP 请求,不是业务方法)
- Feign 是什么
- 声明式 HTTP 客户端,类比声明式事务
- 一个 HTTP 请求的五要素:服务名称、请求方式、请求路径、请求参数、返回值类型
- 四步改造 order-service
- 引入
spring-cloud-starter-openfeign依赖 - 启动类加
@EnableFeignClients - 编写
UserClient接口(@FeignClient+ SpringMVC 注解) OrderService注入接口直接调用
- 引入
- 改造前后对比:RestTemplate 写法 vs Feign 写法
- 底层原理:动态代理 + 集成 Ribbon 负载均衡
- 实战避坑:注解扫描不到、参数注解缺失、服务名大小写、返回类型不一致
RestTemplate 到底差在哪
先看改造前的代码,这是 order-service 里查询订单时远程查询用户的逻辑:
// 2.利用RestTemplate发起http请求,查询用户// 2.1.url路径:服务名写死在字符串里,参数靠手工拼接Stringurl="http://userservice/user/"+order.getUserId();// 2.2.发送http请求,实现远程调用Useruser=restTemplate.getForObject(url,User.class);这段代码已经是基于 Ribbon 做过优化的版本了——URL 里写的是服务名userservice而不是 IP + 端口,负载均衡已经生效。但它依然有三个硬伤:
| 问题 | 具体表现 | 后果 |
|---|---|---|
| 可读性差 | 一段代码里混着 URL、请求方式、参数拼接、返回类型转换 | 没接触过远程调用的人第一眼看不懂 |
| 参数拼接易错 | 路径参数靠+手工拼接 | 参数一多就乱,拼错路径只会在运行时报 404 |
| 编程体验不统一 | 业务代码里到处写的是"怎么发请求",而不是"要做什么" | 正常写业务都是调方法,这里突然冒出一个 URL 字符串 |
参数复杂时问题会被放大。回想一下在浏览器里访问 Nacos 控制台、或者用百度搜索时地址栏里那一长串参数——七八个参数拼在 Java 字符串里维护,将来参数一变,改代码就是灾难。
Feign:把发请求的五个信息"声明"出来
Feign 是一个声明式的 HTTP 客户端。
"声明式"这个概念在 Spring 声明式事务里已经见过:早期手动开事务、提交事务、回滚,后来只需要告诉 Spring 规则,剩下的事框架做。Feign 同理——你把发 HTTP 请求所需要的信息声明出来,请求本身由 Feign 帮你发。
发一个 HTTP 请求,恰好需要五个信息:
- 服务名称(发给谁)
- 请求方式(GET / POST)
- 请求路径
- 请求参数
- 返回值类型
Feign 的做法是:定义一个接口,把这五个信息全部用注解声明在接口上,运行时由 Feign 生成实现并发请求。声明完之后,业务代码里只剩下"调接口的方法"这一件事。
四步改造 order-service
改造只动 order-service 这个消费方,user-service 作为提供方一行不改。改造后的 order-service 结构:
order-service ├── pom.xml # 第一步:在这里加 feign 依赖 └── src/main/java/cn/itcast/order ├── OrderApplication.java # 第二步:加 @EnableFeignClients ├── client │ └── UserClient.java # 第三步:Feign 客户端接口 ├── controller │ └── OrderController.java ├── mapper │ └── OrderMapper.java ├── pojo │ └── Order.java └── service └── OrderService.java # 第四步:注入 UserClient 调用引入依赖
在 order-service 的 pom.xml 中添加 openfeign 起步依赖。artifactId 是spring-cloud-starter-openfeign,注意别写成老版本的spring-cloud-starter-feign:
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>starter 意味着自动装配,Feign 运行所需的各种组件由 Spring Boot 帮我们配好。
启动类加 @EnableFeignClients
@EnableFeignClients是 Feign 功能的总开关,不加它,接口声明得再规范也不会生效。这一步改的就是启动类这一个注解:
packagecn.itcast.order;importorg.mybatis.spring.annotation.MapperScan;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;importorg.springframework.cloud.client.loadbalancer.LoadBalanced;importorg.springframework.cloud.openfeign.EnableFeignClients;importorg.springframework.context.annotation.Bean;importorg.springframework.web.client.RestTemplate;@MapperScan("cn.itcast.order.mapper")@SpringBootApplication@EnableFeignClients// 开启Feign功能,默认扫描启动类所在包及其子包中的@FeignClient接口publicclassOrderApplication{publicstaticvoidmain(String[]args){SpringApplication.run(OrderApplication.class,args);}/** * 创建RestTemplate并注入Spring容器 * 改用Feign后这个Bean可以删掉,这里暂时保留用于对比 */@Bean@LoadBalancedpublicRestTemplaterestTemplate(){returnnewRestTemplate();}}@EnableFeignClients默认扫描启动类所在包及子包。UserClient放在cn.itcast.order.client,在扫描范围内,什么都不用配。如果客户端接口放在别的包,就要显式指定basePackages或clients属性——这是后面避坑清单里的第一名。
编写 UserClient 接口
新建一个接口,封装所有对 userservice 服务的远程调用:
packagecn.itcast.order.client;importcn.itcast.order.pojo.User;importorg.springframework.cloud.openfeign.FeignClient;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.PathVariable;@FeignClient("userservice")publicinterfaceUserClient{@GetMapping("/user/{id}")UserfindById(@PathVariable("id")Longid);}仔细看这个接口,全是 SpringMVC 的注解,没有任何新东西——这正是 Feign 降低学习成本的设计,它默认采用 SpringMVC 的注解来声明调用信息。五个要素对应关系如下:
| 声明位置 | 代码 | 对应要素 |
|---|---|---|
类上@FeignClient("userservice") | "userservice" | 服务名称(注册中心里的服务名,不是 IP 地址) |
方法上@GetMapping("/user/{id}") | @GetMapping | 请求方式 GET |
| 同上 | "/user/{id}" | 请求路径,{id}是路径占位符 |
方法参数@PathVariable("id") Long id | Long id | 请求参数 |
| 方法返回值 | User | 返回值类型 |
写这个接口时对着提供方的UserController抄即可,两边的方法签名和注解必须保持一致。user-service 里的接口长这样:
packagecn.itcast.user.web;importcn.itcast.user.pojo.User;importcn.itcast.user.service.UserService;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.PathVariable;importorg.springframework.web.bind.annotation.RequestMapping;importorg.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/user")publicclassUserController{@AutowiredprivateUserServiceuserService;/** * 路径: /user/110 * * @param id 用户id * @return 用户 */@GetMapping("/{id}")publicUserqueryById(@PathVariable("id")Longid){returnuserService.queryById(id);}}类上的@RequestMapping("/user")加方法上的@GetMapping("/{id}"),合并起来就是/user/{id}——这正是UserClient里声明的路径。
有一个关键认知:@FeignClient的 value 是服务名,对应 nacos/eureka 注册中心里注册的服务名。Feign 拿到服务名后自己去注册中心拉实例列表,你永远不需要在代码里写 IP 和端口。
OrderService 注入 UserClient 调用
最后一步,把原来 RestTemplate 的代码整段删掉,注入UserClient,直接调方法:
packagecn.itcast.order.service;importcn.itcast.order.client.UserClient;importcn.itcast.order.mapper.OrderMapper;importcn.itcast.order.pojo.Order;importcn.itcast.order.pojo.User;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.stereotype.Service;@ServicepublicclassOrderService{@AutowiredprivateOrderMapperorderMapper;@AutowiredprivateUserClientuserClient;publicOrderqueryOrderById(LongorderId){// 1.查询订单Orderorder=orderMapper.findById(orderId);// 2.用Feign远程调用,查用户Useruser=userClient.findById(order.getUserId());// 3.封装user到Orderorder.setUser(user);// 4.返回returnorder;}}启动 order-service,浏览器访问http://localhost:8080/order/101并多刷新几次,每次都返回完整订单数据。观察 user-service 的 8081、8082 两个实例的日志,会发现两个实例都被访问到了——Feign 不仅完成了远程调用,负载均衡也在生效。
改造前后对比
同一个"查用户"动作,两种写法放在一起看:
改造前(RestTemplate):
Stringurl="http://userservice/user/"+order.getUserId();Useruser=restTemplate.getForObject(url,User.class);改造后(Feign):
Useruser=userClient.findById(order.getUserId());| 维度 | RestTemplate | Feign |
|---|---|---|
| 调用风格 | 拼 URL 字符串发请求 | 调接口方法 |
| URL 维护 | 硬编码在业务代码里 | 声明在客户端接口上,集中管理 |
| 参数处理 | 手工字符串拼接,多个参数极易出错 | 方法参数 + 注解,几个参数写几个形参 |
| 负载均衡 | 需要@LoadBalanced手动开启 | 内部集成 Ribbon,自动生效 |
| 可读性 | 不看注释不知道在干什么 | 不说明都以为是本地方法调用 |
| 复杂 URL | 参数七八个时基本没法维护 | 方法列表里加形参即可 |
将来遇到参数非常多的接口,Feign 的应对方式很朴素:方法列表里多加几个参数,每个参数配好@RequestParam或@PathVariable,维护成本恒定。
Feign 底层是怎么工作的
你写的只是一个接口,没有实现类,那调用方法时发生了什么?答案是动态代理:Feign 在启动时为每个@FeignClient接口生成代理对象注入容器,调用方法时,代理对象把注解里声明的信息组装成一个 HTTP 请求发出去。
整个过程可以概括成一条链路:
所以 Feign 并不是什么黑魔法,它的本质就是RestTemplate/OkHttp 这类 HTTP 客户端 + 负载均衡的一层封装,只是把这层封装藏到了动态代理背后。打开 Feign 的核心依赖树能看到feign-core,其内部已经带上了 Ribbon,负载均衡不用你操心。
实战避坑
这几个坑在真实项目里出现频率极高,改造时提前避开:
- 启动类忘加
@EnableFeignClients:最常见的启动坑。现象是注入UserClient时报Field userClient required a bean,找不到 Feign 客户端的 Bean。检查启动类注解即可。 - 客户端接口不在扫描范围内:
@EnableFeignClients默认只扫启动类所在包及子包。如果UserClient放在cn.itcast.feign.clients这类外部包里,必须在注解上显式指定,两种写法二选一:
// 写法一:指定扫描包@EnableFeignClients(basePackages="cn.itcast.feign.clients")// 写法二:直接指定接口类@EnableFeignClients(clients={UserClient.class})- 方法参数漏写注解:Feign 方法有多个参数时,
@RequestParam("xxx")、@PathVariable("xxx")一个都不能省,且要写明参数名。漏写后 Feign 无法确定参数该放 query、path 还是 body,多参数场景会冲突甚至直接把参数塞进请求体导致提供方收不到。 - 服务名大小写与拼写:
@FeignClient("userService")与注册中心里的userservice对不上,启动不报错,一调用就报No instances available。服务名以注册中心列表里显示的为准。 - 返回类型与提供方不一致:提供方返回
User,客户端方法却声明成Order,反序列化字段全为 null 或者直接抛解析异常。排查时先对齐两边的方法签名。
API 速览
| 注解 / 组件 | 位置 | 作用 |
|---|---|---|
@EnableFeignClients | 启动类 | 开启 Feign 功能,扫描@FeignClient接口;basePackages/clients指定扫描范围 |
@FeignClient("服务名") | 接口上 | 声明这是 Feign 客户端,value 填注册中心里的服务名 |
@GetMapping/@PostMapping | 接口方法上 | 声明请求方式与请求路径,与 SpringMVC 注解通用 |
@PathVariable("x") | 方法参数 | 路径占位符参数,必须写参数名 |
@RequestParam("x") | 方法参数 | query 参数,必须写参数名 |
@LoadBalanced | RestTemplate 的 Bean | RestTemplate 方案下开启负载均衡;Feign 内部已集成,无需再配 |
官方文档
- Spring Cloud OpenFeign 官方文档
- Spring Cloud Netflix Ribbon
总结
- RestTemplate 的三个问题:URL 硬编码拼接、复杂参数难维护、编程体验不统一。
- Feign 是声明式 HTTP 客户端:把服务名称、请求方式、请求路径、请求参数、返回值类型五个信息用注解声明在接口上,请求由框架发送。
- 改造四步:引依赖
spring-cloud-starter-openfeign→ 启动类加@EnableFeignClients→ 编写UserClient接口 → 业务代码注入接口调方法。 - 客户端接口全部使用 SpringMVC 注解,照着提供方的 Controller 抄即可,
@FeignClient的 value 是服务名不是地址。 - 底层没有黑魔法:动态代理生成实现,内部集成 Ribbon 自动负载均衡,本质是 HTTP 客户端 + 负载均衡的封装。
- 排查口诀:启动报找不到 Bean 查
@EnableFeignClients;调用报找不到实例查服务名拼写;参数收不到查@RequestParam/@PathVariable是否写全。