CloudNotes之桌面客戶端篇:外掛系統的實現

dax.net發表於2015-02-25

CloudNotes版本更新歷史與各版本下載地址請點選此處

CloudNotes中文系列文章彙總列表請點選此處

檢視CloudNotes原始碼請點選此處

有時候,同一個名詞,針對不同的人群,應該採用不同的表達方式。比如外掛的概念,對於程式設計師而言,可以將其稱為外掛,或者擴充套件。對於使用者而言,或許“擴充套件功能”一詞會更加貼切。本文還是脫離不了碼農的氣質,繼續討論技術問題,因此,我會以“外掛”一詞進行描述。

概述

1.0.5504.38654版本開始,CloudNotes桌面客戶端可以支援外掛了。不僅該版本預設附帶了三個外掛,而且開發人員還能非常方便地使用Visual Studio 2013/2015,基於.NET Framework 4.5.1,為CloudNotes開發自己的外掛。本文將首先介紹外掛系統的設計。需要注意的是,目前外掛部分的實現,僅僅是針對CloudNotes的桌面客戶端,今後隨著CloudNotes的不斷更新和改進,可能會新增諸如Windows Phone、Web等客戶端,這些客戶端不在本文討論範圍之內。

有圖有真相。先看幾張新版本的截圖吧。首先是在“工具”選單中多出了一個“從網頁匯入”的選單項。通過該選單項,使用者可以直接輸入需要匯入的頁面的URL地址,然後CloudNotes會將此頁面儲存為當前使用者的一條筆記。筆記標題即為頁面的標題,如果無法識別頁面標題,或者標題已經存在,該功能還會提示使用者指定一個新的標題:

image

其次,在“檔案”選單中出現了“另存為”選單項。別小看這個普普通通的“另存為”,它可是以外掛的形式實現的。

image

點選此選單項,會開啟標準的“另存為”對話方塊,用來將當前開啟的筆記儲存到本地磁碟檔案。至於以何種格式的檔案進行儲存,就是由外掛來實現了。在1.0.5504.38654版本中,預設自帶的兩個匯出型外掛,可以分別將當前筆記儲存為純文字格式和HTML格式。

此外,CloudNotes桌面客戶端還為外掛配置提供了豐富的介面,比如使用者可以自己決定如何在“工具”選單中顯示外掛選單項,還可以對每個外掛進行單獨配置:

image

接下來就讓我們一起看看,在CloudNotes中,外掛系統是如何設計實現的。

設計與實現

首先可以考慮到的是,既然我們要為應用程式開發外掛,那麼我們就需要能夠通過一套機制,更確切地說是一套介面,將一部分應用程式的功能委託給外掛進行處理,這樣做的理由是顯而易見的。比如,在CloudNotes桌面客戶端中,會為外掛提供匯入筆記的介面,外掛只需要從另一些途徑獲得了筆記的內容,就可以使用這個介面將筆記匯入到CloudNotes中;其次,外掛是可以動態載入的,這也是外掛的基本特性之一,那麼外掛的載入和管理,就成為外掛系統實現的另一個話題;再次,外掛是可以配置的,我們的設計需要對外掛的可配置性進行考慮,這就需要包含以下三個內容:1、要能夠方便地提供外掛配置介面,2、要能夠方便地開啟或儲存外掛的配置資訊,3、要能夠為外掛的開發提供配置系統的應用程式介面。下面就從這三個方面出發,詳細講解CloudNotes桌面客戶端中外掛系統的設計與實現。

外掛的分類

相信你已經從上面的截圖中瞭解到,在CloudNotes桌面客戶端中,外掛分為兩種型別:工具型和匯出型。匯出型功能相對單一:只負責將當前筆記匯出成一個本地檔案,而工具型外掛所能實現的功能就比較多了。

image

技術上看,兩種外掛型別都是Extension抽象類的子類,在後續的系列文章中,我會詳細介紹如何使用CloudNotes桌面客戶端的擴充套件框架,分別開發工具型和匯出型外掛。

Shell

Shell是CloudNotes桌面客戶端外掛系統所引入的第一個概念,Shell在計算機領域中的英文字意是“外殼程式”的意思,在此表述了這樣一種機制:它提供了應用程式的基礎功能,卻隱含了另一部分的具體實現。這個概念聽起來有點抽象,然而在我們的外掛系統中,所有的外掛操作物件,就是這個Shell。

