【第四章】详解Feign的实现原理

1.1 Feign概述

这篇文章主要讲述如何通过Feign去消费服务,以及Feign的实现原理的解析。

FeignNetflix开发的声明式、模板化的HTTP客户端,Feign可以帮助我们更快捷、优雅地调用HTTP API

Feign是⼀个HTTP请求的轻量级客户端框架。通过 接口 + 注解的方式发起HTTP请求调用,面向接口编程,而不是像Java中通过封装HTTP请求报文的方式直接调用。服务消费方拿到服务提供方的接⼝,然后像调⽤本地接⼝⽅法⼀样去调⽤,实际发出的是远程的请求。让我们更加便捷和优雅的去调⽤基于 HTTPAPI,被⼴泛应⽤在 Spring Cloud的解决⽅案中。

在前面的文章中可以发现当我们通过RestTemplate调用其它服务的API时,所需要的参数须在请求的URL中进行拼接,如果参数少的话或许我们还可以忍受,一旦有多个参数的话,这时拼接请求字符串就会效率低下,并且显得好傻。

那么有没有更好的解决方案呢?答案是确定的有,Netflix已经为我们提供了一个框架:Feign

Feign是一个声明式的Web Service客户端,它的目的就是让Web Service调用更加简单。Feign提供了HTTP请求的模板,通过编写简单的接口和插入注解,就可以定义好HTTP请求的参数、格式、地址等信息。

Feign则会完全代理HTTP请求,我们只需要像调用方法一样调用它就可以完成服务请求及相关处理。Feign整合了RibbonHystrix(关于Hystrix我们后面再讲),可以让我们不再需要显式地使用这两个组件。

总起来说,Feign具有如下特性:

  • 采用的是基于接口可插拔的注解支持,包括Feign注解和JAX-RS注解;
  • 支持可插拔的HTTP编码器和解码器;
  • 支持Hystrix和它的Fallback,具有熔断降级的能力;
  • 支持Ribbon的负载均衡,具有负载均衡的能力;
  • 支持HTTP请求和响应的压缩。

这看起来有点像我们Spring MVC模式的Controller层的RequestMapping映射。这种模式是我们非常喜欢的。Feign是用@FeignClient来映射服务的。

1.2 为什么使用Feign

Feign 的首要目标就是减少HTTP 调用的复杂性。在微服务调用的场景中,我们调用很多时候都是基于HTTP协议的服务,如果服务调用只使用提供 HTTP调用服务的 HTTP Client框架(e.g. Apache HttpComponnets、HttpURLConnection OkHttp 等),我们需要关注哪些问题呢?

【第四章】详解Feign的实现原理
相比这些 HTTP请求框架,Feign封装了HTTP 请求调用的流程,而且会强制使用者去养成面向接口编程的习惯(因为 Feign 本身就是要面向接口)。

1.3 Feign详解

1.3.1 代码示例

首先第一步,在原来的基础上新建一个Feign模块,接着引入相关依赖,引入Feign依赖,会自动引入Hystrix依赖的,如下所示:

    <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-feign</artifactId>
        </dependency>

application.yml配置如下所示:

server:
  port: 8083
spring:
  application:
    name: feign-consumer
eureka:
  client:
    service-url:
      defaultZone: http://localhost:8888/eureka/,http://localhost:8889/eureka/

接着在前面文章中的的的两个provider1provider2两个模块的服务新增几个方法,如下代码所示:

@RestController
public class HelloController {

    @RequestMapping("/hello")
    public String hello(){
        System.out.println("访问来1了......");
        return "hello1";
    }

    @RequestMapping("/hjcs")
    public List<String> laowangs(String ids){
        List<String> list = new ArrayList<>();
        list.add("laowang1");
        list.add("laowang2");
        list.add("laowang3");
        return list;
    }

    //新增的方法
    @RequestMapping(value = "/hellol", method= RequestMethod.GET)
    public String hello(@RequestParam String name) {
        return "Hello " + name;
    }

    @RequestMapping(value = "/hello2", method= RequestMethod.GET)
    public User hello(@RequestHeader String name, @RequestHeader Integer age) {
        return new User(name, age);
    }

