参数绑定

正如你所看到的,在入门demo中我们使用Spring Cloud Feign实现的是一个不带参数的REST服务绑定,但是在实际开发过程中,更多的则是携带参数的情况,同时返回请求响应的时候也可能是一个复杂的对象结构。接下来就开始对Feign中对于不同形式参数绑定方法的学习。

服务提供者提供演示接口

既然是服务调用,那么首先应该有服务提供者,因此需要在eureka-client项目(服务名为hello-service,即服务提供者)内提供几个携带参数的API接口。在HelloController类中新增如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@GetMapping(value = "/feignOne")
public String feignOne(@RequestParam("name")String name){
return "welcome "+ name +"to Beijing!";
}

@GetMapping(value = "/feignTwo")
public Movie feignTwo(@RequestHeader("name")String name,
@RequestHeader("author")String author,
@RequestHeader("price")Double price) throws UnsupportedEncodingException {
Movie movie =new Movie();
movie.setName(URLDecoder.decode(name,"UTF-8"));
movie.setAuthor(URLDecoder.decode(author,"UTF-8"));
movie.setPrice(price);
System.out.println(">>>>>>>>>>>movie对象是:"+movie+"<<<<<<<<<<");
return movie;
}

@PostMapping(value = "/feignThree")
public String feignThree(@RequestBody Movie movie){
return "你所添加的电影名称为: "+ movie.getName() +"作者为: "+movie.getAuthor()
+"价格为: "+movie.getPrice();
}

解释一下上述三个接口的含义:

  • /feignOne接口用于接收一个字符串类型的name参数,然后该参数通过key/value的形式进行传入,最后返回包含name信息的字符串对象。

  • /feignTwo接口用于接收三个参数并携带在请求头中,注意如果这些参数中包含中文就会产生乱码,因此需要先编码后解码才行;如果不包含中文则可以省略其中编码和解码的逻辑,最后返回一个Movie对象。

  • /feignThree接口用于接收一个Movie对象,然后返回包含该对象各个属性信息的字符串对象。

也就说这些接口定义中包含了带有Request参数的请求、带有Header信息的请求、带有RequestBody的请求以及请求响应体是一个对象的请求。

请注意,hello-service服务提供者中定义的Movie实体类中必须提供无参的构造方法,否则Spring Cloud Feign根据JSON字符串转换Movie对象时会抛出异常。

改造Feign服务消费者

在完成了服务者提供接口的工作后,接下来对之前demo例子进行修改,在feign-consumer应用中实现这些新增请求的绑定。

第一步,在feign-consumer项目目录内新建pojo包,并在里面新建Movie这个实体类,相应的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public class Movie {
private String name;
private double price;
private String author;
private Long 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 String getAuthor() {
return author;
}

public void setAuthor(String author) {
this.author = author;
}
public Long getId(){
return id;
}

public void setId(Long id){
this.id=id;
}

public Movie() {}

public Movie(String name, double price, String author,Long id) {
this.name = name;
this.price = price;
this.author = author;
this.id = id;
}

public Movie(String name, String author,double price) {
this.name = name;
this.price = price;
this.author = author;
}

@Override
public String toString() {
return "Movie{" +
"name='" + name + '\'' +
", price=" + price +
", author='" + author + '\'' +
", id='" + id + '\'' +
'}';
}
}

第二步,在feign-consumer项目的HelloService接口中增加对上述三个新增接口的绑定声明,修改后的HelloService接口内的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@FeignClient("hello-service")
public interface HelloService {
@GetMapping(value = "/world")
String world();

@GetMapping(value = "/feignOne")
String feignOne(@RequestParam("name")String name);

@GetMapping(value = "/feignTwo")
Movie feignTwo(@RequestHeader("name")String name,
@RequestHeader("author")String author,
@RequestHeader("price")Double price);

@PostMapping(value = "/feignThree")
String feignThree(@RequestBody Movie movie);
}

可以发现上面我们直接使用了SpringBoot2.x系列中提供的@GetMapping(value = "/world")等方式,而不是使用SpringBoot1.x系列中@RequestMapping(value="/world",method = RequestMethod.POST)等这种方式。

