我試圖通過這篇文章告訴你,什麼是神奇的泛化調用。

你好呀,我是歪歪。

關於 RPC 調用,大家肯定都是比較熟悉的了,就是在微服務架構下解決系統間通信問題的一個玩意。

其中的典型代表之一就是 Dubbo 了:

在微服務架構下,我們針對某個 RPC 接口,我們一般有兩個角色。

  • 服務消費者 (Dubbo Consumer),發起業務調用或 RPC 通信的 Dubbo 進程
  • 服務提供者 (Dubbo Provider),接收業務調用或 RPC 通信的 Dubbo 進程

假設我是服務消費者,想要調用某個服務,只要我們鏈接到的是同一個服務註冊中心,那麼找對應服務要到 API 包對應的 Maven 座標,引入到項目中,就類似於這樣的東西:

<dependency>
    <groupId>org.apache.dubbo</groupId>
    <artifactId>dubbo-spring-boot-demo-interface</artifactId>
    <version>${project.parent.version}</version>
</dependency>

那麼對於這個 API 包中的接口,雖然我們沒有具體的實現類,但是我們還是能像調用本地方法一樣調用該服務提供的接口。

這些都是常規的東西了,你肯定是門清。

那我現在問你一個問題啊:

我是服務消費者,我要調用一個服務提供者的 RPC 接口,但是我又不想引入它的 API 包,或者我根本就拉取不到它的 API 包,那麼我應該怎麼辦?

如果你要非給我說:這不可能,既然是要消費別人的接口,那麼肯定要拿到 API 包纔對,你不拿就是你偷懶。

那我再給你舉個歪師傅在實際開發過程中遇到的具體的例子:網關服務。

網關是個什麼玩意?

是你對外請求的統一入口,做接受請求、分發請求用的,作爲鏈接各個微服務的角色,你勢必要使用到下游的若干個 RPC 服務。

你怎麼辦?

引入所有的服務提供方的 API 包,然後發起調用嗎?

可以是可以,但是不夠優雅。

你想,如果有一個服務提供方發佈了新的 API 包,你也需要更新版本,重新發版?

或者新來一個服務提供者 E,你需要引入其 API 包,然後重新發版?

網關應該是一個穩定的基礎服務,它提供的是聚攏 API 接口、轉發調用的基礎功能,不應該頻繁發版,不應該主動去關注下游的服務接口變化。平臺本身不應該依賴於服務提供方的接口 API。

不主動,才能更加優雅,也能讓自己更加輕鬆。

那麼怎麼才能做到不主動關注呢?

這個事情,總有一方要主動的,所以網關層不主動,那麼服務提供者就需要主動起來。

我們可以搞成這樣:

網關層提供一個 API 接口發佈平臺,當服務提供者的接口有新增或者發生變化的時候,由對應系統的接口管理人員把接口信息,比如接口路徑、方法、入參、出參、方法功能說明、方法負責團隊、接口對接人等等這些消息維護到 API 接口發佈平臺上。

這樣網關層就可以從 API 接口發佈平臺獲取到所有服務的所有接口,並不需要引入任何服務提供者的 API 包。

這樣就解決了“主動”的問題,如果接口有變化,請在 API 接口發佈平臺進行登記,從而解決了網關頻繁發佈的問題。

在官網上,除了網關的場景外,還提到一個測試平臺的場景,道理是一樣的,我就不贅述了:

https://cn.dubbo.apache.org/zh-cn/overview/mannual/java-sdk/advanced-features-and-usage/service/generic-reference/

解決了“主動”的問題,那麼下一個問題就隨之而來了:知道所有服務的所有接口然後呢,怎麼發起調用呢?

這個時候泛化調用,啪的一下就站出來了:鋪墊了這麼多,終於該老子上場了。

泛化調用

啥是泛化調用呢?

在 Dubbo 官網上是這樣介紹的:

首先需要強調的是“泛化調用”不是 Dubbo 特有的,它是一個功能,很多的框架都支持泛化調用,只是我這裏用的 Dubbo 做演示而已。

老規矩,先花五分鐘時間搭個 Demo 出來再說。

這個 Demo 我也是跟着網上的 quick start 搞的:

https://cn.dubbo.apache.org/zh-cn/overview/quickstart/java/spring-boot/

可以說寫的非常詳細了,你就跟着官網的步驟一步步的搞就行了。

我這個 Demo 稍微不一樣的是我在消費者模塊裏面搞了一個 Http 接口:

在接口裏面發起了 RPC 調用,模擬從前端頁面發起請求的場景,更加符合我們的開發習慣。

