原文:某廠面試:如何優(yōu)雅使用 SPI 機(jī)制
作者:龍臺(tái)
代碼不多,文章可能有點(diǎn)長(zhǎng)。朋友面試某廠問到的 SPI 機(jī)制,聯(lián)想到自己項(xiàng)目最近寫到的 SPI 場(chǎng)景,文章簡(jiǎn)要描述下 SPI 機(jī)制的發(fā)展歷程
產(chǎn)出背景
因?yàn)樽罱?xiàng)目中使用分庫(kù)分表以及數(shù)據(jù)加密使用到了 ShardingSphere,所以決定這段時(shí)間看看源碼實(shí)現(xiàn)。問我為什么要讀源碼?不看源碼怎么提高逼格嘞,就是這么樸實(shí)無(wú)華~
考慮到自己看微信文章的習(xí)慣,不喜歡代碼太多的,看著邏輯有點(diǎn)不清晰。所以,以后的文章風(fēng)格就是,少貼代碼,畫圖 + BB
Sharding-Jdbc SPI
看源碼的歷程,往往從點(diǎn)開 Jar 包的瞬間開始。好巧不巧,就看到源代碼包下有個(gè) SPI 包,處于好奇心就點(diǎn)了一點(diǎn),嗯~ 代碼果然很熟悉,還是那個(gè)配方原來(lái)的味道

看了許久,陷入深深的沉思。內(nèi)心小九九:這玩意好像之前看過,但是在哪我忘了,這到底是個(gè)啥?
代碼還是那個(gè)代碼,只是它認(rèn)識(shí)我,我不認(rèn)識(shí)它了
這一塊的 SPI 接口是 shrding-jdbc 預(yù)留自定義加密器的接口
看到這里相信就遇到過絕大多數(shù)技術(shù)同學(xué)都會(huì)遇到的一個(gè)問題,那就是 認(rèn)為自己會(huì)了,實(shí)際情況呢?不一定。所以,學(xué)習(xí)一門技術(shù),一定要多看幾遍,嘗試去理解記憶。千萬(wàn)不要看一遍之后,眼高手低認(rèn)為技術(shù) so easy,然后隔十天半個(gè)月就啥都不記的
繼續(xù)回過頭來(lái)說說今天的主角:SPI。首先回答這么一個(gè)問題,什么是 SPI 機(jī)制
SPI 全稱為 Service Provider Interface,是一種服務(wù)發(fā)現(xiàn)機(jī)制。為了被第三方實(shí)現(xiàn)或擴(kuò)展的 API,它可以用于實(shí)現(xiàn)框架擴(kuò)展或組件替換
SPI 機(jī)制本質(zhì)是將 接口實(shí)現(xiàn)類的全限定名配置在文件中,并由服務(wù)加載器讀取配置文件,加載文件中的實(shí)現(xiàn)類,這樣運(yùn)行時(shí)可以動(dòng)態(tài)的為接口替換實(shí)現(xiàn)類
看文字描述介紹總是枯燥無(wú)味且空洞的。簡(jiǎn)單一點(diǎn)來(lái)說,就是你在 META-INF/services 下面定義個(gè)文件,然后通過一個(gè)特殊的類加載器,啟動(dòng)的時(shí)候加載你定義文件中的類,這樣就能擴(kuò)展原有框架的功能
就這么簡(jiǎn)單,那可能有讀者會(huì)問:我不定義在 META-INF/services 下面行不行?就想定義在別的地方
不行滴,請(qǐng)遏制住這么危險(xiǎn)的想法,人家怎么定義你就怎么實(shí)現(xiàn)。這是 JDK 規(guī)定好的配置路徑,你隨便定義,類加載器怎么知道去哪里加載

看到這個(gè) PREFIX 常量之后,想法比較活躍的小伙子不知道清醒點(diǎn)了么。簡(jiǎn)單畫張圖來(lái)描述下 SPI 的運(yùn)行機(jī)制

