springcloud服务通信组件之OpenFeign
前言
在上一节中05.负载均衡之Ribbon中我们学习了使用Ribbon+RestTemplate实现微服务间负载均衡通信,但是最终还是存在以下问题
- 路径写死后期不易维护
- 不能自动转换响应结果为所需对象
针对上面两个问题我们今天来看下springcloud提供的另外一个组件–OpenFeign
OpenFeign
简介
OpenFeign是一个声明式的伪Http客户端,它使得写Http客户端变得更简单。使用OpenFeign,只需要创建一个接口并注解。它具有可插拔的注解特性(可以使用springmvc的注解),可使用OpenFeign注解和JAX-RS注解。OpenFeign支持可插拔的编码器和解码器。OpenFeign默认集成了Ribbon,默认实现了负载均衡的效果并且springcloud为OpenFeign添加了springmvc注解的支持。
简单认为:OpenFeign是springcloud基于Netflix的组件feign开发而来,主要也是一个实现微服务间负载均衡通信的组件(springcloud推荐)。作用就是一个HttpClient客户端对象,不过我们称之为伪HttpClient客户端对象。OpenFeign底层继承了RestTemplate和Ribbon,也就是说实现通信和负载均衡的还是RestTemplate和Ribbon,只不过OpenFeign将两者合二为一做了简化,使开发人员实现起来更加方便了
OpenFeign简单使用
准备工作
我们这里重新构建三个服务:一个商品分类服务–Category(8890),两个商品服务–Product(8891,8892);我们使用商品分类服务调用商品服务来延时OpenFeign的使用
CATEGORY
1.新建Module
2.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>springcloud_parent</artifactId>
<groupId>com.christy</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>04.springcloud_openfeign_category</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<!-- springboot依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 引入consul client依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
<!-- 引入健康检查依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 作为服务调用方需要引入OpenFeign依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
</project>
3.application.properties
server.port=8890
spring.application.name=CATEGORY
# 注册到consul server
spring.cloud.consul.host=localhost
spring.cloud.consul.port=8500
4.CategoryApplication.java
/**
* @Author Christy
* @Date 2021/6/4 10:13
* @EnableDiscoveryClient 开启服务发现与注册
* @EnableFeignClients 开启OpenFeign客户端调用
**/
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class CategoryApplication {
public static void main(String[] args) {
SpringApplication.run(CategoryApplication.class, args);
}
}
6.ProductClient.java
我们上面说了使用OpenFeign,只需要创建一个接口并注解
,首先我们先来创建一个接口
/**
* @Author Christy
* @Date 2021/6/4 10:22
* 商品服务的服务名为“PRODUCT”,会提供一个获取商品列表的list方法(注意:这个方法只做演示,不提供业务逻辑)
**/
@FeignClient(value = "PRODUCT")
public interface ProductClient {
/**
* 这里的方法返回值,路径和请求方式必须和PRODUCT里面保持一致,方法名不做要求
* @author Christy
* @date 2021/6/4 10:27
* @param
* @return java.lang.String
*/
@GetMapping("/product/list")
String list();
}
5.CategoryController.java
/**
* @Author Christy
* @Date 2021/6/4 10:16
**/
@RestController
@RequestMapping("category")
public class CategoryController {
private static final Logger log = LoggerFactory.getLogger(CategoryController.class);
@Autowired
ProductClient productClient;
@GetMapping("/product/list")
public String getProductList(){
log.info("category service start……");
String result = productClient.list();
log.info("category service end," + result);
return "category: " + result;
}
}
PRODUCT
1.新建Module
2.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>springcloud_parent</artifactId>
<groupId>com.christy</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>04.springcloud_openfeign_product</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<!-- springboot依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 引入consul client依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
<!-- 引入健康检查依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
</project>
3.application.properties
server.port=8891
spring.application.name=PRODUCT
# 注册到consul server
spring.cloud.consul.host=localhost
spring.cloud.consul.port=8500
4.ProductApplication.java
/**
* @Author Christy
* @Date 2021/6/4 10:36
**/
@SpringBootApplication
@EnableDiscoveryClient
public class ProductApplication {
public static void main(String[] args) {
SpringApplication.run(ProductApplication.class, args);
}
}
5.ProductController.java
/**
* @Author Christy
* @Date 2021/6/4 10:38
**/
@RestController
@RequestMapping("product")
public class ProductController {
private static final Logger log = LoggerFactory.getLogger(ProductController.class);
@Value("${server.port}")
private int port;
@RequestMapping("list")
public String list(){
log.info("product service start ……");
log.info("product service ok, current service port:" + port);
return "product service ok, current service port:" + port;
}
}
启动consul
win+R
调出命令行窗口,输入命令consul agent -dev
启动consul,浏览器访问http://localhost:8500
启动服务
consul启动成功后依次启动我们的CATEGORY和PRODUCT服务,其中PRODUCT服务需要按照文章02.服务注册中心之Eureka中搭建Eureka集群的方法分别启动8891和8892
测试
服务启动完毕后,我们在浏览器访问CATEGORY服务中的方法http://localhost:8890/category/product/list
我们可以看到服务调用成功了,而且实现了我们说的负载均衡
服务间的参数传递和响应处理
开始这个话题之前我们首先要明确的是参数传递的方式有哪几种?大体来讲可以分为三种
- 零散类型的传参:这里又分为两种,
path?key=value
和path/{id}/{name}
- 对象类型的传参
- 数组和集合类型的传参
零散类型传参
单个参数
1.ProductController
使用OpenFeign使用零散类型参数,如果是一个参数的话不需要额外的操作,直接定义然后请求就可以了,比如我们在PRODUCT定义单个参数的方法
@RequestMapping("one")
public String getOne(String name){
log.info("product service select one start ……");
log.info("product service select one ok, current service port:" + port + ", accept param name:" + name);
return "product service select one ok, current service port:" + port + ", accept param name:" + name;
}
2.ProductClient
对于调用方CATEGORY来说,我们首先要在ProductClient定义出这个接口
@GetMapping("/product/one")
String getOne(String name);
3.CategoryController
然后我们在CategoryController中调用该方法
@GetMapping("/product/one")
public String getProductOne(){
log.info("category service get one product start……");
String result = productClient.getOne("001");
log.info("category service get one product end," + result);
return "category: " + result;
}
4.测试
之后我们分别启动两个服务(能正常启动,不报错),直接浏览器输入http://localhost:8890/category/product/one
,
我们可以看到参数并没有接收到,这是为什么?想搞清楚这个问题我们先来看下多个参数传递
多个参数
1.ProductController
我们在ProductController中定义如下方法
@RequestMapping("/byParams")
public String getProductByParams(String name, double price){
log.info("product service select product by params start ……");
log.info("product service product by params ok, current service port:" + port + ", accept param name=" + name + ",price" +
"=" + price );
return "product service product by params ok, current service port:" + port + ", accept param name=" + name + ",price" +
"=" + price;
}
2.ProductClient
然后我们在ProductClient中调用
@GetMapping("/product/byParams")
String getProductByParams(String name, double price);
3.CategoryController
最后我们在CategoryController中执行该方法
@GetMapping("/product/byParams")
public String getProductByParams(){
log.info("category service get product by params start……");
String result = productClient.getProductByParams("杜蕾斯",99.99);
log.info("category service get product by params end," + result);
return "category: " + result;
}
4.测试
准备完毕后我们分别启动两个服务,我们发现PRODUCT服务能正常启动,但是CATEGORY报错了
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'com.christy.feignclient.ProductClient': FactoryBean threw exception on object creation; nested exception is java.lang.IllegalStateException: Method has too many Body parameters: public abstract java.lang.String com.christy.feignclient.ProductClient.getProductByParams(java.lang.String,double)
问题分析
我们来分析一下这是什么原因导致的:
我们开头说了OpenFeign是一个伪HttpClient客户端,底层间的服务调用还是通过RestTemplate来进行的,我们知道零散类型的参数传递有两种方式path?key=value
与path/{id}
。一个参数传递的时候默认和第一种方式传递,而多个参数传递的时候我们就必须显式的告诉底层我们希望用哪一种方式传递参数。对于问号传参沃恩需要用注解**@RequestParam修饰参数,对于路径传参必须使用@PathVariable**修饰参数。而且必须要指定形参。
问题修复
1.ProductController
首先们在ProductController中定义下面两个方法
@RequestMapping("/byRequestParam")
public String getProductByRequestParam(@RequestParam(value = "name") String name, @RequestParam(value = "price") double price){
log.info("product service select product by request params start ……");
log.info("product service product by request params ok, current service port:" + port + ", accept param name=" + name +
",price" +
"=" + price );
return "product service product by request params ok, current service port:" + port + ", accept param name=" + name + ",price" +
"=" + price;
}
@RequestMapping("/byPathVariable/{name}/{price}")
public String getProductByPathVariable(@PathVariable(value = "name") String name, @PathVariable(value = "price") double price){
log.info("product service select product by path variable start ……");
log.info("product service product by path variable ok, current service port:" + port + ", accept param name=" + name +
",price" +
"=" + price );
return "product service product by path variable ok, current service port:" + port + ", accept param name=" + name + ",price" +
"=" + price;
}
2.ProductClient
@GetMapping("/product/byRequestParam")
String getProductByRequestParam(@RequestParam(value = "name") String name, @RequestParam(value = "price") double price);
@GetMapping("/product/byPathVariable/{name}/{price}")
String getProductByPathVariable(@PathVariable(value = "name") String name, @PathVariable(value = "price") double price);
3.CategoryController
/**
* 问号传参方式
* @author Christy
* @date 2021/6/4 12:42
* @param
* @return java.lang.String
*/
@GetMapping("/product/byRequestParam")
public String getProductByRequestParam(){
log.info("category service get product by request param start……");
String result = productClient.getProductByRequestParam("杜蕾斯",99.99);
log.info("category service get product by request param end," + result);
return "category: " + result;
}
/**
* 路径传参方式
* @author Christy
* @date 2021/6/4 12:42
* @param
* @return java.lang.String
*/
@GetMapping("/product/byPathVariable")
public String getProductByPathVariable(){
log.info("category service get product by path variable start……");
String result = productClient.getProductByPathVariable("杜蕾斯",99.99);
log.info("category service get product by path variable end," + result);
return "category: " + result;
}
4.测试
启动五之前记得将上面没有加注解的多个参数传递的方法删除或者注释掉,最后我们来分别启动两个服务
我们可以看到两个服务都启动成功了,我们再来访问一下这两个方法,浏览器分别输入http://localhost:8890/category/product/byRequestParam
与http://localhost:8890/category/product/byPathVariable
我们可以看到两个方法都访问成功了,这也说明我们上面的问题分析也是对的。那么对于单个参数的传递虽然启动的时候不报错,但是实际书写的时候我们也要指定传递的方式和明确形参的名称,否则参数是传递不过去的。
对象类型传参
由于微服务中使用OpenFeign做服务间调用主要是以Rest的方式,所以对于对象类型的传参,主要是传递json的形式。我们知道传递json化的对象数据要使用注解**@RequestBody**来修饰传递的参数
由于涉及到对象的传递,我们现在两个服务里面新建一个实体类Product.java
实际开发中,我们的实体类肯定是放到common服务中心,其他的服务想要使用实体类直接引入就行了。这里由于是演示demo,没有考虑具体的业务逻辑,所以没有设计具体的公共模块,这里我们在CATEGORY和PRODUCT里面分别新建一个实体类Product.java
1.Product.java
package com.christy.entity;
import java.util.Date;
/**
* @Author Christy
* @Date 2021/6/4 13:55
**/
public class Product {
private int id;
private String name;
private double price;
private Date create;
public Product() {
}
public Product(int id, String name, double price, Date create) {
this.id = id;
this.name = name;
this.price = price;
this.create = create;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
public Date getCreate() {
return create;
}
public void setCreate(Date create) {
this.create = create;
}
@Override
public String toString() {
return "Product{" +
"id=" + id +
", name='" + name + '\'' +
", price=" + price +
", create=" + create +
'}';
}
}
2.ProductController
@PostMapping("/testobj")
public String testObj(@RequestBody Product product){
log.info("product service test object start ……");
log.info("product service test object start, current service port:" + port + ", product=" + product);
return "product service test object start, current service port:" + port + ", product=" + product;
}
3.ProductClient
@PostMapping("/product/testobj")
String testObj(@RequestBody Product product);
4.CategoryController
@GetMapping("/product/testobj")
public String testobj(){
log.info("category service get product test obj start……");
String result = productClient.testObj(new Product(1,"冈本",79.99,new Date()));
log.info("category service get product test obj end," + result);
return "category: " + result;
}
5.测试
重新启动服务,浏览器访问http://localhost:8890/category/product/testobj
数组与集合类型传参
对于数组和集合来讲,他们的传参是一样的,不同时的收参不同
在OpenFeign中传递数组和集合类型的参数时必须在声明时使用@RequestParam注解标识,对于数组在被调用方可以以数组形式直接接收;但是对于集合来讲,由于SpringMVC不能直接接收集合类型的参数,如果想要接收集合类型的参数必须将集合放入对象中,使用对象的方式接收才行
1.CollectionVO
上面说了传递集合是收参必须以对象的方式接收,我们首先定义一个CollectionVO,里面定义一个成员变量List ids,并提供getter与setter方法
public class CollectionVO {
private List<String> ids;
public List<String> getIds() {
return ids;
}
public void setIds(List<String> ids) {
this.ids = ids;
}
}
2.ProductController
// 定义一个接口接受集合类型参数
// springmvc 不能直接接受集合类型参数,如果想要接收集合类型参数必须将集合放入对象中,使用对象的方式接收才行
@GetMapping("/testCollection")
public String testCollection(CollectionVO collectionVO){
collectionVO.getIds().forEach(id-> log.info("id:{} ",id));
return "testCollection ok,当前服务端口为: "+port;
}
//定义个接口接受数组类型参数
@GetMapping("/testArr")
public String testArr(String[] ids){
for (String id : ids) {
log.info("id: {}",id);
}
//手动转为list List<String> strings = Arrays.asList(ids);
return "testArr ok,当前服务端口为: "+port;
}
3.ProductClient
//声明调用商品服务中testCollection接口 传递一个list集合类型参数 test4?ids=21&ids=22
@GetMapping("/product/testCollection")
String testCollection(@RequestParam("ids") String[] ids);
//声明调用商品服务中testArr接口 传递一个数组类型 queryString /test3?ids=21&ids=22
@GetMapping("/product/testArr")
String testArr(@RequestParam("ids") String[] ids);
4.CategoryController
@GetMapping("/product/testArr")
public String testArr(){
log.info("category service get product test array start……");
String result = productClient.testArr(new String[]{"8","9","10"});
log.info("category service get product test array end," + result);
return "category: " + result;
}
@GetMapping("/product/testCollection")
public String testCollection(){
log.info("category service get product test collection start……");
String result = productClient.testCollection(new String[]{"18","19","20"});
log.info("category service get product test collection end," + result);
return "category: " + result;
}
5.测试
最后我们重启两个服务,浏览器分别访问http://localhost:8890/category/product/testArr
与http://localhost:8890/category/product/testCollection
响应处理
一般情况我们请求接口返回的结果无外乎对象,集合或者再复杂一点的组合对象,而一般情况先遇到复杂的组合对象我们习惯使用Map的方式,下面就针对这三种方式的返回结果做一下处理
1.pom.xml
在第三种返回结果Map中我们获取的结果需要序列化,所以我们现在CATEGORY服务的pom.xml中增加fastjson的依赖
<!-- 引入fastjson依赖 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.76</version>
</dependency>
2.ProductController
@GetMapping("/findByCategoryIdAndPage")
public Map<String,Object> findByCategoryIdAndPage(Integer page, Integer rows, Integer categoryId){
log.info("当前页: {} 每页显示记录数:{} 当前类别id:{} ",page,rows,categoryId);
//根据类别id分页查询符合当前页集合数据 List<Product> select * from t_product where categoryId=? limt ?(page-1)*rows,?(rows)
//根据类别id查询当前类别下总条数 totalCount select count(id) from t_product where categoryId=?
Map<String, Object> map = new HashMap<>();
List<Product> products = new ArrayList<>();
products.add(new Product(1,"超薄",39.99,new Date()));
products.add(new Product(2,"超薄003",59.99,new Date()));
products.add(new Product(3,"超薄001",79.99,new Date()));
int total = 1000;
map.put("rows",products);
map.put("total", total);
return map;
}
@GetMapping("/findByCategoryId")
public List<Product> findByCategoryId(Integer categoryId){
log.info("类别id: {}",categoryId);
//调用业务逻辑根据类别id查询商品列表
List<Product> products = new ArrayList<>();
products.add(new Product(1,"超薄",39.99,new Date()));
products.add(new Product(2,"超薄003",59.99,new Date()));
products.add(new Product(3,"超薄001",79.99,new Date()));
return products;
}
//定义一个接口接收id类型参数,返回一个基于id查询的对象
@GetMapping("/findById/{id}")
public Product findById(@PathVariable("id") Integer id){
log.info("id:{}",id);
return new Product(id,"超薄",39.99,new Date());
}
3.ProductClient
//声明调用商品服务根据类别id查询分页查询商品信息 以及总条数
@GetMapping("/product/findByCategoryIdAndPage")
String findByCategoryIdAndPage(@RequestParam("page") Integer page,@RequestParam("rows") Integer rows,@RequestParam("categoryId") Integer categoryId);
///声明调用商品服务根据类别id查询一组商品信息
@GetMapping("/product/findByCategoryId")
List<Product> findByCategoryId(@RequestParam("categoryId") Integer categoryId);
//声明调用根据id查询商品信息接口
@GetMapping("/product/findById/{id}")
Product findById(@PathVariable("id") Integer id);
4.CategoryController
@GetMapping("/product/findByCategoryIdAndPage")
public String findByCategoryIdAndPage(){
String result = productClient.findByCategoryIdAndPage(1, 5, 1);
System.out.println(result);
//自定义json反序列化 对象转为json 序列化 on字符串转为对象
JSONObject jsonObject = JSONObject.parseObject(result);
System.out.println(jsonObject.get("total"));
Object rows = jsonObject.get("rows");
System.out.println(rows);
//二次json反序列化
List<Product> products = JSON.parseArray(rows.toString(), Product.class);
products.forEach(product -> {
log.info("product:{}",product);
});
return result;
}
@GetMapping("/product/findByCategoryId")
public List<Product> findByCategoryId(){
List<Product> products = productClient.findByCategoryId(1);
products.forEach(product -> log.info("product: {}",product));
return products;
}
@GetMapping("/product/findById")
public Product findById(){
Product product = productClient.findById(21);
log.info("product: {}",product);
return product;
}
5.测试
分别启动两个服务,浏览器输入http://localhost:8890/category/product/findById
然后输入http://localhost:8890/category/product/findByCategoryId
最后我们输入http://localhost:8890/category/product/findByCategoryIdAndPage
OpenFeign的超时设置
超时说明
默认情况下,openFiegn在进行服务调用时,要求服务提供方处理业务逻辑时间必须在1S内返回,如果超过1S没有返回则OpenFeign会直接报错,不会等待服务执行。
模拟超时
下面我们来模拟一下超时的情况,我们在PRODUCT服务的ProductController中的findById
方法里面新增睡眠3秒钟,代码如下
@GetMapping("/findById/{id}")
public Product findById(@PathVariable("id") Integer id) throws InterruptedException {
Thread.sleep(3000);
log.info("id:{}",id);
return new Product(id,"超薄",39.99,new Date());
}
然后我们重新启动服务,然后页面访问http://localhost:8890/category/product/findById
修改超时默认值
但是往往在处理复杂业务逻辑是可能会超过1S,因此需要在调用方修改OpenFeign的默认服务调用超时时间。
# 配置指定服务连接超时
feign.client.config.PRODUCT.connectTimeout=5000
# 配置指定服务等待超时
feign.client.config.PRODUCT.readTimeout=5000
我们再次重启CATEGORY服务,同样的访问上面的方法,可以看到由于我们将超时时间设置成5秒,所以服务能够调用成功。如下图:
上面是针对单个服务进行超时设置,但是通常微服务系统中会存在多个服务,单一设置不太现实,所以我们可以统一对服务进行超时设置,如下
# 配置所有服务连接超时
feign.client.config.default.connectTimeout=5000
# 配置所有服务等待超时
feign.client.config.default.readTimeout=5000
OpenFeign详细日志
日志介绍
OpenFeign对日志的处理非常灵活可为每个OpenFeign客户端指定日志记录策略,每个客户端都会创建一个logger。默认情况下logger的名称是OpenFeign的全限定名,需要注意的是OpenFeign日志的打印只会DEBUG级别做出响应。
日志分类
我们可以为OpenFeign客户端配置各自的logger.lever对象,告诉OpenFeign记录那些日志。logger.lever有以下的几种值:
- NONE:不记录任何日志
- BASIC:仅仅记录请求方法,url,响应状态代码及执行时间
- HEADERS:记录Basic级别的基础上,记录请求和响应的header
- FULL:记录请求和响应的header,body和元数据(推荐)
往往在服务调用时我们需要详细展示OpenFeign的日志,默认OpenFeign在调用是并不是最详细日志输出,因此在调试程序时应该开启OpenFeign的详细日志展示。
开启日志
## 配置OpenFeign详细日志
# 指定feign调用客户端对象所在包,必须是debug级别
logging.level.com.christy.feignclient=debug
# 开启指定服务日志展示
feign.client.config.PRODUCT.loggerLevel=full
# 全局开启服务日志展示
# feign.client.config.default.loggerLevel=full
此时我们重启CATEGORY服务,可以看到控制台打印输出的日志