SpringBoot 插件化開發詳細總結

作者:小碼農叔叔

原文:https://blog.csdn.net/zhangcongyi420/article/details/131139599

一、前言

插件化開發模式正在很多編程語言或技術框架中得以廣泛的應用實踐,比如大家熟悉的 jenkins,docker 可視化管理平臺 rancher,以及日常編碼使用的編輯器 idea,vscode 等,隨處可見的帶有熱插拔功能的插件,讓系統像插了翅膀一樣,大大提升了系統的擴展性和伸縮性,也拓展了系統整體的使用價值,那麼爲什麼要使用插件呢?

1.1 使用插件的好處

1.1.1 模塊解耦

實現服務模塊之間解耦的方式有很多,但是插件來說,其解耦的程度似乎更高,而且更靈活,可定製化、個性化更好。

舉例來說,代碼中可以使用設計模式來選擇使用哪種方式發送短信給下單完成的客戶,問題是各個短信服務商並不一定能保證在任何情況下都能發送成功,怎麼辦呢?這時候設計模式也沒法幫你解決這個問題,如果使用定製化插件的方式,結合外部配置參數,假設系統中某種短信發送不出去了,這時候就可以利用插件動態植入,切換爲不同的廠商發短信了。

1.1.2 提升擴展性和開放性

以 spring 來說,之所以具備如此廣泛的生態,與其自身內置的各種可擴展的插件機制是分不開的,試想爲什麼使用了 spring 框架之後可以很方便的對接其他中間件,那就是 spring 框架提供了很多基於插件化的擴展點。微信搜索公衆號:架構師指南,回覆:架構師 領取資料 。

插件化機制讓系統的擴展性得以提升,從而可以豐富系統的周邊應用生態。

1.1.3 方便第三方接入

有了插件之後,第三方應用或系統如果要對接自身的系統,直接基於系統預留的插件接口完成一套適合自己業務的實現即可,而且對自身系統的侵入性很小,甚至可以實現基於配置參數的熱加載,方便靈活,開箱即用。

1.2 插件化常用實現思路

以 java 爲例,這裏結合實際經驗,整理一些常用的插件化實現思路:

二、Java 常用插件實現方案

2.1 serviceloader 方式

serviceloader 是 java 提供的 spi 模式的實現。按照接口開發實現類,而後配置,java 通過 ServiceLoader 來實現統一接口不同實現的依次調用。而 java 中最經典的 serviceloader 的使用就是 Java 的 spi 機制。

2.1.1 java spi

SPI 全稱 Service Provider Interface ,是 JDK 內置的一種服務發現機制,SPI 是一種動態替換擴展機制,比如有個接口,你想在運行時動態給他添加實現,你只需按照規範給他添加一個實現類即可。比如大家熟悉的 jdbc 中的 Driver 接口,不同的廠商可以提供不同的實現,有 mysql 的,也有 oracle 的,而 Java 的 SPI 機制就可以爲某個接口尋找服務的實現。

下面用一張簡圖說明下 SPI 機制的原理

2.1.2 java spi 簡單案例

如下工程目錄,在某個應用工程中定義一個插件接口,而其他應用工程爲了實現這個接口,只需要引入當前工程的 jar 包依賴進行實現即可,這裏爲了演示我就將不同的實現直接放在同一個工程下;

定義接口

public interface MessagePlugin {
 
    public String sendMsg(Map msgMap);
 
}

定義兩個不同的實現

public class AliyunMsg implements MessagePlugin {
 
    @Override
    public String sendMsg(Map msgMap) {
        System.out.println("aliyun sendMsg");
        return "aliyun sendMsg";
    }
}
public class TencentMsg implements MessagePlugin {
 
    @Override
    public String sendMsg(Map msgMap) {
        System.out.println("tencent sendMsg");
        return "tencent sendMsg";
    }
}

在 resources 目錄按照規範要求創建文件目錄,並填寫實現類的全類名