    @RequestMapping(value = "/hello3", method = RequestMethod.POST)
    public String hello (@RequestBody User user) {
        return "Hello "+ user. getName () + ", " + user. getAge ();
    }

}

接着是上面代码所需用到的User类,代码如下所示:

public class User {

    private String name;
    private Integer age;

    //序列化传输的时候必须要有空构造方法,不然会出错
    public User() {
    }
    public User(String name, Integer age) {
        this.name = name;
        this.age = age;
    }
      //...getter setter省略
}

接下来用Feign@FeignClient(“服务名称”)映射服务调用。代码如下所示:

package hjc;

import org.springframework.cloud.netflix.feign.FeignClient;
import org.springframework.web.bind.annotation.*;

//configuration = xxx.class  这个类配置Hystrix的一些精确属性
//value=“你用到的服务名称”

@FeignClient(value = "hello-service",fallback = FeignFallBack.class)
public interface FeignService {
  //服务中方法的映射路径
    @RequestMapping("/hello")
    String hello();

    @RequestMapping(value = "/hellol", method= RequestMethod.GET)
    String hello(@RequestParam("name") String name) ;

    @RequestMapping(value = "/hello2", method= RequestMethod.GET)
    User hello(@RequestHeader("name") String name, @RequestHeader("age") Integer age);

    @RequestMapping(value = "/hello3", method= RequestMethod.POST)
    String hello(@RequestBody User user);
}

接着在Controller层注入FeiService这个接口,进行远程服务调用,代码如下:

@RestController
public class ConsumerController {

    @Autowired
    FeignService feignService;

    @RequestMapping("/consumer")
    public String helloConsumer(){
        return feignService.hello();
    }

    @RequestMapping("/consumer2")
    public String helloConsumer2(){
        String r1 = feignService.hello("hjc");
        String r2 = feignService.hello("hjc", 23).toString();
        String r3 = feignService.hello(new User("hjc", 23));
        return r1 + "-----" + r2 + "----" + r3;
    }

}

接着在Feign模块的启动类哪里打上Eureka客户端的注解@EnableDiscoveryClient Feign客户端的注解@EnableFeignClients,代码如下所示:

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class FeignApplication {

    public static void main(String[] args) {
        SpringApplication.run(FeignApplication.class, args);
    }
}

接着启动启动类,浏览器上输入localhost:8083/consumer 运行结果如下所示:

【第四章】详解Feign的实现原理

【第四章】详解Feign的实现原理

可以看到负载均衡轮询出现hello1,hello2

接着继续在浏览器上输入localhost:8083/consumer2,运行结果如下:

【第四章】详解Feign的实现原理

接下来我们进行Feign声明式调用服务下的,服务降级的使用,那么我们就必须新建一个FeignFallBack类来继承FeiService,代码如下:

package hjc;

import org.springframework.stereotype.Component;

@Component
public class FeignFallBack implements FeignService{
  //实现的方法是服务调用的降级方法
    @Override
    public String hello() {
        return "error";
    }

    @Override
    public String hello(String name) {
        return "error";
    }

    @Override
    public User hello(String name, Integer age) {
        return new User();
    }

    @Override
    public String hello(User user) {
        return "error";
    }
}

接着我们再把那两个服务提供模块provider1,provider2模块进行停止,运行结果如下所示:

【第四章】详解Feign的实现原理

可以看到我们这几个调用,都进行了服务降级了。

那么如果我们想精确的控制一下Hystrix的参数也是可以的,比方说跟Hystrix结合的参数,那么可以在FeignClient注解里面配置一个Configuration=XXX类.class属性,在哪个类里面精确的指定一下属性。

或者在application.yml里面配置,如下:

hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutinMilliseconds: 5000

ribbon:
  connectTimeout: 500

#如果想对单独的某个服务进行详细配置,如下
hello-service:
  ribbon:
    connectTimeout: 500

1.3.2 参数处理

Feign处理远程服务调用时,传递参数是通过HTTP协议传递的,参数存在的位置是请求头或请求体中。请求头传递的参数必须依赖@RequestParam注解来处理请求参数,请求体传递的参数必须依赖@RequestBody注解来处理请求参数。

1.3.2.1 代码环境如下

Contronller层通过feignClient调用微服务 获取所有任务