爲了起到強調作用,我再次把這個部分給你框起來:

DemoService 是 RPC 接口,它的實現類是這樣的:

在我的消費者模塊裏面爲什麼能注入這個 DemoService 並調用它的 sayHello 方法呢?

因爲我引入了對應的依賴包。

那麼,如果我把這個依賴包去掉,也就是模擬我們前面說的“不主動”的動作,這個 DemoService 肯定會報錯,找不到這個類:

那麼我們應該怎麼去修改一下這個 Demo,讓它泛化起來呢?

非常簡單:

注入 DemoService 修改爲注入 GenericService。

有的小夥伴可能會問 GenericService 是怎麼冒出來的?

你先別管它是怎麼冒出來的,我現在是在給你鋪墊 Demo,後面要撕給你看。你現在只需要知道它是 Dubbo 框架裏面的包,並不會讓你引用額外的包就行了:

現在 Demo 就算是搭好了,本地啓動一個 zk,然後把服務提供者啓動起來,再把消費者啓動起來,最後輕輕的發起一個調用:

朋友,它不就跑起來了嗎?

我沒有引用接口的 api 包,我不也正常發起了調用,然後拿到了返回值嗎?

啥原理

你就想,遠程調用,你把一些花裏胡哨的東西都拿掉之後,它的本質是什麼?

本質就是幫助解決微服務組件之間的通信問題,不管是基於 HTTP、HTTP/2、TCP 還是什麼其他的通信協議,解決的是網絡連接管理、數據傳輸等基礎問題。

雖然我沒有引用 API 的對應的包,但是我前面我不是說了嗎,我們有一個 API 接口發佈平臺,這個平臺裏面有接口維護人員提供的接口路徑、方法、入參、出參這些關鍵信息。

所以我在調用的時候可以拿到相關的信息,以一種通用的方式,比如字符串的方式告訴 RPC 框架,我要調用的是 DemoService 接口的 sayHello 方法,入參是 String 類型的 world 字符串:

如果是你來開發一個 RPC 框架,調用方都把這些關鍵信息給你了,無非就是你幫忙多做幾步類似於反射、序列化之類的處理。而處理的這個過程,就是泛化調用的過程。

泛化調用不是 Dubbo 特有的,但是具體到 Dubbo 這個框架裏面,具體是這樣的。

首先,Dubbo 裏面有一層 Filter,這些 Filter 構成了一個 Filter 鏈條:

Filter 用來對每次服務調用做一些預處理、後處理動作,使用 Filter 可以完成訪問日誌、加解密、流量統計、參數驗證等任務。

一次請求過程中可以植入多個 Filter,Filter 之間相互獨立沒有依賴。

從消費端視角,它在請求發起前基於請求參數等做一些預處理工作,在接收到響應後,對響應結果做一些後置處理。

從提供者視角,在接收到訪問請求後,在返回響應結果前做一些預處理。

所以我們的泛化調用,也是通過下面這兩個 Filter 來搞事情的:

  • org.apache.dubbo.rpc.filter.GenericFilter
  • org.apache.dubbo.rpc.filter.GenericImplFilter

那麼問題就來了?

爲什麼要兩個 Filter 呢?

因爲要完成一次泛化調用,消費端和服務提供者都需要感知到並做相關的處理,所以一個是消費端的 Fliter,一個是服務提供者的 Fliter:

知道了對應的 Filter,關於泛化調用的所有祕密都藏在 Filter 對應的源碼裏面。

歪師傅帶着你簡單的看一眼。

GenericImplFilter.invoke

首先,我們在方法的消費者對應的 Fliter 的入口處打上斷點:

org.apache.dubbo.rpc.filter.GenericImplFilter#invoke

可以看到分爲了三個模塊。

  • isCallingGenericImpl:calling a generic impl service,判斷是否調用的是一個實現了泛化接口的接口。
  • isMakingGenericCall:making a generic call to a normal service,把泛化調用轉換爲一個常規調用。
  • invoker.invoke(invocation):常規調用。

我們研究的情況屬於 isMakingGenericCall 這個分支。

既然是要把泛化調用轉換爲一個常規調用,那麼 Dubbo 是怎麼判斷這是一個泛化調用的呢?

org.apache.dubbo.rpc.filter.GenericImplFilter#isMakingGenericCall

  • 判斷本次調用的方法名稱是否是 invokeAsync
  • 判斷本次調用的入參個數是否是 3 個
  • 判斷容器上下文中的 generic 參數是否對應着泛化調用的序列化方法。

