SpringBoot應用篇之FactoryBean及代理實現SPI機制示例

一灰灰發表於2019-03-01

更多Spring文章,歡迎點選 一灰灰Blog-Spring專題

FactoryBean在Spring中算是一個比較有意思的存在了,雖然在日常的業務開發中,基本上不怎麼會用到,但在某些場景下,如果用得好,卻可以實現很多有意思的東西

本篇博文主要介紹如何通過FactoryBean來實現一個類SPI機制的微型應用框架

文章內涉及到的知識點

  • SPI機制
  • FactoryBean
  • JDK動態代理

I. 相關知識點

在看下面的內容之前,得知道一下什麼是SPI,以及SPI的用處和JDK實現SPI的方式,對於這一塊有興趣瞭解的童鞋,可以看一下個人之前寫的相關文章

1. demo背景說明

在開始之前,有必要了解一下,我們準備做的這個東西,到底適用於什麼樣的場景。

在電商中,有一個比較恰當的例子,商品詳情頁的展示。拿淘寶系的詳情頁作為背景來說明(沒有在阿里工作過,下面的東西純粹是為了說明應用場景而展開)

商品詳情頁

假設有這麼三個詳情頁,我們設定一個大前提,底層的資料層提供方都是一套的,商品詳情展示的服務完全可以做到複用,即三個性情頁中,絕大多數的東西都一樣,只是不同的詳情頁車重點不同而已。

如上圖中,我們假定有細微區別的幾個地方

位置 淘寶詳情 天貓詳情 鹹魚詳情 說明
banner 顯示淘寶的背景牆 顯示天貓的廣告位 鹹魚的坑位 三者資料結構完全一致,僅圖片url不同
推薦 推薦同類商品 推薦店家其他商品 推薦同類二手產品 資料結構相同,內容不同
評價 商品評價 商品評價 沒有評價,改為留言
促銷 優惠券 天貓積分券 沒有券

根據上面的簡單對比,其實只想表達一個意思,業務基本上一致,僅僅只有很少的一些東西不同,需要定製化,這個時候可以考慮用SPI來支援定製化的服務

2. SPI簡述

a. 基本定義

SPI的全名為Service Provider Interface,簡單的總結下java spi機制的思想。我們系統裡抽象的各個模組,往往有很多不同的實現方案,比如日誌模組的方案,xml解析模組、jdbc模組的方案等。面向的物件的設計裡,我們一般推薦模組之間基於介面程式設計,模組之間不對實現類進行硬編碼。一旦程式碼裡涉及具體的實現類,就違反了可拔插的原則,如果需要替換一種實現,就需要修改程式碼。為了實現在模組裝配的時候能不在程式裡動態指明,這就需要一種服務發現機制。 java spi就是提供這樣的一個機制:為某個介面尋找服務實現的機制

上面是相對正視一點的介紹,簡單一點,符合本文設計目標的介紹如下

  • 介面方式引用
  • 具體執行時,根據某些條件,選中實際的子類執行

通過上面的描述,可以發現一個最大的優點就是:

  • 通過擴充套件介面的實現,就可以實現服務擴充套件;而不需要改原來的業務程式碼

b. demo輔助說明

一個簡單的應用場景如下

報警系統demo

這個報警系統中,對於使用者而言,通過 IAlarm#sendMsg(level, msg) 來執行報警傳送的方式,然而這一行的具體執行者是(忽略,日誌報警,郵件報警還是簡訊報警)不確定的,通過SPI的實現方式將是如下

  • 如果level為1,則忽略報警內容
  • 如果level為2,則採用日誌報警的方式來報警

如果我們想新新增一種報警方式呢?那也很簡單,新建一個報警的實現

  • level == 5, 則採用微信報警

然後對於使用者而言,其他的地方都不用改,只是在傳入的level引數換成5就可以了

3. 代理模式簡述

代理模式,在Spring中可以說是非常非常非常常見的一種設計模式了,大名鼎鼎的AOP就是這個實現的一個經典case,常見的代理有兩種實現方式

  • JDK方式
  • CGLIB方式

簡單說一下,代理模式的定義和說明如下,更多詳情可以參考: 實現MVC: 3. AOP實現準備篇代理模式