自定義服務加載類

 public static void main(String[] args) {
        ServiceLoader<MessagePlugin> serviceLoader = ServiceLoader.load(MessagePlugin.class);
        Iterator<MessagePlugin> iterator = serviceLoader.iterator();
        Map map = new HashMap();
        while (iterator.hasNext()){
            MessagePlugin messagePlugin = iterator.next();
            messagePlugin.sendMsg(map);
        }
    }

運行上面的程序後,可以看到下面的效果,這就是說,使用 ServiceLoader 的方式可以加載到不同接口的實現,業務中只需要根據自身的需求,結合配置參數的方式就可以靈活的控制具體使用哪一個實現。

2.2 自定義配置約定方式

serviceloader 其實是有缺陷的,在使用中必須在 META-INF 裏定義接口名稱的文件,在文件中才能寫上實現類的類名,如果一個項目裏插件化的東西比較多,那很可能會出現越來越多配置文件的情況。關注公衆號:碼猿技術專欄,回覆關鍵詞:1111 獲取阿里內部 Java 性能調優手冊!所以在結合實際項目使用時,可以考慮下面這種實現思路:

在上文中案例基礎上,我們做如下調整;

2.2.1 添加配置文件

在配置文件中,將具體的實現類配置進去

server :
  port : 8081
impl:
  name : com.congge.plugins.spi.MessagePlugin
  clazz :
    - com.congge.plugins.impl.TencentMsg
    - com.congge.plugins.impl.AliyunMsg

2.2.2 自定義配置文件加載類

通過這個類,將上述配置文件中的實現類封裝到類對象中,方便後續使用;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("impl")
@ToString
public class ClassImpl {
    @Getter
    @Setter
    String name;
 
    @Getter
    @Setter
    String[] clazz;
}

2.2.3 自定義測試接口

使用上述的封裝對象通過類加載的方式動態的在程序中引入

import com.congge.config.ClassImpl;
import com.congge.plugins.spi.MessagePlugin;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.util.HashMap;
 
@RestController
public class SendMsgController {
 
    @Autowired
    ClassImpl classImpl;
 
    //localhost:8081/sendMsg
    @GetMapping("/sendMsg")
    public String sendMsg() throws Exception{
        for (int i=0;i<classImpl.getClazz().length;i++) {
            Class pluginClass= Class.forName(classImpl.getClazz()[i]);
            MessagePlugin messagePlugin = (MessagePlugin) pluginClass.newInstance();
            messagePlugin.sendMsg(new HashMap());
        }
        return "success";
    }
 
}

2.2.4 啓動類

@EnableConfigurationProperties({ClassImpl.class})
@SpringBootApplication
public class PluginApp {
 
    public static void main(String[] args) {
        SpringApplication.run(PluginApp.class,args);
    }
 
}

啓動工程代碼後,調用接口:localhost:8081/sendMsg,在控制檯中可以看到下面的輸出信息,即通過這種方式也可以實現類似 serviceloader 的方式,不過在實際使用時,可以結合配置參數進行靈活的控制;

2.3 自定義配置讀取依賴 jar 的方式

更進一步,在很多場景下,可能我們並不想直接在工程中引入接口實現的依賴包,這時候可以考慮通過讀取指定目錄下的依賴 jar 的方式,利用反射的方式進行動態加載,這也是生產中一種比較常用的實踐經驗。

具體實踐來說,主要爲下面的步驟:

在上述的基礎上,按照上面的實現思路來實現一下;

2.3.1 創建約定目錄

在當前工程下創建一個 lib 目錄,並將依賴的 jar 放進去

2.3.2 新增讀取 jar 的工具類

添加一個工具類,用於讀取指定目錄下的 jar,並通過反射的方式,結合配置文件中的約定配置進行反射方法的執行;

@Component
public class ServiceLoaderUtils {
 
    @Autowired
    ClassImpl classImpl;
 
 
    public static void loadJarsFromAppFolder() throws Exception {
        String path = "E:\\code-self\\bitzpp\\lib";
        File f = new File(path);
        if (f.isDirectory()) {
            for (File subf : f.listFiles()) {
                if (subf.isFile()) {
                    loadJarFile(subf);
                }
            }
        } else {
            loadJarFile(f);
        }
    }
 