我們一個個的看。

invokeAsync 方法是 GenericService 這個接口裏面的方法。而這兩個方法的入參個數都是三個。

然後有個 generic 參數,在我的 Demo 裏面這個參數是 true:

當我啪的一下跟進到 isGeneric 方法中,才發現這裏面別有洞天:

原來 generic 這個參數不只是可以爲 “true”,它不同的值,代表着不同的序列化方式。

通過這部分源碼可以看出來,泛化調用對於客戶端,即在 GenericImplFilter 裏面,並沒有做什麼特別的操作,注意還是參數校驗。

如果入參和對應的序列化方法不能匹配起來,即使的拋出異常,這樣符合 Dubbo 框架的 fast-fail 思想。

但是其實看到這裏的時候,我有一個小疑問,如果我寫一個這樣的類:

public interface WhyService {
    Object $invoke(String a,String b,String c);
}

和 GenericService 類一樣,有 $invoke 方法,而且也是三個參數。

然後在上下文中塞個 generic=true 進去,那麼是不是也能騙過這段代碼呢,也能進入到 isMakingGenericCall 方法裏面呢?

從代碼上看確實是這樣的,那麼 Dubbo 到底是怎麼規避這些“惡意”冒充者的呢?

我也不知道。

先存個疑吧,接着往下看。

GenericFilter.invoke

我們同樣在服務端打上斷點,當這個請求來到服務端的時候,我們再看看服務端的情況。

org.apache.dubbo.rpc.filter.GenericFilter#invoke

可以看到這個方法邏輯都在 if 判斷爲 true 的時候。

而這個判斷我們剛剛在客戶端已經解析過了,只是多了一個判斷:

!GenericService.class.isAssignableFrom(invoker.getInterface())

看看發起調用的接口類是不是 GenericService 類的子類,如果是,則進入到 if 分支裏面。

朋友,這就有點意思了。幾秒鐘之前我們還在存疑,然後啪的一下疑問就解開了。

直接就是恍然大悟了。

我這個類:

public interface WhyService {
    Object $invoke(String a,String b,String c);
}

過不了服務提供者的 GenericFilter 裏面的這個判斷:

!GenericService.class.isAssignableFrom(invoker.getInterface())

在 invoke 方法裏面,可以看到經過了一個 findMethodByMethodSignature 方法,獲取了我們想要調用的 method 方法:

這個方法,從名字上也可以看出,是根據方法簽名反射出具體的方法:

在服務端,是有 DemoService 接口對應的類的,所以可以通過反射找到它。

然後再解析出入參的具體值:

這樣你就有了構建一個 RpcInvocation 對象,即發起 RPC 調用的對象的所有關鍵消息。

直接就是發動一招“狸貓換太子”的大動作,重新構建一個 RpcInvocation 對象,然後自己發起一個 invoke 調用。

這樣整體看起來似乎一次泛化調用也是很簡單的,當你去看服務提供端的源碼的時候,你會發現這裏面的源碼特別多。

不過是因爲 Dubbo 支持了多種不同的序列化方式而已,本質是一樣的:

onResponse 方法也是同理,就不贅述了:

org.apache.dubbo.rpc.filter.GenericFilter#onResponse

到這裏就算是扯下了泛化調用的神祕面紗,和我們預想的一樣,無非是拿到接口調用的關鍵信息之後,重新構建一個請求而已,整體邏輯並不複雜。

複雜的邏輯是什麼?

我演示的是最簡單的,入參是一個 String 類型的情況。如果我是一個複雜對象呢,對象裏面的成員變量特別多,對象裏面套對象,對象裏面有 List 或者 Map 的情況呢?

複雜的地方在於怎麼處理這些複雜對象,把複雜對象搞成服務提供者的 Java 對象入參。

我這裏只是一個導讀而已,如果你對這部分有興趣的話,自己搞個複雜對象去研究研究吧,老有意思了。

就當是家庭作業了。

意外收穫

歪師傅在扯麪紗的時候,沒想到還有意外收穫。

給你看一段代碼,也是前面出現過的一個方法,我把完整的代碼都截圖放出來:

org.apache.dubbo.common.utils.ReflectUtils#findMethodByMethodSignature

你瞅瞅我框起來部分的 signature 字段,是不是沒有任何卵用?

自信一點,不要懷疑,確實沒有任何用處,signature 只是賦了個值而已,後續的代碼中並沒有使用。

所以,我小腦瓜子一轉,立刻察覺到這又是一個水 pr 的好機會。

於是...

https://github.com/apache/dubbo/pull/13382

