如何優雅使用 SPI 機制?

代碼不多,文章可能有點長。朋友面試某廠問到的 SPI 機制,聯想到自己項目最近寫到的 SPI 場景,文章簡要描述下 SPI 機制的發展歷程

產出背景


因爲最近項目中使用分庫分表以及數據加密使用到了 ShardingSphere,所以決定這段時間看看源碼實現。問我爲什麼要讀源碼?不看源碼怎麼提高逼格嘞,就是這麼樸實無華~

考慮到自己看微信文章的習慣,不喜歡代碼太多的,看着邏輯有點不清晰。所以,以後的文章風格就是,少貼代碼,畫圖 + BB

Sharding-Jdbc SPI

看源碼的歷程,往往從點開 Jar 包的瞬間開始。好巧不巧,就看到源代碼包下有個 SPI 包,處於好奇心就點了一點,嗯~ 代碼果然很熟悉,還是那個配方原來的味道

看了許久,陷入深深的沉思。內心小九九:這玩意好像之前看過,但是在哪我忘了,這到底是個啥?

代碼還是那個代碼,只是它認識我,我不認識它了

這一塊的 SPI 接口是 shrding-jdbc 預留自定義加密器的接口

看到這裏相信就遇到過絕大多數技術同學都會遇到的一個問題,那就是 認爲自己會了,實際情況呢?不一定。所以,學習一門技術,一定要多看幾遍,嘗試去理解記憶。千萬不要看一遍之後,眼高手低認爲技術 so easy,然後隔十天半個月就啥都不記的

繼續回過頭來說說今天的主角:SPI。首先回答這麼一個問題,什麼是 SPI 機制

SPI 全稱爲 Service Provider Interface,是一種服務發現機制。爲了被第三方實現或擴展的 API,它可以用於實現框架擴展或組件替換

SPI 機制本質是將 接口實現類的全限定名配置在文件中,並由服務加載器讀取配置文件,加載文件中的實現類,這樣運行時可以動態的爲接口替換實現類

看文字描述介紹總是枯燥無味且空洞的。簡單一點來說,就是你在 META-INF/services 下面定義個文件,然後通過一個特殊的類加載器,啓動的時候加載你定義文件中的類,這樣就能擴展原有框架的功能

就這麼簡單,那可能有讀者會問:我不定義在 META-INF/services 下面行不行?就想定義在別的地方

不行滴,請遏制住這麼危險的想法,人家怎麼定義你就怎麼實現。這是 JDK 規定好的配置路徑,你隨便定義,類加載器怎麼知道去哪裏加載

看到這個 PREFIX 常量之後,想法比較活躍的小夥子不知道清醒點了麼。簡單畫張圖來描述下 SPI 的運行機制

有點 SPI 基礎的同學看到圖之後應該又開始自信了,這不就是我之前看過的那玩意麼?是的,技術還是那個技術,可以繼續往下看看,有沒有自己不知道的

爲什麼要有 SPI

瞭解一項技術的前提,一定要知道它爲了解決什麼樣的痛點而存在,JDK 作者也不會沒屁事加點代碼玩

引入了 SPI 機制後,服務接口與服務實現就會達成分離的狀態,可以實現 解耦以及程序可擴展機制。服務提供者(比如 springboot starter)提供出 SPI 接口後,客戶端(平常的 springboot 項目)就可以通過本地註冊的形式,將實現類註冊到服務端,輕鬆實現可插拔

數據加密舉例

以實際項目舉個例子,就拿 sharding-jdbc 數據加密模塊來說,sharding-jdbc 本身支持 AES 和 MD5 兩種加密方式。但是,如果客戶端不想用內置的兩種加密,偏偏想用 RSA 算法呢?難道每加一種算法,sharding-jdbc 就要發個版本麼

sharding-jdbc 可不會這麼幹,首先提供出 Encryptor 加密接口,並引入 SPI 的機制,做到服務接口與服務實現分離的效果。如果客戶端想要使用新的加密算法,只需要在客戶端項目 META-INF/services 目錄下定義接口的全限定名稱文件,並在文件內寫上加密實現類的全限定名,就像這樣式的