注意在定义各参数绑定时,@RequestParam@RequestHeader等可以指定参数名称的注解,它们的value属性必须配置。而在SpringMVC程序中,这些注解会使用参数名来作为默认值,但是在Feign中绑定参数必须通过value属性来指明具体的参数名,否则会抛出IllegalStateException异常,所以value属性不能为空,这一点必须要引起高度重视。

第三步,在feign-consumer项目的ConsumerController类中新增接口,用于调用第二步中新增的声明接口,修改后的完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class ConsumerController {

@Autowired
private HelloService helloService;

@GetMapping(value = "/feign-consumer")
public String helloConsumer(){
return helloService.world();
}

@GetMapping(value = "/feign-consumer2")
public String helloConsumer2(){
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(helloService.world()).append("\n");
stringBuilder.append(helloService.feignOne("envy")).append("\n");
stringBuilder.append(helloService.feignTwo("the west travel", "Mrs wu", 128.00)).append("\n");
stringBuilder.append(helloService.feignThree(new Movie("the west travel","Mrs wu",118.00))).append("\n");
return stringBuilder.toString();
}
}

第四步,启动测试。分别按照以下顺序来启动相应的服务,启动顺序如下(注意不能颠倒):
(1)服务名为eureka-server的工程,这是服务注册中心,注意端口为1111,即启动实例名为peer1的服务实例。
(2)服务名为hello-service的工程,这是服务提供者,注意需要启动两个实例,启动的端口分别为8081和8082,即启动实例名为p1和p2的服务实例(注意两个服务提供者均需往服务注册中心注册)。
(3)服务名为feign-consumer的工程,这是服务消费者,注意只启动一个实例,启动的端口为3001,即启动实例名为feign-consumer的服务实例(注意这个服务消费者需往服务注册中心注册)。

然后在浏览器地址栏中输入http://localhost:3001/feign-consumer2链接,可以看到页面显示如下信息:

通过页面的输出结果,我们知道当请求http://localhost:3001/feign-consumer2链接的时候就会触发HelloService对之前新增接口的调用,最终输出上述信息,表示接口绑定进而调用成功。

继承特性

通过前面入门demo和参数绑定的例子,细心的朋友们可能发现了一个问题,就是当使用SpringMVC的注解来绑定服务接口时,我们完全可以从服务提供方的Controller中复制一些代码,然后就可以构建出相应的服务客户端绑定接口。

查看一下参数绑定的例子,首先查阅eureka-client项目(即服务提供者)Controller类中的接口信息,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@GetMapping(value = "/feignOne")
public String feignOne(@RequestParam("name")String name){
return "welcome "+ name +"to Beijing!";
}

@GetMapping(value = "/feignTwo")
public Movie feignTwo(@RequestHeader("name")String name,
@RequestHeader("author")String author,
@RequestHeader("price")Double price) throws UnsupportedEncodingException {
Movie movie =new Movie();
movie.setName(URLDecoder.decode(name,"UTF-8"));
movie.setAuthor(URLDecoder.decode(author,"UTF-8"));
movie.setPrice(price);
System.out.println(">>>>>>>>>>>movie对象是:"+movie+"<<<<<<<<<<");
return movie;
}

@PostMapping(value = "/feignThree")
public String feignThree(@RequestBody Movie movie){
return "你所添加的电影名称为: "+ movie.getName() +"作者为: "+movie.getAuthor()
+"价格为: "+movie.getPrice();
}

再来查看一下feign-consumer项目(即服务消费者)Service接口中的方法信息,如下所示:

1
2
3
4
5
6
7
8
9
10
@GetMapping(value = "/feignOne")
String feignOne(@RequestParam("name")String name);

@GetMapping(value = "/feignTwo")
Movie feignTwo(@RequestHeader("name")String name,
@RequestHeader("author")String author,
@RequestHeader("price")Double price);

@PostMapping(value = "/feignThree")
String feignThree(@RequestBody Movie movie);

发现问题了吧,这两者是不是有点类似于前者是后者的方法实现?但是这只是类似,毕竟两个是存在于不同的服务当中。当然我们可以考虑将这部分内容进行进一步的抽象,Spring Cloud Feign对于解决上述问题提供了继承特性这一功能,用来进一步减少编码量。接下来就来学习如何通过Spring Cloud Feign的继承特性来实现REST接口定义的复用。

