EXP 一款 Java 插件化熱插拔框架
閱讀本文大概需要 5.5 分鐘。
來(lái)自:juejin.cn/post/7267091417029148733
1 前言
多年以來(lái),ToB 的應(yīng)用程序都面臨定制化需求應(yīng)該怎么搞的問(wèn)題。
舉例,大部分本地化軟件廠家,都有一個(gè)標(biāo)準(zhǔn)程序,這個(gè)程序支持大部分企業(yè)的功能需求,但面對(duì)世界 500 強(qiáng)等大客戶(hù)時(shí),他們的特殊需求,廠家通常是無(wú)法拒絕的(通常因?yàn)橛唵未?,給的多,可背書(shū))。比如使用非標(biāo)準(zhǔn)數(shù)據(jù)庫(kù),業(yè)務(wù)流程里加入一些安全檢查等,回調(diào)里加入一些定制字段等;
由此而來(lái)的需求,一般有幾種解決方案;
-
將這個(gè)需求做進(jìn)標(biāo)準(zhǔn)產(chǎn)品里。讓這個(gè)功能有個(gè)配置開(kāi)關(guān),也可以被其他的客戶(hù)使用,通常這類(lèi)需求可能比較“通用” -
由于客戶(hù)工期時(shí)間緊,雖然功能“通用”,但奈何時(shí)間不足,無(wú)法排進(jìn)標(biāo)準(zhǔn)產(chǎn)品里,只能使用 git 拉一個(gè)標(biāo)準(zhǔn)的客戶(hù)分支來(lái)進(jìn)行開(kāi)發(fā),后期可能將其 merge 到標(biāo)準(zhǔn)產(chǎn)品里; -
這個(gè)功能太不常見(jiàn)了,無(wú)法放到到標(biāo)準(zhǔn)產(chǎn)品里,那就直接拉新的 git 分支開(kāi)發(fā);
分支開(kāi)發(fā),他的好處是效率非??欤枥锱纠惨活D改,定制需求就完成了。但是,這種方式會(huì)帶來(lái)一個(gè)致命的問(wèn)題:后期程序升級(jí)成本非常巨大。
本地化程序和 saas 服務(wù)不同,本地化的程序通常是需要手動(dòng)升級(jí)的,使用分支開(kāi)發(fā),升級(jí)的方式無(wú)非就是 merge 分支,解決沖突。
如果改動(dòng)很小,merge 倒也問(wèn)題不大。但是如果改動(dòng)很大,merge 的方式,會(huì)帶來(lái)很大的問(wèn)題。因?yàn)槿绻e例定制開(kāi)發(fā)的時(shí)間久了,當(dāng)時(shí)拉分支改的代碼和后面的標(biāo)準(zhǔn)產(chǎn)品迭代代碼早就不兼容了,此時(shí),merge 升級(jí)就非常困難。
由此,我們思考,到底什么樣的方式才能解決這種場(chǎng)景;
目前來(lái)看,使用插件機(jī)制擴(kuò)展客戶(hù)需求,是其中一種方式。其本質(zhì)用簡(jiǎn)單一句話(huà)概括:主程序預(yù)留擴(kuò)展接口,定制客戶(hù)實(shí)現(xiàn)接口邏輯。
你可以將其理解為一種更復(fù)雜的策略模式或者 SPI;只是,插件通常是 classloader 類(lèi)隔離的。
大概如下圖:

本文我們假設(shè)插件系統(tǒng)是當(dāng)前解決定制需求痛點(diǎn)的方案之一,那我們今天就來(lái)設(shè)計(jì)一下這個(gè)插件系統(tǒng)。
2 設(shè)計(jì)
首先分析需求,插件系統(tǒng)需要哪些功能:
-
通常主程序定義接口,插件實(shí)現(xiàn)邏輯;即這個(gè)功能在主程序里是空的。在客戶(hù)側(cè)是安裝的。那么,我們可能需要一個(gè)可插拔的功能,即需要的時(shí)候,我們安裝,不需要的時(shí)候,不安裝或者卸載。 -
需要熱插拔嗎?我想不是必須的,但如果每次都需要重啟才能調(diào)整插件,用戶(hù)體驗(yàn)會(huì)很不好。那我們就加上熱插拔吧。 -
插件里可以寫(xiě) servlet or spring rest api 嗎?我想是需要的。插件里可以對(duì)外新增接口,為定制客戶(hù)提供新的服務(wù)能力。對(duì)了,插件還得支持事務(wù),Mybatis ,AOP,RPC 等。 -
一個(gè)擴(kuò)展點(diǎn)可以有多個(gè)實(shí)現(xiàn),那這個(gè)擴(kuò)展點(diǎn)可以同時(shí)存在多個(gè)插件嗎?我想是需要的,比方說(shuō)對(duì)接短信服務(wù)商,a 客戶(hù)走 s1 廠商,b 客戶(hù)走 s2 廠商。另外,一個(gè)客戶(hù)可能對(duì)同一個(gè)擴(kuò)展點(diǎn)有多個(gè)實(shí)現(xiàn),此時(shí)可能需要更復(fù)雜的路由策略,那么,這個(gè)時(shí)候,我們可以提供一種機(jī)制,支持這種策略。 -
插件里的配置怎么辦?插件配置通常是可以熱更新的,且通常是在一個(gè)單獨(dú)的插件系統(tǒng)里配置的。此時(shí),我們需要提供一個(gè)區(qū)分于 spring application.yml 的配置策略,即幾個(gè)基于插件維度的配置 API。 -
出于安全考慮,插件包類(lèi)型不僅僅支持 jar 包,還需要支持 zip 包。 -
插件的技術(shù)問(wèn)題,要支持類(lèi)隔離,否則,如果插件開(kāi)發(fā)者引入了一個(gè)有問(wèn)題的 lib 或版本不兼容的 lib,將會(huì)導(dǎo)致災(zāi)難。另外,無(wú)法保證各個(gè)插件之間的包名完全不同。
需要 7788 差不多了,我們來(lái)設(shè)計(jì)一下編程界面。
-
入口 API
public interface ExpAppContext {
/**
* 加載插件
*/
Plugin load(File file) throws Throwable;
/**
* 卸載插件
*/
void unload(String id) throws Exception;
/**
* 獲取多個(gè)擴(kuò)展點(diǎn)的插件實(shí)例
*/
<P> List<P> get(String extCode);
/**
* 簡(jiǎn)化操作, code 就是全路徑類(lèi)名
*/
<P> List<P> get(Class<P> pClass);
/**
* 獲取單個(gè)插件實(shí)例.
*/
<P> P get(String extCode, String pluginId);
}
ExpAppContext 接口,作為核心模型,提供以下能力
-
安裝一個(gè) file 插件,并在 jvm 里生效,返回插件信息,每個(gè)插件都有一個(gè) id -
可以根據(jù) id 從 jvm spring 里卸載插件。 -
可以根據(jù)擴(kuò)展點(diǎn) code 獲取多個(gè)實(shí)現(xiàn),這個(gè)返回的實(shí)現(xiàn)是一個(gè)集合 -
可以根據(jù)擴(kuò)展點(diǎn) code + 插件 id 指定獲取多單個(gè)實(shí)現(xiàn),這個(gè)返回的實(shí)現(xiàn)是一個(gè)對(duì)象。
這幾個(gè) API 可以實(shí)現(xiàn)插件的基本功能。
我們?cè)偬砑雨P(guān)于租戶(hù)的 API
public interface TenantService {
/**
* 獲取 TenantCallback 擴(kuò)展邏輯;
*/
default TenantCallback getTenantCallback() {
return TenantCallback.TenantCallbackMock.instance;
}
/**
* 設(shè)置 callback;
*/
default void setTenantCallback(TenantCallback callback) {
}
}
public interface TenantCallback {
/**
* 返回這個(gè)插件的序號(hào), 默認(rèn) 0;
* {@link cn.think.in.java.open.exp.client.ExpAppContext#get(java.lang.Class)} 函數(shù)返回的List 的第一位就是 sort 最高的.
*/
Integer getSort(String pluginId);
/**
* 這個(gè)插件是否屬于當(dāng)前租戶(hù), 默認(rèn)是;
* 這個(gè)返回值, 會(huì)影響 {@link cn.think.in.java.open.exp.client.ExpAppContext#get(java.lang.Class)} 的結(jié)果
* 即進(jìn)行過(guò)濾, 返回為 true 的 plugin 實(shí)現(xiàn), 才會(huì)被返回.
*/
Boolean isOwnCurrentTenant(String pluginId);
}
在調(diào)用 ExpAppContext#get 時(shí),需要過(guò)濾租戶(hù)實(shí)現(xiàn),還需要對(duì)單個(gè)租戶(hù)的多個(gè)實(shí)現(xiàn)進(jìn)行排序。用戶(hù)可以實(shí)現(xiàn)自己的 getSort(pluginId) 和 isOwnCurrentTenant(pluginId) 邏輯。
API 有了,我們的編程界面就出來(lái)了,他應(yīng)該是這樣的:
public static void main(String[] args) throws Throwable {
Class<UserService> extensionClass = UserService.class;
ExpAppContext expAppContext = Bootstrap.bootstrap("exp-plugins/", "workdir-simple-java-app");
expAppContext.setTenantCallback(new TenantCallback() {
@Override
public Integer getSort(String pluginId) {
return new Random().nextInt(10);
}
@Override
public Boolean isOwnCurrentTenant(String pluginId) {
return true;
}
});
Optional<UserService> first = expAppContext.get(extensionClass).stream().findFirst();
first.ifPresent(userService -> {
System.out.println(userService.getClass());
System.out.println(userService.getClass().getClassLoader());
userService.createUserExt();
});
}
-
我們的擴(kuò)展點(diǎn)介紹名是UserService,方法名是 createUserExt -
我們使用 Bootstrap 配置工作目錄和插件目錄,并啟動(dòng),啟動(dòng)過(guò)程中包含調(diào)用 load 方法,然后返回一個(gè)核心領(lǐng)域?qū)ο蟆? -
可以使用 Context 配置租戶(hù)策略; -
最后我們使用 expAppContext.get().findFirst() 方法,返回一個(gè)這個(gè)擴(kuò)展點(diǎn)優(yōu)先級(jí)最高的實(shí)現(xiàn)。
讀取插件配置 API:
public interface PluginConfig {
String getProperty(String pluginId, String key, String defaultValue);
}
注意這個(gè) API 和正常的 config api 不同,他新增了 pluginId 維度,使插件配置之間是互相隔離的。具體的 PluginConfig 還可以根據(jù)租戶(hù)再進(jìn)行配置隔離。
表面的 API 已經(jīng)差不多了,內(nèi)部的實(shí)現(xiàn),需要開(kāi)始了,比如
-
類(lèi)加載機(jī)制,包含 zip jar 的類(lèi)隔離加載。 -
容器注入,需要將插件里代碼注入到 spring 里。 -
插件的熱插拔,怎么 unload,怎么 load,怎么從 spring 里 remove,怎么卸載等等。
3 開(kāi)發(fā)
具體細(xì)節(jié)本文不再展開(kāi),因?yàn)榇a都在 github stateis0/exp 項(xiàng)目里,這個(gè)項(xiàng)目包含實(shí)現(xiàn)代碼,example 代碼,api 使用,適配 springboot starter,最佳實(shí)踐等。
項(xiàng)目代碼結(jié)構(gòu)依賴(lài):

4 總結(jié)
EXP 全稱(chēng):Extension Plugin 擴(kuò)展點(diǎn)插件系統(tǒng);
希望本項(xiàng)目可以幫助你解決本地化軟件的定制需求問(wèn)題。同時(shí),也歡迎為本項(xiàng)目提 issue,pr 等。
項(xiàng)目地址 EXP 擴(kuò)展點(diǎn)插件系統(tǒng) for Github [1]
參考資料
[1]EXP 擴(kuò)展點(diǎn)插件系統(tǒng) for Github: https://github.com/stateIs0/exp
推薦閱讀:
我把SpringBoot的banner換成了美女,老板說(shuō)工作不飽和,建議安排加班...
互聯(lián)網(wǎng)初中高級(jí)大廠面試題(9個(gè)G) 內(nèi)容包含Java基礎(chǔ)、JavaWeb、MySQL性能優(yōu)化、JVM、鎖、百萬(wàn)并發(fā)、消息隊(duì)列、高性能緩存、反射、Spring全家桶原理、微服務(wù)、Zookeeper......等技術(shù)棧!
?戳閱讀原文領(lǐng)?。?/span> 朕已閱

