10000 字 - 深入理解 OpenFeign 的架構原理

大家好,我是悟空呀。

上次我們深入講解了 Ribbon 的架構原理,這次我們再來看下 Feign 遠程調用的架構原理。

一、理解遠程調用

遠程調用怎麼理解呢?

遠程調用本地調用是相對的,那我們先說本地調用更好理解些,本地調用就是同一個 Service 裏面的方法 A 調用方法 B。

那遠程調用就是不同 Service 之間的方法調用。Service 級的方法調用,就是我們自己構造請求 URL 和請求參數,就可以發起遠程調用了。

在服務之間調用的話,我們都是基於 HTTP 協議,一般用到的遠程服務框架有 OKHttp3,Netty, HttpURLConnection 等。其調用流程如下:

但是這種虛線方框中的構造請求的過程是很繁瑣的,有沒有更簡便的方式呢?

Feign 就是來簡化我們發起遠程調用的代碼的,那簡化到什麼程度呢?簡化成就像調用本地方法那樣簡單。

比如我的開源項目 PassJava 中的使用 Feign 執行遠程調用的代碼:

//遠程調用拿到該用戶的學習時長
R memberStudyTimeList = studyTimeFeignService.getMemberStudyTimeListTest(id);

而 Feign 又是 Spring Cloud 微服務技術棧中非常重要的一個組件,如果讓你來設計這個微服務組件,你會怎麼來設計呢?

我們需要考慮這幾個因素:

接下來我們圍繞這些核心問題來一起看下 Feign 的設計原理。

二、Feign 和 OpenFeign

OpenFeign 組件的前身是 Netflix Feign 項目,它最早是作爲 Netflix OSS 項目的一部分,由 Netflix 公司開發。後來 Feign 項目被貢獻給了開源組織,於是纔有了我們今天使用的 Spring Cloud OpenFeign 組件。

Feign 和 OpenFeign 有很多大同小異之處,不同的是 OpenFeign 支持 MVC 註解。

可以認爲 OpenFeign 爲 Feign 的增強版。

簡單總結下 OpenFeign 能用來做什麼:

三、OpenFeign 如何用?

OpenFeign 的使用也很簡單,這裏還是用我的開源 SpringCloud 項目 PassJava 作爲示例。

開源地址: https://github.com/Jackson0714/PassJava-Platform

喜歡的小夥伴來點個 Star 吧,衝 2K Star。

Member 服務遠程調用 Study 服務的方法 memberStudyTime(),如下圖所示。

第一步:Member 服務需要定義一個 OpenFeign 接口:

@FeignClient("passjava-study")
public interface StudyTimeFeignService {
    @RequestMapping("study/studytime/member/list/test/{id}")
    public R getMemberStudyTimeListTest(@PathVariable("id") Long id);
}

我們可以看到這個 interface 上添加了註解@FeignClient,而且括號裏面指定了服務名:passjava-study。顯示聲明這個接口用來遠程調用 passjava-study服務。

第二步:Member 啓動類上添加 @EnableFeignClients註解開啓遠程調用服務,且需要開啓服務發現。如下所示:

@EnableFeignClients(basePackages = "com.jackson0714.passjava.member.feign")
@EnableDiscoveryClient

第三步:Study 服務定義一個方法,其方法路徑和 Member 服務中的接口 URL 地址一致即可。

URL 地址:"study/studytime/member/list/test/{id}"

@RestController
@RequestMapping("study/studytime")
public class StudyTimeController {
    @RequestMapping("/member/list/test/{id}")
    public R memberStudyTimeTest(@PathVariable("id") Long id) {
       ... 
    }
}

第四步:Member 服務的 POM 文件中引入 OpenFeign 組件。

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

第五步:引入 studyTimeFeignService,Member 服務遠程調用 Study 服務即可。

Autowired
private StudyTimeFeignService studyTimeFeignService;

studyTimeFeignService.getMemberStudyTimeListTest(id);

通過上面的示例,我們知道,加了 @FeignClient 註解的接口後,我們就可以調用它定義的接口,然後就可以調用到遠程服務了。

這裏你是否有疑問:爲什麼接口都沒有實現,就可以調用了?

OpenFeign 使用起來倒是簡單,但是裏面的原理可沒有那麼簡單,OpenFeign 幫我們做了很多事情,接下來我們來看下 OpenFeign 的架構原理。

四、梳理 OpenFeign 的核心流程

先看下 OpenFeign 的核心流程圖:

針對上面的流程,我們再來看下每一步的設計原理。首先主動掃包是如何掃的呢?

五、OpeFeign 包掃描原理

上面的 PassJava 示例代碼中,涉及到了一個 OpenFeign 的註解:@EnableFeignClients。根據字面意思可以知道,可以註解是開啓 OpenFeign 功能的。

包掃描的基本流程如下:

(1)@EnableFeignClients 這個註解使用 Spring 框架的 Import 註解導入了 FeignClientsRegistrar 類,開始了 OpenFeign 組件的加載。PassJava 示例代碼如下所示。

// 啓動類加上這個註解
@EnableFeignClients(basePackages = "com.jackson0714.passjava.member.feign")