有點(diǎn) SPI 基礎(chǔ)的同學(xué)看到圖之后應(yīng)該又開始自信了,這不就是我之前看過的那玩意么?是的,技術(shù)還是那個(gè)技術(shù),可以繼續(xù)往下看看,有沒有自己不知道的
為什么要有 SPI
了解一項(xiàng)技術(shù)的前提,一定要知道它為了解決什么樣的痛點(diǎn)而存在,JDK 作者也不會(huì)沒屁事加點(diǎn)代碼玩
引入了 SPI 機(jī)制后,服務(wù)接口與服務(wù)實(shí)現(xiàn)就會(huì)達(dá)成分離的狀態(tài),可以實(shí)現(xiàn) 解耦以及程序可擴(kuò)展機(jī)制。服務(wù)提供者(比如 springboot starter)提供出 SPI 接口后,客戶端(平常的 springboot 項(xiàng)目)就可以通過本地注冊(cè)的形式,將實(shí)現(xiàn)類注冊(cè)到服務(wù)端,輕松實(shí)現(xiàn)可插拔
數(shù)據(jù)加密舉例
以實(shí)際項(xiàng)目舉個(gè)例子,就拿 sharding-jdbc 數(shù)據(jù)加密模塊來(lái)說,sharding-jdbc 本身支持 AES 和 MD5 兩種加密方式。但是,如果客戶端不想用內(nèi)置的兩種加密,偏偏想用 RSA 算法呢?難道每加一種算法,sharding-jdbc 就要發(fā)個(gè)版本么
sharding-jdbc 可不會(huì)這么干,首先提供出 Encryptor 加密接口,并引入 SPI 的機(jī)制,做到服務(wù)接口與服務(wù)實(shí)現(xiàn)分離的效果。如果客戶端想要使用新的加密算法,只需要在客戶端項(xiàng)目 META-INF/services 目錄下定義接口的全限定名稱文件,并在文件內(nèi)寫上加密實(shí)現(xiàn)類的全限定名,就像這樣式的

通過 SPI 的方式,就可以將客戶端提供的加密算法加載到 sharding-jdbc 加密規(guī)則中,這樣就可以在項(xiàng)目運(yùn)行中選擇自定義算法來(lái)對(duì)數(shù)據(jù)進(jìn)行加密存儲(chǔ)
通過 sharding-jdbc 的例子,可以很好的看出來(lái),上面提到的 SPI 優(yōu)點(diǎn),都體現(xiàn)了出來(lái)
- 客戶端(自己的項(xiàng)目)提供了服務(wù)端(sharding-jdbc)的接口自定義實(shí)現(xiàn),但是與服務(wù)端狀態(tài)分離,只有在客戶端提供了自定義接口實(shí)現(xiàn)時(shí)才會(huì)加載,其它并沒有關(guān)聯(lián);客戶端的新增或刪除實(shí)現(xiàn)類不會(huì)影響服務(wù)端
- 如果客戶端不想要 RSA 算法,又想要使用內(nèi)置的 AES 算法,那么可以隨時(shí)刪掉實(shí)現(xiàn)類,可擴(kuò)展性強(qiáng),插件化架構(gòu)
配合實(shí)際案例理解 SPI 是不是很簡(jiǎn)單。為了防止有些小伙伴沒有理解 sharding-jdbc 的例子,這里再舉一個(gè)真實(shí)的例子
對(duì)象存儲(chǔ)舉例
假如你是一家集團(tuán)公司里做公共架構(gòu)開發(fā)的(可以把這個(gè)集團(tuán)想大一點(diǎn),幾百家子公司的那種 ? ),領(lǐng)導(dǎo)給你安排了個(gè)開發(fā)任務(wù),需要你開發(fā)一個(gè)對(duì)象存儲(chǔ)服務(wù),讓其它業(yè)務(wù)線的團(tuán)隊(duì)使用,統(tǒng)一集團(tuán)內(nèi)部的對(duì)象存儲(chǔ)
OK,開發(fā)訴求明白了,這個(gè)時(shí)候就該想想怎么去完成這個(gè)需求(主要想給領(lǐng)導(dǎo)留個(gè)好印象,升官發(fā)財(cái) ing...)。首先應(yīng)該考慮的是要兼容多套對(duì)象存儲(chǔ)供應(yīng)商,比如阿里 OSS、騰訊 COS、華為云 OBS,最基本的三連對(duì)吧
高高興興的封裝了個(gè) starter,告訴領(lǐng)導(dǎo)封裝完成了,然后就下發(fā)到各項(xiàng)目組去用了。但是這個(gè)時(shí)候其中一個(gè)子公司負(fù)責(zé)人告訴你,說他們之前用的七牛云 Kodo
心態(tài)炸了呀,難道要給他再適配一個(gè)七牛云么?萬(wàn)一適配完這個(gè),又一位大哥說項(xiàng)目自建 HDFS 咋整
聊到這,大家就明白了吧,SPI 的場(chǎng)景可不就出現(xiàn)了么。就是身為服務(wù)提供者,在你無(wú)法形成絕對(duì)規(guī)范強(qiáng)制的時(shí)候,"放權(quán)" 往往是比較明智的選擇,適當(dāng)讓客戶端去自定義實(shí)現(xiàn)
這個(gè)時(shí)候,回過頭想一想最初的一個(gè)問題。為什么 sharding-jdbc 不多實(shí)現(xiàn)幾套算法,而是提供出一個(gè) SPI 接口呢
因?yàn)殚_發(fā)者明白,不論提供多少接口,總有個(gè)別用戶因各方面因素導(dǎo)致的個(gè)性化需求。個(gè)性化這個(gè)事情是追摸不透的,就像 女生的心思一樣,永遠(yuǎn)不知道在想什么...(重點(diǎn)都加黑加粗了,剩下的全靠自己領(lǐng)悟)
實(shí)戰(zhàn)講解
都說到這了,不來(lái)個(gè)實(shí)戰(zhàn),感覺有點(diǎn)說不過去。吹過的牛逼,負(fù)責(zé)到底!就實(shí)現(xiàn)上面說的統(tǒng)一對(duì)象存儲(chǔ)服務(wù)的代碼
最簡(jiǎn)單的對(duì)象存儲(chǔ),只需要兩個(gè)接口就可以實(shí)現(xiàn)功能,分別是 上傳和下載