晚上 10 點半的時候,直接就是一個貢獻源碼的大動作,小手一揮,帶走四行代碼:

當時我沒細想,但是後來躺在牀上的時候我突然想起來:不應該啊,這個地方爲什麼會留着幾行看起來是沒有刪除不乾淨的代碼呢?

隱隱覺得這裏面應該是有故事的。

於是看了這個類的提交記錄,主要找兩個地方:這個 signature 是什麼時候有的,又是什麼時候沒的。

在 2012 年 6 月 15 日,針對這個類做了一次性能優化:

優化的具體內容就是用 Map 把方法緩存起來,以免每次都需要去走反射的邏輯。

看完這個提交之後我覺得很合理啊,使用 Map 緩存一下確實屬於性能優化。

那麼爲什麼又把這個 Map 拿走了呢?

於是我在 2021 年 9 月 6 日的提交中找到了拿走 Map 對應的提交記錄:

這次提交的內容非常的多,而從提交記錄的 log 中並沒有找到爲什麼要移除這個 Map 的原因:

怎麼辦?

很簡單,社區提問就行了。

於是我在我的 pr 下面拋出了自己的問題:

我查看了該類的提交歷史,發現 #8684 刪除了 ReflectUtils.java 中的所有 Map 緩存,遺留了對 signature 字段的處理。
但是我不明白爲什麼要刪除緩存,在我看來應該保留緩存。能說一下官方是怎麼考慮的嗎?

很快我就得到了官方的回覆:

刪除緩存的原因是因爲這些 Map 緩存是全局變量,這會導致從 Dubbo 的類(通常是 GC root)到對應類的引用,而這些類在 ClassLoader 被閒置後無法釋放。

啥意思呢?

我大概的解釋一下。

首先,我們看一下這個 Map 的定義是怎麼樣的:

private static final ConcurrentMap<String, Method> SIGNATURE_METHODS_CACHE = new ConcurrentHashMap<String, Method>();

它是個 static 對象,那麼它是不是會被作爲一個 GC root?

如果它作爲一個 GC root,它裏面緩存的這些方法,是不是都是“可達的”?

方法是可達的,那麼這些方法對應的 Class 類是不是也是“可達的”?

但是在這些方法對應的 Class 類的 ClassLoader 完成自己的使命,被回收之後,那麼這個 Class 類是不是理論上也可以被回收了?

但是實際情況是什麼呢?

實際情況是因爲這個 static 對象還持有其引用,導致它不會被回收。

基於這個考慮,官方決定移除這個 Map。

其實我個人覺得,如果我上面的理解沒有錯的話,那麼討論這個 Map 的效果,可以得兩個分情況:

如果一個泛化調用的調用頻率非常低,那麼你把對應的方法緩存起來,導致 GC 一直回收不了,確實沒啥意思。

如果一個泛化調用的調用頻率比較高,那麼你把對應的方法緩存起來,確實能起到“性能優化”的效果。

那麼 Dubbo 作爲一個框架怎麼知道你的這個方法調用的頻率高不高呢?

它也不知道,所以乾脆不要替用戶多做這一步,做多了,反而容易出錯。

其實它也是可以知道的,比如可以提供一個參數給用戶進行配置,把選擇權給到用戶,讓用戶通過配置來告訴你。甚至它可以不用用戶提供信息,可以自己來做數據收集,來評判這個方法是否應該被緩存起來。

但是,這玩意收益也不高啊。

本來泛化調用就是比較小衆的東西了,在這上面搞這麼多心思,投入產出比不高啊。

有這時間,還不如想想主鏈路上還有沒有什麼地方可以優化優化,在主鏈路上幹事情,纔是收益最大的事情。

就像是你在公司裏面,在邊緣部門裏面幹得再出色,也很少能讓人注意到。但是如果你在覈心部門裏面,做出一點稍微亮眼的成績,大家都能看到。

所以,你以爲你敲的只是代碼嗎?

不是的,你敲的,是人情世故。

最後,這個 pr 也合併到源碼中去了,再次查看這個類的提交記錄,你會發現一個熟悉的名稱:

說真的,刪除這三行代碼沒有任何技術含量,這部分代碼讓任何一個有 Java 基礎的人來看,都會發現這個問題。

我不過是在調試源碼的過程中撿了個漏而已。

但是爲什麼這部分代碼存在了很久時間了,是我撿到了這個漏呢?

我想,大概是我真的搭了個 Demo 然後一行行的跟了一下源碼吧。

所以,朋友,別隻是看,要動手,說不定有意外收穫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章