CloudNotes.DesktopClient.Extensibility名稱空間下,定義了IShell介面,它表述所有實現了該介面的類,均是CloudNotes桌面客戶端的外殼程式。在該介面中,我們定義了能夠提供給外掛所使用的各種屬性與方法,比如ImportNote方法,它可以實現筆記的匯入,還有Note屬性,它包含了當前選中的筆記的內容。下面的類圖描述了IShell介面以及與之相關的類之間的關係:

image

由此可見,CloudNotes桌面客戶端的主窗體FrmMain就是一個殼(Shell),而外掛的Execute方法會使用IShell介面進行所需的操作,也就是說,外掛會呼叫FrmMain中由IShell介面定義的屬性和方法。

每當FrmMain窗體初始化的時候,它會呼叫InitializeExtensions方法對所有外掛進行初始化,主要工作就是生成選單項,如果當前所載入的外掛是匯出型外掛的話,在該方法中就會針對“另存為”對話方塊生成相應的檔案格式過濾器。從下面這段程式碼可以看到,當某個外掛選單項被點選的時候,與該選單項關聯的外掛將被執行,而當前FrmMain窗體的例項,則會以Execute方法的引數形式傳遞給外掛執行邏輯。

extensionToolStrip.Click +=
    (s, e) => { SafeExecutionContext.Execute(this, () => toolExtension.Execute(this)); };

Shell其實是一個比較老的概念,根據維基百科中所述,計算機中的Shell是指作業系統提供的一組使用者介面(可以是文字的,也可以是圖形化的),使用者通過這些介面與作業系統互動。在CloudNotes桌面客戶端中,借用了這個概念,為外掛提供了與CloudNotes桌面客戶端的互動介面。

外掛管理器(Extension Manager)

在CloudNotes桌面客戶端中,外掛是由外掛管理器負責載入並管理的。外掛管理器的功能其實很簡單,主要部分就是外掛的裝載:掃描指定路徑下所有的DLL檔案,發現如果是合理的.NET程式集,並且其中包含外掛型別定義時,就會使用反射,獲取外掛型別並例項化外掛,最後將其儲存在本地的一個字典集合裡以備使用。這部分程式碼被定義在CloudNotes.DesktopClient.Extensibility名稱空間下的ExtensionManager類中,相對還是比較簡單的:

public void Load()
{
    var extensionFiles = Directory.EnumerateFiles(this.path, Constants.ExtensionFileSearchPattern,
        SearchOption.AllDirectories);
    foreach (var extensionFile in extensionFiles)
    {
        try
        {
            var assembly = Assembly.LoadFrom(extensionFile);
            foreach (var type in assembly.GetExportedTypes())
            {
                try
                {
                    if (type.IsDefined(typeof (ExtensionAttribute)) &&
                        type.IsSubclassOf(typeof (Extension)))
                    {
                        var extensionLoaded = (Extension) Activator.CreateInstance(type);
                        this.OnExtensionLoaded(extensionLoaded.Name);
                        this.extensions.Add(extensionLoaded.ID, extensionLoaded);
                    }
                }
                catch
                {
                }
            }
        }
        catch
        {
        }
    }
}

在此就不對這部分程式碼作過多解釋了。引入外掛管理器的一個最大好處就是,可以保證外掛在整個CloudNotes桌面客戶端的生命週期中只被裝載一次,並且能夠很方便地在多個元件之間共享:

  • FrmMain窗體:需要通過外掛管理器將所載入的外掛顯示到使用者介面,並觸發外掛的執行
  • FrmAbout窗體:需要通過外掛管理器獲取所載入的外掛的詳細資訊,並顯示給使用者
  • FrmSettings窗體:需要通過外掛管理器獲取所載入外掛的配置資訊,併為使用者提供外掛配置的功能
  • SingleInstanceController:在該型別中初始化外掛管理器,並載入外掛