定義好上傳、下載接口后,我們就要考慮,如何讓客戶端項(xiàng)目可以選擇底層的對(duì)象存儲(chǔ)服務(wù)器,以及如何通過 SPI 的方式將客戶端自定義的文件存儲(chǔ)組件加載到服務(wù)端
我們可以定義個(gè)對(duì)象存儲(chǔ)容器,存放可以使用的對(duì)象存儲(chǔ)服務(wù),然后再 使用 SPI 的機(jī)制加載客戶端自定義組件放到容器。對(duì)象存儲(chǔ)服務(wù)放到容器中自然需要一個(gè)標(biāo)識(shí),那么就需要給文件接口加一個(gè)獲取類型接口

定義好了接口,就要寫具體的代碼了。我們?yōu)?nbsp;對(duì)象存儲(chǔ)服務(wù)提供出一個(gè)對(duì)外的門面,所有訪問對(duì)象存儲(chǔ)的服務(wù),必須訪問門面對(duì)象進(jìn)行文件的上傳下載操作
下面這段代碼將 對(duì)象服務(wù) bean 存儲(chǔ)至容器,并提供根據(jù)客戶端的自定義配置,選擇合適的對(duì)象存儲(chǔ)服務(wù)
代碼里用到的關(guān)鍵字 var 是 lombok 的注解,可以自動(dòng)識(shí)別對(duì)象類型

因?yàn)槭莻€(gè)示例 demo,所以將獲取對(duì)象存儲(chǔ)和具體的上傳、下載耦合在了一起,如果小伙伴有類似需求,一定要將不同行為拆分開,類職責(zé)盡量單一些
這段代碼整體邏輯不算復(fù)雜,所以也有點(diǎn)自信回頭,就沒跑單元測(cè)試,不過問題應(yīng)該不大。解釋一下其中具體邏輯:
- FileServiceFactory 大家可以理解為文件服務(wù)對(duì)外的統(tǒng)一訪問入口。實(shí)現(xiàn)了 spirng 初始化的一個(gè)接口,可以在 bean 初始化時(shí)進(jìn)行代碼邏輯操作
- bean 初始化時(shí),通過 ServiceLoader 類加載器負(fù)責(zé)加載對(duì)象存儲(chǔ)接口,這樣就能加載到客戶端存放到 META-INF/services 中的自定義對(duì)象存儲(chǔ)實(shí)現(xiàn)
- 獲取到自定義對(duì)象存儲(chǔ)后,和服務(wù)端本身自帶的對(duì)象存儲(chǔ)一起存放至容器中,這樣就可以根據(jù)項(xiàng)目中的 fileStoreType 獲取對(duì)應(yīng)的服務(wù)了
結(jié)合實(shí)際的項(xiàng)目場(chǎng)景,一個(gè)簡(jiǎn)簡(jiǎn)單單的 SPI 應(yīng)用就完成了,自我感覺比 JDBC 裝配的例子更好理解一些
上面的業(yè)務(wù)只是為了讓不理解 SPI 的小伙伴更好的掌握應(yīng)用場(chǎng)景,其實(shí)對(duì)象存儲(chǔ)服務(wù)是一種可窮舉的業(yè)務(wù)場(chǎng)景,SPI 并不是唯一的解決思路。當(dāng)然,為了省事使用 SPI 也沒啥問題。最后提一句,SPI 最合適的還是沒有統(tǒng)一業(yè)務(wù)實(shí)現(xiàn)場(chǎng)景,就像上面提到過的加密算法
深入解析 SPI
一篇技術(shù)解析文章,適當(dāng)放一些源碼解析感覺會(huì)更好一些。下面一起來(lái)看看 ServiceLoader底層都做了什么事情
通過 ServiceLoader 的 load 方法創(chuàng)建一個(gè)新的 ServiceLoader,并實(shí)例化其中的成員變量