// EnableFeignClients 類還引入了 FeignClientsRegistrar 類
@Import(FeignClientsRegistrar.class)
public @interface EnableFeignClients {
  ...
}

(2)FeignClientsRegistrar 負責 Feign 接口的加載。

源碼如下所示:

@Override
public void registerBeanDefinitions(AnnotationMetadata metadata,
      BeanDefinitionRegistry registry) {
   // 註冊配置
   registerDefaultConfiguration(metadata, registry);
   // 註冊 FeignClient
   registerFeignClients(metadata, registry);
}

(3)registerFeignClients 會掃描指定包。

核心源碼如下,調用 find 方法來查找指定路徑 basePackage 的所有帶有 @FeignClients 註解的帶有 @FeignClient 註解的類、接口。

Set<BeanDefinition> candidateComponents = scanner
      .findCandidateComponents(basePackage);

(4)只保留帶有 @FeignClient 的接口。

// 判斷是否是帶有註解的 Bean。
if (candidateComponent instanceof AnnotatedBeanDefinition) {
  // 判斷是否是接口
   AnnotatedBeanDefinition beanDefinition = (AnnotatedBeanDefinition) candidateComponent;
   AnnotationMetadata annotationMetadata = beanDefinition.getMetadata();
  // @FeignClient 只能指定在接口上。
   Assert.isTrue(annotationMetadata.isInterface(),
         "@FeignClient can only be specified on an interface");

接下來我們再來看這些掃描到的接口是如何註冊到 Spring 中。

六、註冊 FeignClient 到 Spring 的原理

還是在 registerFeignClients 方法中,當 FeignClient 掃描完後,就要爲這些 FeignClient 接口生成一個動態代理對象。

順藤摸瓜,進到這個方法裏面,可以看到這一段代碼:

BeanDefinitionBuilder definition = BeanDefinitionBuilder
      .genericBeanDefinition(FeignClientFactoryBean.class);

核心就是 FeignClientFactoryBean 類,根據類的名字我們可以知道這是一個工廠類,用來創建 FeignClient Bean 的。

我們最開始用的 @FeignClient,裏面有個參數 "passjava-study",這個是註解的屬性,當 OpenFeign 框架去創建 FeignClient Bean 的時候,就會使用這些參數去生成 Bean。流程圖如下:

源碼如下:

// 生成 beanDefinition
AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();
// 轉換成 holder,包含了 beanDefinition, alias, beanName 信息
BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className,
    new String[] { alias });
// 註冊到 Spring 上下文中。
BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);

上面我們已經知道 FeignClient  的接口是如何註冊到 Spring 容器中了。後面服務要調用接口的時候,就可以直接用 FeignClient 的接口方法了,如下所示:

@Autowired
private StudyTimeFeignService studyTimeFeignService;

// 省略部分代碼
// 直接調用 
studyTimeFeignService.getMemberStudyTimeListTest(id);

但是我們並沒有細講這個 FeignClient 的創建細節,下面我們看下 FeignClient 的創建細節,這個也是 OpenFeign 核心原理。

七、OpenFeign 動態代理原理

上面的源碼解析中我們也提到了是由這個工廠類  FeignClientFactoryBean 來創建 FeignCient Bean,所以我們有必要對這個類進行剖析。

在創建 FeignClient Bean 的過程中就會去生成動態代理對象。調用接口時,其實就是調用動態代理對象的方法來發起請求的。

分析動態代理的入口方法爲 getObject()。源碼如下所示:

Targeter targeter = get(context, Targeter.class);
return (T) targeter.target(this, builder, context,
      new HardCodedTarget<>(this.type, this.name, url));

接着調用 target 方法這一塊,裏面的代碼真的很多很細,我把核心的代碼拿出來給大家講下,這個 target 會有兩種實現類:

DefaultTargeter 和 HystrixTargeter。而不論是哪種 target,都需要去調用 Feign.java 的 builder 方法去構造一個 feign client。

在構造的過程中,依賴 ReflectiveFeign 去構造。源碼如下:

// 省略部分代碼
public class ReflectiveFeign extends Feign {
  // 爲 feign client 接口中的每個接口方法創建一個 methodHandler
 public <T> T newInstance(Target<T> target) {
    for(...) {
      methodToHandler.put(method, handler);
    }
    // 基於 JDK 動態代理的機制,創建了一個 passjava-study 接口的動態代理,所有對接口的調用都會被攔截,然後轉交給 handler 的方法。
    InvocationHandler handler = factory.create(target, methodToHandler);
    T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(),
          new Class<?>[] {target.type()}, handler);
}

ReflectiveFeign 做的工作就是爲帶有 @FeignClient 註解的接口,創建出接口方法的動態代理對象。

比如示例代碼中的接口 StudyTimeFeignService,會給這個接口中的方法 getMemberStudyTimeList 創建一個動態代理對象。

@FeignClient("passjava-study")
public interface StudyTimeFeignService {
    @RequestMapping("study/studytime/member/list/test/{id}")
    public R getMemberStudyTimeList(@PathVariable("id") Long id);
}

創建動態代理的原理圖如下所示:

這個動態代理對象的結構如下所示,它包含了所有接口方法的 MethodHandler。

八、解析 MVC 註解的原理

上面我們講到了接口上是有一些註解的,比如 @RequestMapping,@PathVariable,這些註解統稱爲 Spring MVC 註解。但是由於 OpenFeign 是不理解這些註解的,所以需要進行一次解析。

解析的流程圖如下:

而解析的類就是 SpringMvcContract 類,調用 parseAndValidateMetadata  進行解析。解析完之後,就會生成元數據列表。源碼如下所示:

List<MethodMetadata> metadata = contract.parseAndValidateMetadata(target.type());

這個類在這個路徑下,大家可以自行翻閱下如何解析的,不在本篇的討論範圍內。

https://github.com/spring-cloud/spring-cloud-openfeign/blob/main/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/support/SpringMvcContract.java

這個元數據 MethodMetadata 裏面有什麼東西呢?

然後每個接口方法就會有對應的一個 MethodHandler,它裏面就包含了元數據,當我們調用接口方法時,其實是調用動態代理對象的 MethodHandler 來發送遠程調用請求的。

上面我們針對 OpenFeign 框架如何爲 FeignClient 接口生成動態代理已經講完了,下面我們再來看下當我們調用接口方法時,動態代理對象是如何發送遠程調用請求的。

九、OpenFeign 發送請求的原理

先上流程圖:

還是在 ReflectiveFeign 類中,有一個 invoke 方法,會執行以下代碼:

dispatch.get(method).invoke(args);

這個 dispatch 我們之前已經講解過了,它指向了一個 HashMap,裏面包含了 FeignClient 每個接口的 MethodHandler 類。

這行代碼的意思就是根據 method 找到 MethodHandler,調用它的 invoke 方法,並且傳的參數就是我們接口中的定義的參數。

那我們再跟進去看下這個 MethodHandler invoke 方法裏面做了什麼事情。源碼如下所示:

public Object invoke(Object[] argv) throws Throwable {
  RequestTemplate template = buildTemplateFromArgs.create(argv);
  ...
}

我們可以看到這個方法裏面生成了 RequestTemplate,它的值類似如下:

GET /study/list/test/1 HTTP/1.1

RequestTemplate 轉換成 Request,它的值類似如下:

GET http://passjava-study/study/list/test/1 HTTP/1.1

這路徑不就是我們要 study 服務的方法,這樣就可以直接調用到 study 服了呀!

OpenFeign 幫我們組裝好了發起遠程調用的一切,我們只管調用就好了。

接着 MethodHandler 會執行以下方法,發起 HTTP 請求。

client.execute(request, options);

從上面的我們要調用的服務就是 passjava-study,但是這個服務的具體 IP 地址我們是不知道的,那 OpenFeign 是如何獲取到 passjava-study 服務的 IP 地址的呢?

回想下最開始我們提出的核心問題:OpenFeign 是如何進行負載均衡的?

我們是否可以聯想到上一講的 Ribbon 負載均衡,它不就是用來做 IP 地址選擇的麼?

那我們就來看下 OpenFeign 又是如何和 Ribbon 進行整合的。

十、OpenFeign 如何與 Ribbon 整合的原理

爲了驗證 Ribbon 的負載均衡,我們需要啓動兩個 passjava-study 服務,這裏我啓動了兩個服務,端口號分別爲 12100 和 12200,IP 地址都是本機 IP:192.168.10.197。

接着上面的源碼繼續看,client.execute() 方法其實會調用 LoadBalancerFeignClient 的 exceute 方法。

這個方法裏面的執行流程如下圖所示:

GET http:///study/list/test/1 HTTP/1.1
Server svc = lb.chooseServer(loadBalancerKey);

通過 debug 調試,我們可以看到兩次請求的端口號不一樣,一個是 12200,一個是 12100,說明確實進行了負載均衡。

那大家有沒有疑問,Ribbon 是如何拿到服務地址列表的?這個就是上一講 Ribbon 架構裏面的內容。

Ribbon 的核心組件 ServerListUpdater,用來同步註冊表的,它有一個實現類 PollingServerListUpdater ,專門用來做定時同步的。默認 1s 後執行一個 Runnable 線程,後面就是每隔 30s 執行 Runnable 線程。這個 Runnable 線程就是去獲取註冊中心的註冊表的。

十一、OpenFeign 處理響應的原理

當遠程服務 passjava-study 處理完業務邏輯後,就會返回 reponse 給 passjava-member 服務了,這裏還會對 reponse 進行一次解碼操作。

Object result = decode(response);

這個裏面做的事情就是調用 ResponseEntityDecoder 的 decode 方法,將 Json 字符串轉化爲 Bean 對象。

十二、總結

本文通過我的開源項目 PassJava 中用到的 OpenFeign 作爲示例代碼作爲入口進行講解。然後以圖解 + 解讀源碼的方式深入剖析了 OpenFeign 的運行機制和架構設計。

核心思想:

OpenFeign 的核心流程圖:

我是悟空,努力變強,變身超級賽亞人!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7EJTSw5WGE5bYbo00nZ4jA