    public static void loadJarFile(File path) throws Exception {
        URL url = path.toURI().toURL();
        // 可以獲取到AppClassLoader,可以提到前面,不用每次都獲取一次
        URLClassLoader classLoader = (URLClassLoader) ClassLoader.getSystemClassLoader();
        // 加載
        //Method method = URLClassLoader.class.getDeclaredMethod("sendMsg", Map.class);
        Method method = URLClassLoader.class.getMethod("sendMsg", Map.class);
 
        method.setAccessible(true);
        method.invoke(classLoader, url);
    }
 
    public  void main(String[] args) throws Exception{
        System.out.println(invokeMethod("hello"));;
    }
 
    public String doExecuteMethod() throws Exception{
        String path = "E:\\code-self\\bitzpp\\lib";
        File f1 = new File(path);
        Object result = null;
        if (f1.isDirectory()) {
            for (File subf : f1.listFiles()) {
                //獲取文件名稱
                String name = subf.getName();
                String fullPath = path + "\\" + name;
                //執行反射相關的方法
                //ServiceLoaderUtils serviceLoaderUtils = new ServiceLoaderUtils();
                //result = serviceLoaderUtils.loadMethod(fullPath);
                File f = new File(fullPath);
                URL urlB = f.toURI().toURL();
                URLClassLoader classLoaderA = new URLClassLoader(new URL[]{urlB}, Thread.currentThread()
                        .getContextClassLoader());
                String[] clazz = classImpl.getClazz();
                for(String claName : clazz){
                    if(name.equals("biz-pt-1.0-SNAPSHOT.jar")){
                        if(!claName.equals("com.congge.spi.BitptImpl")){
                            continue;
                        }
                        Class<?> loadClass = classLoaderA.loadClass(claName);
                        if(Objects.isNull(loadClass)){
                            continue;
                        }
                        //獲取實例
                        Object obj = loadClass.newInstance();
                        Map map = new HashMap();
                        //獲取方法
                        Method method=loadClass.getDeclaredMethod("sendMsg",Map.class);
                        result = method.invoke(obj,map);
                        if(Objects.nonNull(result)){
                            break;
                        }
                    }else if(name.equals("miz-pt-1.0-SNAPSHOT.jar")){
                        if(!claName.equals("com.congge.spi.MizptImpl")){
                            continue;
                        }
                        Class<?> loadClass = classLoaderA.loadClass(claName);
                        if(Objects.isNull(loadClass)){
                            continue;
                        }
                        //獲取實例
                        Object obj = loadClass.newInstance();
                        Map map = new HashMap();
                        //獲取方法
                        Method method=loadClass.getDeclaredMethod("sendMsg",Map.class);
                        result = method.invoke(obj,map);
                        if(Objects.nonNull(result)){
                            break;
                        }
                    }
                }
                if(Objects.nonNull(result)){
                    break;
                }
            }
        }
        return result.toString();
    }
 
    public Object loadMethod(String fullPath) throws Exception{
        File f = new File(fullPath);
        URL urlB = f.toURI().toURL();
        URLClassLoader classLoaderA = new URLClassLoader(new URL[]{urlB}, Thread.currentThread()
                .getContextClassLoader());
        Object result = null;
        String[] clazz = classImpl.getClazz();
        for(String claName : clazz){
            Class<?> loadClass = classLoaderA.loadClass(claName);
            if(Objects.isNull(loadClass)){
                continue;
            }
            //獲取實例
            Object obj = loadClass.newInstance();
            Map map = new HashMap();
            //獲取方法
            Method method=loadClass.getDeclaredMethod("sendMsg",Map.class);
            result = method.invoke(obj,map);
            if(Objects.nonNull(result)){
                break;
            }
        }
        return result;
    }
 
 
    public static String invokeMethod(String text) throws Exception{
        String path = "E:\\code-self\\bitzpp\\lib\\miz-pt-1.0-SNAPSHOT.jar";
        File f = new File(path);
        URL urlB = f.toURI().toURL();
        URLClassLoader classLoaderA = new URLClassLoader(new URL[]{urlB}, Thread.currentThread()
                .getContextClassLoader());
        Class<?> product = classLoaderA.loadClass("com.congge.spi.MizptImpl");
        //獲取實例
        Object obj = product.newInstance();
        Map map = new HashMap();
        //獲取方法
        Method method=product.getDeclaredMethod("sendMsg",Map.class);
        //執行方法
        Object result1 = method.invoke(obj,map);
        // TODO According to the requirements , write the implementation code.
        return result1.toString();
    }
 