@Controller
@RequestMapping("tsa/task")
public class TaskController{
    @Autowired
    TaskFeignClient taskFeignClient;
 
    @PostMapping("/getAll")
    @ResponseBody
    public List<TaskVO> getAll() {
        List<TaskVO> all = taskFeignClient.getAll();
        return all;
    }
}

@FeignClient用于通知Feign组件对该接口进行代理(不需要编写接口实现),使用者可直接通过@Autowired注入。

Spring Cloud应用在启动时,Feign会扫描标有@FeignClient注解的接口,生成代理,并注册到Spring容器中。生成代理时Feign会为每个接口方法创建一个RequetTemplate对象,该对象封装了HTTP请求需要的全部信息,请求参数名、请求方法等信息都是在这个过程中确定的,Feign的模板化就体现在这里。

@FeignClient(qualifier = "taskFeignClient", name = "service-tsa",fallback = TaskFeignClientDegraded.class)
public interface TaskFeignClient {
    @PostMapping(value = "taskApiController/getAll")
    List<TaskVO> getAll();
}

微服务端

@Slf4j
@RestController
@RequestMapping("taskApiController")
public class TaskApiController{
 
    @Autowired
    private TaskService taskService;
 
    @PostMapping("/getAll")
    public List<TaskVO> getAll() {
        log.info("--------getAll-----");
        List<TaskVO>  all = taskService.getAll();
        return all;
    }
}

1.3.2.2 几个坑

1、坑一

首先再次强调Feign是通过http协议调用服务的,重点是要理解这句话

如果FeignClient中的方法有@PostMapping注解 则微服务TaskApiController中对应方法的注解也应当保持一致为@PostMapping

如果不一致,则会报404的错误

调用失败后会触发它的熔断机制,如果@FeignClient中不写@FeignClient(fallback = TaskFeignClientDegraded.class),会直接报错:

11:00:35.686 [http-apr-8086-exec-8] DEBUG c.b.p.m.b.c.AbstractBaseController - Got an exception
com.netflix.hystrix.exception.HystrixRuntimeException: TaskFeignClient#getAll() failed and no fallback available.
    at com.netflix.hystrix.AbstractCommand$22.call(AbstractCommand.java:819)
    at com.netflix.hystrix.AbstractCommand$22.call(AbstractCommand.java:804)

2、坑2:这个是最惨的了

自己写好的微服务没有运行起来,然后自己的客户端调用这个服务怎么也调用不成功还不知道问题在哪,当时自己微服务运行后,控制台如下:

Process finished with exit code 0

我以前以为Process finished with exit code 1才是运行失败的意思 ,所以只要出现 Process finished with exit code就说明运行失败

服务成功启动的标志为:

11:29:16.483 [restartedMain] INFO  c.b.p.ms.tsa.TsaServiceApplication - Started TsaServiceApplication in 37.132 seconds (JVM running for 39.983)

3、坑3、RequestParam.value() was empty on parameter 0

如果在FeignClient中的方法有参数传递一般要加@RequestParam(“xxx”)注解

错误写法

@FeignClient(qualifier = "taskFeignClient", name = "service-tsa",fallback = TaskFeignClientDegraded.class)
public interface TaskFeignClient {
    @PostMapping(value = "taskApiController/getAll")
    List<TaskVO> getAll(String name);
}

@PostMapping(value = "taskApiController/getAll")
List<TaskVO> getAll(@RequestParam String name);

正确写法

@PostMapping(value = "taskApiController/getAll")
List<TaskVO> getAll(@RequestParam("name") String name);

在微服务那边可以不写这个注解,这个也是自己开发的时候烦的小错误,吸取教训。

疑问
SpringMVCSpringboot 中都可以使用 @RequestParam 注解,不指定 value的用法,为什么到了 Spring cloud中的Feign 这里就不行了呢?

这是因为和 Feign的实现有关。Feign 的底层使用的是httpclient,在低版本中会产生这个问题,听说高版本中已经对这个问题修复了。