應(yīng)用程序通過迭代器接口獲取對(duì)象實(shí)例,這里首先會(huì)判斷 providers 對(duì)象中是否有實(shí)例對(duì)象
如果有實(shí)例,那么就返回;如果沒有,執(zhí)行類的裝載步驟,具體類裝載實(shí)現(xiàn)如下:
- LazyIterator#hasNextService 讀取 META-INF/services 下的配置文件,獲得所有能被實(shí)例化的類的名稱,并完成 SPI 配置文件的解析
- LazyIterator#nextService 負(fù)責(zé)實(shí)例化 hasNextService() 讀到的實(shí)現(xiàn)類,并將實(shí)例化后的對(duì)象存放到 providers 集合中緩存

如果你不知道上面的一些 "黑話" 不要緊,因?yàn)槎际?nbsp;ServiceLoader 底層執(zhí)行的方法,跟著下面這個(gè)程序敲一遍代碼就懂了

這里為了跟源碼,也是把上面對(duì)象存儲(chǔ)的邏輯,簡(jiǎn)單寫了個(gè) SPI 示例,證明是沒有問題的。如果小伙伴想真正了解,就需要跟下源碼去看看,其它源碼部分就不細(xì)說了
結(jié)言
上面說了很多關(guān)于 SPI 機(jī)制的優(yōu)點(diǎn)以及應(yīng)用場(chǎng)景,這里總結(jié)下關(guān)鍵內(nèi)容
- SPI 機(jī)制優(yōu)勢(shì)就是解耦。將接口的定義以及具體業(yè)務(wù)實(shí)現(xiàn)分離,而不是和業(yè)務(wù)端全部耦合在一端。可以實(shí)現(xiàn) 運(yùn)行時(shí)根據(jù)業(yè)務(wù)實(shí)際場(chǎng)景啟用或者替換具體組件
- SPI 機(jī)制的場(chǎng)景就是 沒有統(tǒng)一實(shí)現(xiàn)標(biāo)準(zhǔn)的業(yè)務(wù)場(chǎng)景。一般就是,服務(wù)端有標(biāo)準(zhǔn)的接口,但是沒有統(tǒng)一的實(shí)現(xiàn),需要業(yè)務(wù)方提供其具體實(shí)現(xiàn)。比如說 JDBC 的 JAVA.sql.Driver 接口和不同云廠商提供的數(shù)據(jù)庫(kù)實(shí)現(xiàn)包
每個(gè)事物都是既有優(yōu)點(diǎn),同時(shí)也伴隨著缺點(diǎn)。要從兩個(gè)方面去看,不能總盯著一方面。這里說一下 SPI 機(jī)制的缺點(diǎn)
- 不能按需加載。雖然 ServiceLoader 做了延遲加載,但是只能通過遍歷的方式全部獲取。如果其中某些實(shí)現(xiàn)類很耗時(shí),而且你也不需要加載它,那么就形成了資源浪費(fèi)
- 獲取某個(gè)實(shí)現(xiàn)類的方式不夠靈活,只能通過迭代器的形式獲取。這兩點(diǎn)可以參考 Dubbo SPI 實(shí)現(xiàn)方式進(jìn)行業(yè)務(wù)優(yōu)化
文章通過圖文并茂的方式幫助大家重新梳理了一遍 SPI 的場(chǎng)景、優(yōu)勢(shì)和缺點(diǎn),看完文章后相信大家對(duì) SPI 機(jī)制有了更深入的認(rèn)識(shí)
梳理出 SPI 的場(chǎng)景以及優(yōu)勢(shì)后,小伙伴最好再去 Debug 源代碼,這樣會(huì)大家對(duì) SPI 的實(shí)現(xiàn)才能更加清楚。只有對(duì)一個(gè)知識(shí)點(diǎn)真正掌握,才不至于事后很快遺忘
另外可以通過項(xiàng)目中的場(chǎng)景,比如文中提到的加密、對(duì)象存儲(chǔ),通過類比的方式結(jié)合項(xiàng)目邏輯去實(shí)現(xiàn)代碼代入,這樣能夠更好的去學(xué)習(xí)以及擴(kuò)展相關(guān)的設(shè)計(jì)思路
創(chuàng)作不易,文章看到這里如果有所幫助,可以點(diǎn)個(gè)關(guān)注支持一下,祝好。我們下期見!