    public static String getApplicationFolder() {
        String path = ServiceLoaderUtils.class.getProtectionDomain().getCodeSource().getLocation().getPath();
        return new File(path).getParent();
    }
 
 
 
}

2.3.3 添加測試接口

添加如下測試接口

@GetMapping("/sendMsgV2")
public String index() throws Exception {
    String result = serviceLoaderUtils.doExecuteMethod();
    return result;
}

以上全部完成之後,啓動工程,測試一下該接口,仍然可以得到預期結果;

在上述的實現中還是比較粗糙的,實際運用時,還需要做較多的優化改進以滿足實際的業務需要,比如接口傳入類型參數用於控制具體使用哪個依賴包的方法進行執行等;

三、SpringBoot 中的插件化實現

在大家使用較多的 springboot 框架中,其實框架自身提供了非常多的擴展點,其中最適合做插件擴展的莫過於spring.factories的實現;

3.1 Spring Boot 中的 SPI 機制

在 Spring 中也有一種類似與 Java SPI 的加載機制。它在META-INF/spring.factories文件中配置接口的實現類名稱,然後在程序中讀取這些配置文件並實例化,這種自定義的 SPI 機制是 Spring Boot Starter 實現的基礎。

3.2 Spring Factories 實現原理

spring-core 包裏定義了SpringFactoriesLoader類,這個類實現了檢索META-INF/spring.factories文件,並獲取指定接口的配置的功能。在這個類中定義了兩個對外的方法:

上面的兩個方法的關鍵都是從指定的 ClassLoader 中獲取spring.factories文件,並解析得到類名列表,具體代碼如下:

public static List<String> loadFactoryNames(Class<?> factoryClass, ClassLoader classLoader) {
    String factoryClassName = factoryClass.getName();
    try {
        Enumeration<URL> urls = (classLoader != null ? classLoader.getResources(FACTORIES_RESOURCE_LOCATION) :
                ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));
        List<String> result = new ArrayList<String>();
        while (urls.hasMoreElements()) {
            URL url = urls.nextElement();
            Properties properties = PropertiesLoaderUtils.loadProperties(new UrlResource(url));
            String factoryClassNames = properties.getProperty(factoryClassName);
            result.addAll(Arrays.asList(StringUtils.commaDelimitedListToStringArray(factoryClassNames)));
        }
        return result;
    }
    catch (IOException ex) {
        throw new IllegalArgumentException("Unable to load [" + factoryClass.getName() +
                "] factories from location [" + FACTORIES_RESOURCE_LOCATION + "]", ex);
    }
}

從代碼中我們可以知道,在這個方法中會遍歷整個 ClassLoader 中所有 jar 包下的spring.factories文件,就是說我們可以在自己的 jar 中配置spring.factories文件,不會影響到其它地方的配置,也不會被別人的配置覆蓋。

spring.factories的是通過 Properties 解析得到的,所以我們在寫文件中的內容都是安裝下面這種方式配置的:

com.xxx.interface=com.xxx.classname

如果一個接口希望配置多個實現類,可以使用’,’進行分割

3.3 Spring Factories 案例實現

接下來看一個具體的案例實現來體驗下 Spring Factories 的使用;

3.3.1 定義一個服務接口

自定義一個接口,裏面添加一個方法;