通過 SPI 的方式,就可以將客戶端提供的加密算法加載到 sharding-jdbc 加密規則中,這樣就可以在項目運行中選擇自定義算法來對數據進行加密存儲

通過 sharding-jdbc 的例子,可以很好的看出來,上面提到的 SPI 優點,都體現了出來

  1. 客戶端(自己的項目)提供了服務端(sharding-jdbc)的接口自定義實現,但是與服務端狀態分離,只有在客戶端提供了自定義接口實現時纔會加載,其它並沒有關聯;客戶端的新增或刪除實現類不會影響服務端

  2. 如果客戶端不想要 RSA 算法,又想要使用內置的 AES 算法,那麼可以隨時刪掉實現類,可擴展性強,插件化架構

配合實際案例理解 SPI 是不是很簡單。爲了防止有些小夥伴沒有理解 sharding-jdbc 的例子,這裏再舉一個真實的例子

對象存儲舉例

假如你是一家集團公司裏做公共架構開發的(可以把這個集團想大一點,幾百家子公司的那種 🙃️ ),領導給你安排了個開發任務,需要你開發一個對象存儲服務,讓其它業務線的團隊使用,統一集團內部的對象存儲

OK,開發訴求明白了,這個時候就該想想怎麼去完成這個需求(主要想給領導留個好印象,升官發財 ing...)。首先應該考慮的是要兼容多套對象存儲供應商,比如阿里 OSS、騰訊 COS、華爲雲 OBS,最基本的三連對吧

高高興興的封裝了個 starter,告訴領導封裝完成了,然後就下發到各項目組去用了。但是這個時候其中一個子公司負責人告訴你,說他們之前用的七牛雲 Kodo

心態炸了呀,難道要給他再適配一個七牛雲麼?萬一適配完這個,又一位大哥說項目自建 HDFS 咋整

聊到這,大家就明白了吧,SPI 的場景可不就出現了麼。就是身爲服務提供者,在你無法形成絕對規範強制的時候,"放權" 往往是比較明智的選擇,適當讓客戶端去自定義實現

這個時候,回過頭想一想最初的一個問題。爲什麼 sharding-jdbc 不多實現幾套算法,而是提供出一個 SPI 接口呢

因爲開發者明白,不論提供多少接口,總有個別用戶因各方面因素導致的個性化需求。個性化這個事情是追摸不透的,就像 女生的心思一樣,永遠不知道在想什麼...(重點都加黑加粗了,剩下的全靠自己領悟)

實戰講解

都說到這了,不來個實戰,感覺有點說不過去。吹過的牛逼,負責到底!就實現上面說的統一對象存儲服務的代碼

最簡單的對象存儲,只需要兩個接口就可以實現功能,分別是 上傳和下載

定義好上傳、下載接口後,我們就要考慮,如何讓客戶端項目可以選擇底層的對象存儲服務器,以及如何通過 SPI 的方式將客戶端自定義的文件存儲組件加載到服務端

我們可以定義個對象存儲容器,存放可以使用的對象存儲服務,然後再 使用 SPI 的機制加載客戶端自定義組件放到容器。對象存儲服務放到容器中自然需要一個標識,那麼就需要給文件接口加一個獲取類型接口

定義好了接口,就要寫具體的代碼了。我們爲 對象存儲服務提供出一個對外的門面,所有訪問對象存儲的服務,必須訪問門面對象進行文件的上傳下載操作

下面這段代碼將 對象服務 bean 存儲至容器,並提供根據客戶端的自定義配置,選擇合適的對象存儲服務

代碼裏用到的關鍵字 var 是 lombok 的註解,可以自動識別對象類型

因爲是個示例 demo,所以將獲取對象存儲和具體的上傳、下載耦合在了一起,如果小夥伴有類似需求,一定要將不同行爲拆分開,類職責儘量單一些