第一步,创建公共接口工程。由于需要复用POJO对象与接口定义,因此先创建一个基础的Maven工程,名称为hello-service-api。

第二步,添加依赖。由于我们需要在hello-service-api工程中同时复用于服务端与客户端的接口,需要使用到SpringMVC的注解,因此需要在pom.xml依赖文件中添加Web依赖,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?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">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<groupId>com.envy</groupId>
<artifactId>hello-service-api</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>hello-service-api</name>

<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

</project>

第三步,复制POJO对象。将之前定义的Movie对象复制到hello-service-api工程的com.envy.helloserviceapi.pojo包中,相应的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package com.envy.helloserviceapi.pojo;

public class Movie {
private String name;
private double price;
private String author;
private Long 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 String getAuthor() {
return author;
}

public void setAuthor(String author) {
this.author = author;
}
public Long getId(){
return id;
}

public void setId(Long id){
this.id=id;
}

public Movie() {}

public Movie(String name, double price, String author,Long id) {
this.name = name;
this.price = price;
this.author = author;
this.id = id;
}

public Movie(String name, String author,double price) {
this.name = name;
this.price = price;
this.author = author;
}

@Override
public String toString() {
return "Movie{" +
"name='" + name + '\'' +
", price=" + price +
", author='" + author + '\'' +
", id='" + id + '\'' +
'}';
}
}

第四步,新建接口文件。在hello-service-api工程内创建com.envy.helloserviceapi.service包,并在里面新建HelloService接口文件,里面的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RequestMapping(value = "/envy")
public interface HelloService {

@GetMapping(value = "/feign1")
String feignOne(@RequestParam("name")String name);

@GetMapping(value = "/feign2")
Movie feignTwo(@RequestHeader("name")String name,
@RequestHeader("author")String author,
@RequestHeader("price")Double price);

@PostMapping(value = "/feign3")
String feignThree(@RequestBody Movie movie);
}

是不是觉得这些代码非常眼熟,是的,这里就是之前我们在feign-consumer项目(即服务消费者)的HelloService接口文件中定义的三个接口。同时为了和之前的方法进行区分,笔者对请求进行了窄化,给请求定义了前缀/envy。以及为了避免接口的混淆,还将提供服务的三个接口更名为/feign1/feign2/feign3.

第五步,重构服务提供者。接下来回到eureka-client项目(即服务提供者)中,对其进行重构。首先是在其pom.xml依赖文件中添加对hello-service-api工程的依赖,如下所示:

1
2
3
4
5
<dependency>
<groupId>com.envy</groupId>
<artifactId>hello-service-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

注意仅仅是这样不够的,此时该pom.xml依赖文件会变红,说明依赖无法导入:

之后其实还需要进行如下操作:回到eureka-client项目(即服务提供者)中,点击IDEA左侧的File–>Project Structure,然后按照下图进行操作:

点击确定,然后再次刷新Maven,就可以发现此时对于hello-service-api工程的依赖就不会变红了,而且可以点进去,找到对应的pom.xml依赖文件。

接着在eureka-client项目(即服务提供者)中新建RefactorHelloController类,注意这个类必须实现hello-service-api工程中所定义的HelloService接口,并参看之前的HelloController来实现这三个接口,相应的内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
public class RefactorHelloController implements HelloService {
@Override
public String feignOne(@RequestParam("name")String name) {
return "welcome "+ name +"to Beijing!";
}

@Override
public Movie feignTwo(@RequestHeader("name")String name,
@RequestHeader("author")String author,
@RequestHeader("price")Double price) {
return new Movie(name,author,price);
}

@Override
public String feignThree(@RequestBody Movie movie) {
return "你所添加的电影名称为: "+ movie.getName() +"作者为: "+movie.getAuthor()
+"价格为: "+movie.getPrice();
}
}

细心的你肯定就发现了一些不同之处,通过继承的方式,我们在Controller中不再需要和以往一样在方法上添加请求的映射注解@GetMapping@PostMapping等,其实这些参数的注解定义会在重写的时候自动携带过来。在这个类中,除了需要实现接口中的方法逻辑,额外只需要增加@RestController注解使该类成为一个REST接口类即可。另外需要注意方法中的参数@RequestHeader@RequestBody注解还是必须要添加的,而@RequestParam注解可以不添加,但是正常我们还是习惯于添加,这就就不要专门去记忆那些可以不加,直接都加是肯定不会错的。