4、 坑四 FeignClientpost传递对象和`consumes = "application/json"

按照坑三的意思,应该这样写

@FeignClient(qualifier = "taskFeignClient", name = "service-tsa",fallback = TaskFeignClientDegraded.class)
public interface TaskFeignClient {
    @PostMapping(value = "taskApiController/getAll")
    List<TaskVO> getAll(@RequestParam("vo") TaskVO vo);
}

很意外报错

16:00:33.770 [http-apr-8086-exec-1] DEBUG c.b.p.a.s.PrimusCasAuthenticationFilter - proxyReceptorRequest = false
16:00:33.770 [http-apr-8086-exec-1] DEBUG c.b.p.a.s.PrimusCasAuthenticationFilter - proxyTicketRequest = false
16:00:33.770 [http-apr-8086-exec-1] DEBUG c.b.p.a.s.PrimusCasAuthenticationFilter - requiresAuthentication = false
16:00:34.415 [hystrix-service-tsa-2] DEBUG c.b.p.m.b.f.PrimusSoaFeignErrorDecoder - 
error json:{
"timestamp":1543564834395,
"status":500,
"error":"Internal Server Error",
"exception":"org.springframework.web.method.annotation.MethodArgumentConversionNotSupportedException",
"message":"Failed to convert value of type 'java.lang.String' to required type 'com.model.tsa.vo.TaskVO'; 
nested exception is java.lang.IllegalStateException: 
Cannot convert value of type 'java.lang.String' to required type 'com.model.tsa.vo.TaskVO':
no matching editors or conversion strategy found","path":"/taskApiController/getAll"  }

看着错误信息想了半天突然想明白了:
Feign本质是通过http 请求的,http怎么能直接传递对象呢,一般都是把对象转换为json通过post请求传递的。

正确写法应当如下

@FeignClient(qualifier = "taskFeignClient", name = "service-tsa",fallback = TaskFeignClientDegraded.class)
public interface TaskFeignClient {
    @PostMapping(value = "taskApiController/getAll",,consumes = "application/json")
    List<TaskVO> getAll(TaskVO vo);
}

也可以这样写

  @PostMapping(value = "taskApiController/getAll")
  List<TaskVO> getAll(@RequestBody TaskVO vo);

此时不用,consumes = "application/json"

但是第一种写法最正确的 因为FeignClient是在我们本地直接调用的,根本不需要这个注解,Controller调用方法的时候就是直接将对象传给FeignClient,而FeignClient通过http调用服务时则需要将对象转换成json传递。

微服务代码如下所示:

@Slf4j
@RestController
@RequestMapping("taskApiController")
public class TaskApiController{
 
    @Autowired
    private TaskService taskService;
 
    @PostMapping("/getAll")
    public List<TaskVO> getAll(@RequestBody TaskVO vo) {
        log.info("--------getAll-----");
        List<TaskVO>  all = taskService.getAll();
        return all;
    }
}

我第一次写这个的时候方法参数里面什么注解都没加,可以正常跑通,但是传过去的对象却为初始值,实际上那是因为对象根本就没传。

当然还是推荐使用post请求传递对象的:
在使用Feign来调用Get请求接口时,如果方法的参数是一个对象,例如:

@FeignClient ( "microservice-provider-user" )  
public  interface  UserFeignClient {
      
   @RequestMapping (value =  "/user" , method = RequestMethod.GET)   
   public  User get0(User user);       
}

那么在调试的时候你会一脸懵逼,因为报了如下错误:

feign.FeignException: status  405  reading UserFeignClient#get0(User); content:
 
{ "timestamp" : 1482676142940 , "status" : 405 , "error" : "Method Not Allowed" ,  "exception" : "org.springframework.web.HttpRequestMethodNotSupportedException" , "message" : "Request method 'POST' not supported" , "path" : "/user" }

明明定义的Get请求,怎么被转换成了Post?

调整不用对象传递,一切OK,没毛病,可仔细想想,你想写一堆长长的参数吗?用一个不知道里边有什么鬼的Map吗?或者转换为post?这似乎与REST风格不太搭,会浪费url资源,我们还需要在url定义上来区分Get或者Post

我很好奇,我定义的Get请求怎么就被转成了Post,于是就开始逐行调试,直到我发现了这个:

private  synchronized  OutputStream getOutputStream0()  throws  IOException {
        
   try  {   
       if (! this .doOutput) {  
             throw  new  ProtocolException( "cannot write to a URLConnection if doOutput=false - call setDoOutput(true)" );        
  }  else  {      
       if ( this .method.equals( "GET" )) {    
            this .method =  "POST" ;
  }

这段代码是在 HttpURLConnection 中发现的,jdk原生的http连接请求工具类,这个是Feign默认使用的连接工具实现类,但我记得我们的工程用的是apachhttpclient替换掉了原生的UrlConnection,我们用了如下配置:

feign:    
   httpclient:
     enabled:  true

同时在依赖中引入apachehttpclient

<dependency>
  <groupId>org.apache.httpcomponents</groupId>
  <artifactId>httpclient</artifactId>
  <version> 4.5.3 </version>
</dependency>

发现我们少配置了一个依赖:

<!-- 使用Apache HttpClient替换Feign原生httpclient -->       
<dependency>     
   <groupId>com.netflix.feign</groupId>     
   <artifactId>feign-httpclient</artifactId>
   <version>${feign-httpclient}</version>    
</dependency>

那我加上这个依赖后,请求通了,但是接口接收到对象里边属性值是NULL;再看下边的定义是不是少点什么

@RequestMapping (value =  "/user" , method = RequestMethod.GET)
   public  User get0(User user);

对,少了一个注解:@RequestBody,既然使用对象传递参数,那传入的参数会默认放在RequesBody中,所以在接收的地方需要使用@RequestBody来解析,最终就是如下定义:

@RequestMapping (value =  "/user" , method = RequestMethod.GET,consumer="application/json")   
   public  User get0( @RequestBody  User user);

1.3.2.3 传递对象的另一种方法和多参传递

1、GET请求多参数的URL
假设我们请求的URL包含多个参数,例如http://microservice-provider-user/get?id=1&username=张三 ,要怎么办呢?

我们知道Spring CloudFeign添加了Spring MVC的注解支持,那么我们不妨按照Spring MVC的写法尝试一下:

@FeignClient("microservice-provider-user")
public interface UserFeignClient {
  @RequestMapping(value = "/get", method = RequestMethod.GET)
  public User get0(User user);
}

然而我们测试时会发现该写法不正确,我们将会收到类似以下的异常:

feign.FeignException: status 405 reading UserFeignClient#get0(User); content:
{"timestamp":1482676142940"status":405"error":"Method Not Allowed""exception":"org.springframework.web.HttpRequestMethodNotSupportedException""message":"Request method 'POST' not supported""path":"/get"}

由异常可知,尽管指定了GET方法,Feign依然会发送POST请求。

正确写法如下:

(1) 方法一

@FeignClient(name = "microservice-provider-user")
public interface UserFeignClient {
  @RequestMapping(value = "/get", method = RequestMethod.GET)
  public User get1(@RequestParam("id") Long id, @RequestParam("username") String username);
}

这是最为直观的方式,URL有几个参数,Feign接口中的方法就有几个参数。使用@RequestParam注解指定请求的参数是什么。

(2) 方法二

@FeignClient(name = "microservice-provider-user")
public interface UserFeignClient {
  @RequestMapping(value = "/get", method = RequestMethod.GET)
  public User get2(@RequestParam Map<StringObject> map);
}

多参数的URL也可以使用Map去构建

当目标URL参数非常多的时候,可使用这种方式简化Feign接口的编写。

POST请求包含多个参数
下面我们来讨论如何使用Feign构造包含多个参数的POST请求。

实际就是坑四,把参数封装成对象传递过去就可以了

1.3.2.4 最后总结一下

Feign的Encoder、Decoder和ErrorDecoder

Feign将方法签名中方法参数对象序列化为请求参数放到HTTP请求中的过程,是由编码器(Encoder)完成的。同理,将HTTP响应数据反序列化为java对象是由解码器(Decoder)完成的。

默认情况下,Feign会将标有@RequestParam注解的参数转换成字符串添加到URL中,将没有注解的参数通过Jackson转换成json放到请求体中。

注意,如果在@RequetMapping中的method将请求方式指定为get,那么所有未标注解的参数将会被忽略,例如:

@RequestMapping(value = "/group/{groupId}", method = RequestMethod.GET)
void update(@PathVariable("groupId") Integer groupId, @RequestParam("groupName") String groupName, DataObject obj);

此时因为声明的是GET请求没有请求体,所以obj参数就会被忽略。

Spring Cloud环境下,FeignEncoder只会用来编码没有添加注解的参数。如果你自定义了Encoder, 那么只有在编码obj参数时才会调用你的Encoder。对于Decoder, 默认会委托给SpringMVC中的MappingJackson2HttpMessageConverter类进行解码。只有当状态码不在200 ~ 300之间时ErrorDecoder才会被调用。ErrorDecoder的作用是可以根据HTTP响应信息返回一个异常,该异常可以在调用Feign接口的地方被捕获到。我们目前就通过ErrorDecoder来使Feign接口抛出业务异常以供调用者处理。

FeignHTTP Client

Feign在默认情况下使用的是JDK原生的URLConnection发送HTTP请求,没有连接池,但是对每个地址会保持一个长连接,即利用HTTPpersistence connection。我们可以用ApacheHTTP Client替换Feign原始的http client, 从而获取连接池、超时时间等与性能息息相关的控制能力。Spring CloudBrixtion.SR5版本开始支持这种替换,首先在项目中声明Apache HTTP Clientfeign-httpclient依赖:

<!-- 使用Apache HttpClient替换Feign原生httpclient -->
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
        </dependency>
        <dependency>
            <groupId>com.netflix.feign</groupId>
            <artifactId>feign-httpclient</artifactId>
            <version>${feign-httpclient}</version>
        </dependency>

然后在application.properties中添加:

feign.httpclient.enabled=true

通过Feign, 我们能把HTTP远程调用对开发者完全透明,得到与调用本地方法一致的编码体验。这一点与阿里Dubbo中暴露远程服务的方式类似,区别在于Dubbo是基于私有二进制协议,而Feign本质上还是个HTTP客户端。如果是在用Spring Cloud Netflix搭建微服务,那么Feign无疑是最佳选择。

1.4 调用原理解析

Feign远程调用,核心就是通过一系列的封装和处理,将以JAVA注解的方式定义的远程调用API接口,最终转换成HTTP的请求形式,然后将HTTP的请求的响应结果,解码成JAVA Bean,放回给调用者。Feign远程调用的基本流程,大致如下图所示。

【第四章】详解Feign的实现原理
从上图可以看到,Feign通过处理注解,将请求模板化,当实际调用的时候,传入参数,根据参数再应用到请求上,进而转化成真正的 Request请求。通过Feign以及JAVA的动态代理机制,使得Java开发人员,可以不用通过HTTP框架去封装HTTP请求报文的方式,完成远程服务的HTTP调用。

Feign优化
(1)GZIP压缩
gzip是一种数据格式,采用deflate算法压缩数据。当Gzip压缩到一个纯文本数据时,可以减少70%以上的数据大小。

gzip作用:网络数据经过压缩后实际上降低了网络传输的字节数,最明显的好处就是可以加快网页加载的速度。

只配置Feign请求-应答的GZIP压缩

# feign gzip
# 局部配置。只配置feign技术相关的http请求-应答中的gzip压缩。
# 配置的是application client和application service之间通讯是否使用gzip做数据压缩。
# 和浏览器到application client之间的通讯无关。
# 开启feign请求时的压缩, application client -> application service
feign.compression.request.enabled=true
# 开启feign技术响应时的压缩,  application service -> application client
feign.compression.response.enabled=true
# 设置可以压缩的请求/响应的类型。
feign.compression.request.mime-types=text/xml,application/xml,application/json
# 当请求的数据容量达到多少的时候,使用压缩。默认是2048字节。
feign.compression.request.min-request-size=512
配置全局的GZIP压缩

# spring boot gzip
# 开启spring boot中的gzip压缩。就是针对和当前应用所有相关的http请求-应答的gzip压缩。
server.compression.enabled=true
# 哪些客户端发出的请求不压缩,默认是不限制
server.compression.excluded-user-agents=gozilla,traviata
# 配置想压缩的请求/应答数据类型,默认是 text/html,text/xml,text/plain
server.compression.mime-types=application/json,application/xml,text/html,text/xml,text/plain
# 执行压缩的阈值,默认为2048
server.compression.min-response-size=512

版权声明:程序员胖胖胖虎阿 发表于 2022年8月31日 下午12:40。
转载请注明:【第四章】详解Feign的实现原理 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...