其實在現實生活中代理模式還是非常多得,這裡引入一個代理商的概念來加以描述,本來一個水果園直接賣水果就好了,現在中間來了一個水果超市,水果園的代銷商,對水果進行分類,包裝,然後再賣給使用者,這其實也算是一種代理

百科定義:為其他物件提供一種代理以控制對這個物件的訪問。在某些情況下,一個物件不適合或者不能直接引用另一個物件,而代理物件可以在客戶端和目標物件之間起到中介的作用。

II. 方案設計與實現

瞭解完上面的前提之後,我們可以考慮下如何實現一個Spring容器中的SPI工具包

1. 目標拆分

首先確定大的生態環境為Spring,我們針對Bean做SPI功能的擴充套件,即定義一個SPI的介面,然後可以有多個實現類,並且全部都宣告為Bean;

SPI的一個重要特點就是可以選中不同的實現來執行具體的程式碼,那麼放在這裡,就會有兩種方案

  • 方案一:依賴注入時,直接根據選擇條件,注入一個滿足的例項,後續所有的SPI呼叫,都將走這個具體的例項呼叫執行
  • 方案二:依賴注入時,不注入具體的例項,反而註冊一個代理類,在代理類中,根據呼叫的引數來選擇具體匹配的例項來執行,因此後續的呼叫具體選中的例項將與傳入的引數有關

方案對比

方案一 方案二
接近JDK的SPI使用方式 代理方式選中匹配的例項
優點:簡單,使用以及後續維護簡單 靈活, 支援更富想象力的擴充套件
缺點:一對一,複用性不夠,不能支援前面的case 實現和呼叫方式跟繁瑣一點,需要傳入用於選擇具體例項條件引數
每次選擇子類都需要額外計算

對比上面的兩個方案之後,選中第二個(當然主要原因是為了演示FactoryBean和代理實現SPI機制,如果選擇方案一就沒有這兩個什麼事情了)

選中方案之後,目標拆分就比較清晰了

  • 定義SPI介面,以及SPI的使用姿勢(前提)
  • 一個生成代理類的FactoryBean (核心)

2. 方案設計

針對前面拆分的目標,進行方案設計,第一步就是介面相關的定義了

a. 介面定義

設計的SPI微型框架的核心為:在執行的時候,根據傳入的引數來決定具體的例項來執行,因此我們的介面設計中,至少有一個根據傳入的引數來判斷是否選中這個例項的介面

public interface ISpi<T> {
    boolean verify(T condition);
}
複製程式碼

看到上面的實現之後,就會有一個疑問,如果有多個子類都滿足這個條件怎麼辦?因此可以加一個排序的介面,返回優先順序最高的匹配者

public interface ISpi<T> {
    boolean verify(T condition);

    /**
     * 排序,數字越小,優先順序越高
     * @return
     */
    default int order() {
        return 10;
    }
}
複製程式碼

介面定義之後,使用者應該怎麼用呢?

b. 使用約束

spi實現的約束

基於JDK的代理模式,一個最大的前提就是,只能根據介面來生成代理類,因此在使用SPI的時候,我們希望使用者先定義一個介面來繼承ISpi,然後具體的SPI實現這個介面即可

其次就是在Spring的生態下,要求所有的SPI實現都是Bean,需要自動掃描或者配置註解方式宣告,否者代理類就不太好獲取所有的SPI實現了

spi使用的約束

在使用SPI介面時,通過介面的方式來引入,因為我們實際注入的會是代理類,因此不要寫具體的實現類

單獨看上面的說明,可能不太好理解,建議結合下面的例項演示對比

c. 代理類生成

這個屬於最核心的地方了(雖說重要性為No1,但實現其實非常非常簡單)