這段代碼整體邏輯不算複雜,所以也有點自信回頭,就沒跑單元測試,不過問題應該不大。解釋一下其中具體邏輯:

  1. FileServiceFactory 大家可以理解爲文件服務對外的統一訪問入口。實現了 spirng 初始化的一個接口,可以在 bean 初始化時進行代碼邏輯操作

  2. bean 初始化時,通過 ServiceLoader 類加載器負責加載對象存儲接口,這樣就能加載到客戶端存放到 META-INF/services 中的自定義對象存儲實現

  3. 獲取到自定義對象存儲後,和服務端本身自帶的對象存儲一起存放至容器中,這樣就可以根據項目中的 fileStoreType 獲取對應的服務了

結合實際的項目場景,一個簡簡單單的 SPI 應用就完成了,自我感覺比 JDBC 裝配的例子更好理解一些

上面的業務只是爲了讓不理解 SPI 的小夥伴更好的掌握應用場景,其實對象存儲服務是一種可窮舉的業務場景,SPI 並不是唯一的解決思路。當然,爲了省事使用 SPI 也沒啥問題。最後提一句,SPI 最合適的還是沒有統一業務實現場景,就像上面提到過的加密算法

深入解析 SPI

一篇技術解析文章,適當放一些源碼解析感覺會更好一些。下面一起來看看 ServiceLoader 底層都做了什麼事情

通過 ServiceLoader 的 load 方法創建一個新的 ServiceLoader,並實例化其中的成員變量

應用程序通過迭代器接口獲取對象實例,這裏首先會判斷 providers 對象中是否有實例對象

如果有實例,那麼就返回;如果沒有,執行類的裝載步驟,具體類裝載實現如下:

  1. LazyIterator#hasNextService 讀取 META-INF/services 下的配置文件,獲得所有能被實例化的類的名稱,並完成 SPI 配置文件的解析

  2. LazyIterator#nextService 負責實例化 hasNextService() 讀到的實現類,並將實例化後的對象存放到 providers 集合中緩存

如果你不知道上面的一些 "黑話" 不要緊,因爲都是 ServiceLoader 底層執行的方法,跟着下面這個程序敲一遍代碼就懂了

這裏爲了跟源碼,也是把上面對象存儲的邏輯,簡單寫了個 SPI 示例,證明是沒有問題的。如果小夥伴想真正瞭解,就需要跟下源碼去看看,其它源碼部分就不細說了

結言

上面說了很多關於 SPI 機制的優點以及應用場景,這裏總結下關鍵內容

  1. SPI 機制優勢就是解耦。將接口的定義以及具體業務實現分離,而不是和業務端全部耦合在一端。可以實現 運行時根據業務實際場景啓用或者替換具體組件

  2. SPI 機制的場景就是 沒有統一實現標準的業務場景。一般就是,服務端有標準的接口,但是沒有統一的實現,需要業務方提供其具體實現。比如說 JDBC 的 java.sql.Driver 接口和不同雲廠商提供的數據庫實現包

每個事物都是既有優點,同時也伴隨着缺點。要從兩個方面去看,不能總盯着一方面。這裏說一下 SPI 機制的缺點

  1. 不能按需加載。雖然 ServiceLoader 做了延遲加載,但是隻能通過遍歷的方式全部獲取。如果其中某些實現類很耗時,而且你也不需要加載它,那麼就形成了資源浪費

  2. 獲取某個實現類的方式不夠靈活,只能通過迭代器的形式獲取。這兩點可以參考 Dubbo SPI 實現方式進行業務優化

文章通過圖文並茂的方式幫助大家重新梳理了一遍 SPI 的場景、優勢和缺點,看完文章後相信大家對 SPI 機制有了更深入的認識

梳理出 SPI 的場景以及優勢後,小夥伴最好再去 Debug 源代碼,這樣會大家對 SPI 的實現才能更加清楚。只有對一個知識點真正掌握,纔不至於事後很快遺忘

另外可以通過項目中的場景,比如文中提到的加密、對象存儲,通過類比的方式結合項目邏輯去實現代碼代入,這樣能夠更好的去學習以及擴展相關的設計思路

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