public interface SmsPlugin {
 
    public void sendMessage(String message);
 
}

3.3.2 定義 2 個服務實現

實現類 1

public class BizSmsImpl implements SmsPlugin {
 
    @Override
    public void sendMessage(String message) {
        System.out.println("this is BizSmsImpl sendMessage..." + message);
    }
}

實現類 2

public class SystemSmsImpl implements SmsPlugin {
 
    @Override
    public void sendMessage(String message) {
        System.out.println("this is SystemSmsImpl sendMessage..." + message);
    }
}

3.3.3 添加 spring.factories 文件

在 resources 目錄下,創建一個名叫:META-INF的目錄,然後在該目錄下定義一個spring.factories的配置文件,內容如下,其實就是配置了服務接口,以及兩個實現類的全類名的路徑;

com.congge.plugin.spi.SmsPlugin=\
com.congge.plugin.impl.SystemSmsImpl,\
com.congge.plugin.impl.BizSmsImpl

3.3.4 添加自定義接口

添加一個自定義的接口,有沒有發現,這裏和 java 的 spi 有點類似,只不過是這裏換成了SpringFactoriesLoader去加載服務;

@GetMapping("/sendMsgV3")
public String sendMsgV3(String msg) throws Exception{
    List<SmsPlugin> smsServices= SpringFactoriesLoader.loadFactories(SmsPlugin.class, null);
    for(SmsPlugin smsService : smsServices){
        smsService.sendMessage(msg);
    }
    return "success";
}

啓動工程之後,調用一下該接口進行測試,localhost:8087/sendMsgV3?msg=hello,通過控制檯,可以看到,這種方式能夠正確獲取到系統中可用的服務實現;

利用 spring 的這種機制,可以很好的對系統中的某些業務邏輯通過插件化接口的方式進行擴展實現;

四、插件化機制案例實戰

結合上面掌握的理論知識,下面基於 Java SPI 機制進行一個接近真實使用場景的完整的操作步驟;

4.1 案例背景

4.1.1 模塊結構

1、biz-pp,插件化接口工程;

2、bitpt,aliyun 短信發送實現;

3、miz-pt,tencent 短信發送實現;

4.1.2 整體實現思路

本案例完整的實現思路參考如下:

4.2 biz-pp 關鍵代碼實現過程

4.2.1 添加服務接口

public interface MessagePlugin {
 
    public String sendMsg(Map msgMap);
 
}

4.2.2 打成 jar 包並安裝到倉庫

這一步比較簡單就不展開了

4.2.3 自定義服務加載工具類

這個類,可以理解爲在真實的業務編碼中,可以根據業務定義的規則,具體加載哪個插件的實現類進行發送短信的操作;

import com.congge.plugin.spi.MessagePlugin;
import com.congge.spi.BitptImpl;
import com.congge.spi.MizptImpl;
 
import java.util.*;
 
public class PluginFactory {
 
    public void installPlugin(){
        Map context = new LinkedHashMap();
        context.put("_userId","");
        context.put("_version","1.0");
        context.put("_type","sms");
        ServiceLoader<MessagePlugin> serviceLoader = ServiceLoader.load(MessagePlugin.class);
        Iterator<MessagePlugin> iterator = serviceLoader.iterator();
        while (iterator.hasNext()){
            MessagePlugin messagePlugin = iterator.next();
            messagePlugin.sendMsg(context);
        }
    }
 
    public static MessagePlugin getTargetPlugin(String type){
        ServiceLoader<MessagePlugin> serviceLoader = ServiceLoader.load(MessagePlugin.class);
        Iterator<MessagePlugin> iterator = serviceLoader.iterator();
        List<MessagePlugin> messagePlugins = new ArrayList<>();
        while (iterator.hasNext()){
            MessagePlugin messagePlugin = iterator.next();
            messagePlugins.add(messagePlugin);
        }
        MessagePlugin targetPlugin = null;
        for (MessagePlugin messagePlugin : messagePlugins) {
            boolean findTarget = false;
            switch (type) {
                case "aliyun":
                    if (messagePlugin instanceof BitptImpl){
                        targetPlugin = messagePlugin;
                        findTarget = true;
                        break;
                    }
                case "tencent":
                    if (messagePlugin instanceof MizptImpl){
                        targetPlugin = messagePlugin;
                        findTarget = true;
                        break;
                    }
            }
            if(findTarget) break;
        }
        return targetPlugin;
    }
 