代理類主要目的就是在具體呼叫執行時,根據傳入的引數來選中具體的執行者,執行後並返回對應的結果

  • 獲取所有的SPI實現類(org.springframework.beans.factory.ListableBeanFactory#getBeansOfType(java.lang.Class<T>)
  • 通過jdk生成代理類,代理類中,遍歷所有的SPI實現,根據傳入的第一個引數作為條件進行匹配,找出首個命中的SPI實現類,執行

將上面的步驟具體實現,也就比較簡單了

public class SpiFactoryBean<T> implements FactoryBean<T> {
    private Class<? extends ISpi> spiClz;

    private List<ISpi> list;

    public SpiFactoryBean(ApplicationContext applicationContext, Class<? extends ISpi> clz) {
        this.spiClz = clz;

        Map<String, ? extends ISpi> map = applicationContext.getBeansOfType(spiClz);
        list = new ArrayList<>(map.values());
        list.sort(Comparator.comparingInt(ISpi::order));
    }

    @Override
    @SuppressWarnings("unchecked")
    public T getObject() throws Exception {
        // jdk動態代理類生成
        InvocationHandler invocationHandler = new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                for (ISpi spi : list) {
                    if (spi.verify(args[0])) {
                        // 第一個引數作為條件選擇
                        return method.invoke(spi, args);
                    }
                }

                throw new NoSpiChooseException("no spi server can execute! spiList: " + list);
            }
        };

        return (T) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), new Class[]{spiClz},
                invocationHandler);
    }

    @Override
    public Class<?> getObjectType() {
        return spiClz;
    }
}
複製程式碼

3. 例項演示

話說方案設計之後,應該就是實現了,然而因為實現過於簡單,設計的過程中,也就順手寫了,就是上面的一個介面定義 ISpi 和一個用來生成動態代理類的SpiFactoryBean

接下來寫一個簡單的例項用於功能演示,定義一個IPrint用於文字輸出,並給兩個實現,一個控制檯輸出,一個日誌輸出

public interface IPrint extends ISpi<Integer> {

    default void execute(Integer level, Object... msg) {
        print(msg.length > 0 ? (String) msg[0] : null);
    }

    void print(String msg);
}
複製程式碼

具體的實現類如下,外部使用者通過execute方法實現呼叫,其中level<=0時選擇控制檯輸出;否則選則日誌檔案方式輸出

@Component
public class ConsolePrint implements IPrint {
    @Override
    public void print(String msg) {
        System.out.println("console print: " + msg);
    }

    @Override
    public boolean verify(Integer condition) {
        return condition <= 0;
    }
}

@Slf4j
@Component
public class LogPrint implements IPrint {
    @Override
    public void print(String msg) {
        log.info("log print: {}", msg);
    }

    @Override
    public boolean verify(Integer condition) {
        return condition > 0;
    }
}
複製程式碼

前面的步驟和一般的寫法沒有什麼區別,使用的姿勢又是怎樣的呢?

@SpringBootApplication
public class Application {

    public Application(IPrint printProxy) {
        printProxy.execute(10, " log print ");
        printProxy.execute(0, " console print ");
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
複製程式碼

看上面的Application的構造方法,要求傳入一個IPrint引數,Spring會從容器中找到一個bean作為引數傳入,而這個bean就是我們生成的代理類,這樣才可以根據不同的引數來選中具體的實現類

所以問題就是如何宣告這個代理類了,配置如下,通過FactoryBean的方式來宣告Bean,並新增上@Primary註解,這樣就可以確保注入的是我們宣告的代理類了

@Configuration
public class PrintAutoConfig {

    @Bean
    public SpiFactoryBean printSpiPoxy(ApplicationContext applicationContext) {
        return new SpiFactoryBean(applicationContext, IPrint.class);
    }

    @Bean
    @Primary
    public IPrint printProxy(SpiFactoryBean spiFactoryBean) throws Exception {
        return (IPrint) spiFactoryBean.getObject();
    }
}
複製程式碼

上面的使用邏輯,涉及到的知識點在前面的博文中分別有過介紹,更多詳情可以參考

接下來就是實際執行看下結果如何了

演示demo

III. 其他

0. 專案相關

a. 更多博文

基礎篇

應用篇

b. 專案原始碼

1. 一灰灰Blog

一灰灰的個人部落格,記錄所有學習和工作中的博文,歡迎大家前去逛逛

2. 宣告

盡信書則不如,以上內容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發現bug或者有更好的建議,歡迎批評指正,不吝感激

3. 掃描關注

一灰灰blog

QrCode
SpringBoot應用篇之FactoryBean及代理實現SPI機制示例

相關文章