接下來的話題就是,何時啟動外掛的載入過程?外掛的載入其實有很多種方式,相對而言,以下兩種方式最為簡單常見:

  • 提供一個啟動介面,在應用程式啟動的時候載入外掛,並在啟動介面上顯示載入情況:這種方式最為常見,實現也很簡單。很多應用程式都使用這種方式來完成應用程式初始化和外掛載入的過程。然而對於CloudNotes來說,並沒有採取這種方式。首先,單獨設定一個啟動介面會讓使用者感覺到CloudNotes桌面客戶端很“大”(或者說很“重”),顯得並不輕量,因為往往都是一些大型的應用程式(Word、Excel、Photoshop、AutoCAD等等)才會有這樣一個專業的啟動介面;其次,老版本的CloudNotes桌面客戶端沒有提供啟動介面,突然出現一個啟動介面會讓老使用者感覺突兀(別笑我,估計也沒幾個老使用者,但這也是做應用程式設計和開發的時候必須考慮的一個因素);再次,對於CloudNotes桌面客戶端而言,外掛的載入過程還是相當快速的,一方面並沒有成千上萬的外掛需要載入,另一方面,外掛的載入邏輯也相對簡單,所以外掛載入過程應該是一個秒級的操作,引入啟動介面倒還顯得多餘。因此,CloudNotes桌面客戶端採用了下面一條所述的方式
  • 在某個長時操作的同時載入外掛:關鍵是選擇一個長時操作的時間點,從該時間點開始,非同步地將外掛載入到記憶體中。通過簡單分析,不難發現在CloudNotes桌面客戶端中,登入介面就是一個長時操作,在這個介面下,CloudNotes桌面客戶端會等待使用者輸入使用者名稱和密碼,在點選“確定”按鈕後,會聯絡伺服器進行登入認證。整個操作過程所花費的時間將遠遠大於外掛的載入時間,因此,在登入介面啟動時,非同步載入外掛是順理成章的事

CloudNotes桌面客戶端登入介面的啟動是由LoginProvider負責的,而SingleInstanceController使用LoginProvider完成使用者登入功能。SingleInstanceController確保在系統中僅有一個CloudNotes桌面客戶端的例項在執行,這在今後我會介紹。SingleInstanceController的OnCreateMainForm過載方法實現了擴充套件載入的邏輯:

protected override void OnCreateMainForm()
{
    var extensionManager = new ExtensionManager();
    var settings = DesktopClientSettings.ReadSettings();
    var loadExtensionTask = Task.Factory.StartNew(() =>
    {
        // As the extensions are loaded in another thread, setting that thread's ui culture
        // to the one read from the setting preference.
        Thread.CurrentThread.CurrentUICulture = new CultureInfo(settings.General.Language);
        extensionManager.Load();
    });


    Thread.CurrentThread.CurrentUICulture = new CultureInfo(settings.General.Language);

    var credential = LoginProvider.Login(Application.Exit, settings);
    if (credential != null)
    {
        Task.WaitAll(loadExtensionTask);
        // Instantiate your main application form
        this.MainForm = new FrmMain(credential, settings, extensionManager);
    }
}

在上面的程式碼中,使用了.NET並行庫TPL的Task Factory來啟動一個並行任務,在任務的執行體中,呼叫extensionManager的Load方法,開始載入外掛。接下來,就會由LoginProvider負責使用者的登入過程,當登入過程結束之後,當前執行緒會阻塞在Task.WaitAll這行程式碼,等待外掛完全載入完成,最後就會初始化並啟動FrmMain。根據上面的分析,通常情況下外掛載入過程是很快的,因此,事實上99%的情況下,此處Task.WaitAll呼叫並不會阻塞,使用者體驗仍然那麼流暢,不耽誤事兒。

通過這部分內容,我們可以瞭解到,軟體開發過程需要綜合性地考慮很多事情,不僅僅是將關注點放在功能上,那些非功能性需求也無時不刻地需要我們的“關懷”,並且,這些看似不起眼的非功能性需求,往往又是開發的難點(比如高效能需求、嚴格的安全認證機制等),甚至直接影響專案和產品的成敗。

外掛配置

為CloudNotes桌面客戶端外掛提供靈活的、可擴充套件的外掛配置系統,是外掛這個課題的難點。因為不僅需要考慮到終端使用者的體驗,而且還要考慮到外掛開發人員的感受。因此,CloudNotes特別提供了外掛配置框架,保證能夠體現外掛配置系統的上述兩種職能。image

也正如上文截圖中所示,在CloudNotes桌面客戶端的標準配置介面中,新增了“擴充套件功能”選項卡,它列出了目前載入的所有外掛,當使用者單擊左邊的外掛時,與之相關的配置介面會顯示在對話方塊的右邊部分。由此分析,配置介面應該是外掛的一個屬性。既然有了配置介面,就需要有與之對應的配置資料,更進一步,外掛開發人員還應該能夠為外掛提供預設的配置資料,例如我們可以讓使用者選擇筆記儲存所使用的編碼(Encoding),但預設應該使用UTF-8的Encoding。討論到此處,MVC模式慢慢浮現出來,或許我們可以借用MVC模式的概念,並提供一個機制,能夠將配置資料繫結到配置介面,或者從配置介面把配置資料收集起來以便儲存到配置檔案。為了將“配置”的關注點從外掛上分離開來,CloudNotes外掛配置框架引入了“外掛配置供應器”(Extension Setting Provider)的概念,它包含了配置介面、配置資料、預設配置的資訊,並且提供從配置介面收集配置資料和將配置資料繫結到配置介面的方法。