第六步,重构服务消费者。完成了对服务提供者的重构工作,接下来开始对服务消费者进行重构。回到feign-consumer项目(即服务消费者)中,对其进行重构。其实对于服务消费者的重构和服务提供者的重构操作基本上差不多,只是服务消费者的重构需要定义一个接口文件。

首先是在其pom.xml依赖文件中添加对hello-service-api工程的依赖,并按照之前的操作导入该工程依赖。

接着在feign-consumer项目(即服务消费者)的service包内新建RefactorHelloService接口文件,注意这个接口文件需要继承之前hello-service-api工程内的HelloService(注意不是feign-consumer项目本身之前创建的HelloService接口)。然后添加@FeignClient(value = "hello-service")注解来绑定服务,相应的代码为:

1
2
3
@FeignClient(value = "hello-service")
public interface RefactorHelloService extends HelloService {
}

最后在feign-consumer项目(即服务消费者)的controller包内新建RefactorHelloController类,然后在这个类中注入前面定义的RefactorHelloService实例,并新增一个请求/feign-consumer3来触发对RefactorHelloService实例的调用,相应的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
public class RefactorHelloController {
@Autowired
private RefactorHelloService refactorHelloService;

@GetMapping(value = "/feign-consumer3")
public String helloConsumer3(){
StringBuilder sb = new StringBuilder();
sb.append(refactorHelloService.feignOne("envy")).append("\n");
sb.append(refactorHelloService.feignTwo("Beijing Opera", "Chinese", 999.00)).append("\n");
sb.append(refactorHelloService.feignThree(new Movie("Beijing Opera","Chinese",999.00))).append("\n");
return sb.toString();
}
}

第七步,启动测试。分别按照以下顺序来启动相应的服务,启动顺序如下(注意不能颠倒):
(1)服务名为eureka-server的工程,这是服务注册中心,注意端口为1111,即启动实例名为peer1的服务实例。
(2)服务名为hello-service的工程,这是服务提供者,注意需要启动两个实例,启动的端口分别为8081和8082,即启动实例名为p1和p2的服务实例(注意两个服务提供者均需往服务注册中心注册)。
(3)服务名为feign-consumer的工程,这是服务消费者,注意只启动一个实例,启动的端口为3001,即启动实例名为feign-consumer的服务实例(注意这个服务消费者需往服务注册中心注册)。

如果在启动过程中feign-consumer的工程的控制台输出以下信息:

那是因为SpringCloud 2.1.0以上版本,默认不再支持@FeignClient注解name属性存在多个相同的名字。 说白了就是多个接口上的@FeignClient(“相同服务名”)会报错,因为重写被禁止了。

我们之所以会触发这个条件是之前的HelloService和现在的RefactorHelloService接口文件上都使用了@FeignClient(value = "hello-service")注解,因此触发了。

如果开发者想关闭这个禁止重写的功能,可以通过在application.yml配置文件中添加如下配置来实现:

1
2
3
4
spring:
# 多个接口上的@FeignClient(“相同服务名”)会报错,重写被禁止了,即默认值为false,此时需要修改为true。
main:
allow-bean-definition-overriding: true

然后在浏览器地址栏中输入http://localhost:3001/feign-consumer3链接,可以看到页面显示如下信息:

继承特性的优缺点

通过上面例子的学习,可以看到Spring Cloud Feign继承特性的优点非常明显,可以将接口的定义从Controller中剥离,同时配置Maven私有仓库就可以轻易的实现接口定义的共享,实现在构建期的接口绑定,从而有效减少服务客户端的绑定配置。

需要说明的是这种方式尽管可以很方便的实现接口定义和依赖的共享,不用在复制粘贴接口进行绑定,但是如果滥用会造成很严重的问题。

因为接口在构建期就建立起了依赖,那么接口变动就会对项目构建造成影响,可能服务提供方修改一个接口定义,会直接导致客户端工程的构建失败。所以如果想通过此方法来实现接口的共享,那么必须在开发评审期严格遵守面对对象的开闭原则,做好前后版本的兼容,避免发生牵一发而动全身的局面。