    public static void main(String[] args) {
        new PluginFactory().installPlugin();
    }
 
 
}

4.2.4 自定義接口

@RestController
public class SmsController {
 
    @Autowired
    private SmsService smsService;
 
    @Autowired
    private ServiceLoaderUtils serviceLoaderUtils;
 
    //localhost:8087/sendMsg?msg=sendMsg
    @GetMapping("/sendMsg")
    public String sendMessage(String msg){
        return smsService.sendMsg(msg);
    }
 
}

4.2.5 接口實現

@Service
public class SmsService {
 
    @Value("${msg.type}")
    private String msgType;
 
    @Autowired
    private DefaultSmsService defaultSmsService;
 
    public String sendMsg(String msg) {
        MessagePlugin messagePlugin = PluginFactory.getTargetPlugin(msgType);
        Map paramMap = new HashMap();
        if(Objects.nonNull(messagePlugin)){
            return messagePlugin.sendMsg(paramMap);
        }
        return defaultSmsService.sendMsg(paramMap);
    }
}

4.2.6 添加服務依賴

在該模塊中,需要引入對具體實現的兩個工程的 jar 依賴(也可以通過啓動加載的命令方式)

<dependencies>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!--依賴具體的實現-->
    <dependency>
        <groupId>com.congge</groupId>
        <artifactId>biz-pt</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>

    <dependency>
        <groupId>com.congge</groupId>
        <artifactId>miz-pt</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>

</dependencies>

biz-pp 的核心代碼實現就到此結束了,後面再具體測試的時候再繼續;

4.3 bizpt 關鍵代碼實現過程

接下來就是插件化機制中具體的 SPI 實現過程,兩個模塊的實現步驟完全一致,挑選其中一個說明,工程目錄結構如下:

4.3.1 添加對 biz-app 的 jar 的依賴

將上面 biz-app 工程打出來的 jar 依賴過來

<dependencies>
    <dependency>
        <groupId>com.congge</groupId>
        <artifactId>biz-app</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
</dependencies>

4.3.2 添加 MessagePlugin 接口的實現

public class BitptImpl implements MessagePlugin {
 
    @Override
    public String sendMsg(Map msgMap) {
        Object userId = msgMap.get("userId");
        Object type = msgMap.get("_type");
        //TODO 參數校驗
        System.out.println(" ==== userId :" + userId + ",type :" + type);
        System.out.println("aliyun send message success");
        return "aliyun send message success";
    }
}

4.3.3 添加 SPI 配置文件

按照前文的方式,在 resources 目錄下創建一個文件,注意文件名稱爲 SPI 中的接口全名,文件內容爲實現類的全類名

com.congge.spi.BitptImpl

4.3.4 將 jar 安裝到倉庫中

完成實現類的編碼後,通過 maven 命令將 jar 安裝到倉庫中,然後再在上一步的 biz-app 中引入即可;

4.4 效果演示

啓動 biz-app 服務,調用接口:localhost:8087/sendMsg?msg=sendMsg,可以看到如下效果

爲什麼會出現這個效果呢?因爲我們在實現類配置了具體使用哪一種方式進行短信的發送,而加載插件的時候正好能夠找到對應的服務實現,這樣的話就給當前的業務提供了一個較好的擴展點。

五、寫在文末

從當前的趨勢來看,插件化機制的思想已經遍佈各種編程語言,框架,中間件,開源工具等領域,因此掌握插件化的實現機制對於當下做程序實現,或架構設計方面都有着很重要的意義,值得深入研究,本篇到此結束

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