CloudNotes.DesktopClient.Extensibility名稱空間下ExtensionSettingProvider類就是外掛配置供應器,它的程式碼在此就不再重複了。

ExtensionAttribute特性中,包含了指定ExtensionSettingProvider的建構函式過載,當ExtensionAttribute被應用到Extension的子類時,Extension的SettingProvider屬性就會根據ExtensionAttribute特性中指定的ExtensionSettingProvider型別來獲取它的例項,進而完成了外掛對配置框架的聚合,也為CloudNotes桌面客戶端訪問外掛配置提供了橋樑。不過值得一提的是,Extension類的SettingProvider屬性採用了一種類似快取的機制,僅做一次ExtensionSettingProvider的初始化,這是因為ExtensionSettingProvider有可能會保持那些使用者改變過但沒有儲存的配置資料。

接下來的事情就比較簡單了,在FrmSettings窗體程式碼中,BindExtension方法會判斷當前選中的外掛是否指定了外掛配置供應器,如果沒有指定,則簡單地初始化一個NoSettingsControl例項,將其顯示在介面右側,告知使用者“該擴充套件功能未提供任何可供設定的選項”。否則,將外掛配置供應器所提供的使用者介面控制元件顯示在介面上,並檢視本地字典快取中是否有已經更改過的配置資料。若有,則將該資料繫結到介面上,否則就將從配置檔案中讀入配置資訊(若無,則取預設配置資訊),並繫結到使用者介面上。

private void BindExtension(Guid extensionId)
{
    var extension = this.extensionManager.GetByKey(extensionId);
    pnlSettings.Controls.Clear();
    if (extension.SettingProvider == null)
    {
        var noSettingsControl = new NoSettingsControl();
        noSettingsControl.Dock = DockStyle.Fill;
        pnlSettings.Controls.Add(noSettingsControl);
    }
    else
    {
        pnlSettings.Controls.Add(extension.SettingProvider.SettingControl);
        if (cachedSettings.ContainsKey(extensionId))
        {
            extension.SettingProvider.BindSetting(cachedSettings[extensionId]);
        }
        else
        {
            extension.SettingProvider.BindSetting(extension.SettingProvider.ExtensionSetting);
        }
    }
}

上面程式碼中cachedSettings字典儲存了使用者此次開啟系統配置窗體後,對外掛所做的配置更改,這是為了能夠在使用者瀏覽各個外掛配置的過程中,保持每個外掛之前更改過的配置值。當介面左邊所選中的外掛發生變化時,窗體會清除之前所選外掛的配置介面,並顯示當前所選外掛的配置介面。而在清除之前所選外掛的配置介面時,該外掛的相關配置資訊將會被快取下來:

private void lvExtensions_SelectedIndexChanged(object sender, EventArgs e)
{
    if (this.lvExtensions.SelectedItems.Count > 0)
    {
        var item = this.lvExtensions.SelectedItems[0];
        var extensionId = (Guid) item.Tag;
        this.BindExtension(extensionId);
    }
}

private void pnlSettings_ControlRemoved(object sender, ControlEventArgs e)
{
    if (e.Control.Tag != null)
    {
        var extension = e.Control.Tag as Extension;
        if (extension != null && extension.SettingProvider != null)
        {
            var setting = extension.SettingProvider.CollectedSetting;
            this.cachedSettings[extension.ID] = setting;
        }
    }
}

最後,當使用者單擊“確定”按鈕時,所有外掛的配置資料將被儲存下來:

foreach (var extension in this.extensionManager.AllExtensions)
{
    var settingProvider = extension.Value.SettingProvider;
    if (settingProvider != null)
    {
        settingProvider.PersistSettings();
    }
}

在下一篇關於外掛的開發文章中,我還會詳細介紹如何為自定義的外掛設計並實現配置功能。相信到那時讀者朋友應該對外掛系統的實現會有個更好的瞭解。

總結

本文首先展示了CloudNotes桌面客戶端新版本對外掛系統的支援,然後簡單介紹了CloudNotes桌面客戶端中外掛的分類,並通過Shell、外掛管理器和外掛配置三個部分,對外掛系統的設計與實現進行了必要的介紹。篇幅有限,沒有辦法在文章中對技術實現的每個細節進行完美解釋,讀者可以在參考原始碼的同時閱讀本文,如有問題可以直接留言。在接下來的文章中,我會介紹如何使用Visual Studio 2013/2015開發和除錯CloudNotes桌面客戶端的外掛。相信到那時候,讀者對外掛系統的設計與實現會有更深的認識。

相關文章