外掛化的需求主要源於對軟體架構靈活性的追求,特別是在開發大型、複雜或需要不斷更新的軟體系統時,外掛化可以提高軟體系統的可擴展性、可自訂性、隔離性、安全性、可維護性、模組化、易於升級和更新以及支援第三方開發等方面的能力,從而滿足不斷變化的業務需求和技術挑戰。
1. 外掛化探索
在 WPF 中我們想要開發一個外掛化的程式通常有兩種選擇,一種是 MEF,另一種是 MAF,它們有自己的優勢和劣勢,下面我們來分析一下。
1.1. MEF(Managed Extensibility Framework)
1.1.1. 優點
上手容易:使用相對簡單,開發人員可以透過簡單的屬性標記來定義和匯出元件,而不需要編寫大量的複雜程式碼。
輕量化:MEF 是一個輕量級的框架,它的效能開銷較小。
低耦合性:透過將應用程式拆分為多個獨立的外掛,每個外掛都負責實現特定的功能,降低了模組之間的耦合性。這使得程式碼更易於理解和維護,同時也降低了修改一個模組時對其他模組產生意外影響的風險。
並行開發:使用 MEF,不同的開發團隊可以並行地開發不同的外掛,而無需擔心它們之間的依賴關係。每個團隊都可以專注於自己的功能實現,而無需等待其他團隊完成其工作。這可以顯著提高開發效率。
易於測試和維護:由於每個外掛都是一個獨立的單元,因此可以單獨對其進行測試和維護。這減少了測試和維護的複雜性,並使得在出現問題時能夠更快速地定位和解決問題。
易於擴充新功能:當需要新增新功能時,只需要開發一個新的外掛並將其添加到應用程式中即可。這避免了對整個應用程式進行大的修改和重新編譯的需要,從而縮短了開發週期並降低了成本。
1.1.2. 缺點:
外掛隔離:無法支援外掛隔離,這意味著一旦其中一個外掛執行出現問題會影響到整個應用程式。它也不能熱插拔,在執行時不能動態更新外掛。
生命週期:不支援外掛生命週期管理,不能細粒度控制外掛啟停。
1.2. MAF(Managed AddIn Framework)
MAF 與 MEF 外掛一樣也擁有低耦合性、並行開發、易於測試和維護、易於擴充新功能等優點,當然它還有一些其他優點。
1.2.1. 優點
外掛隔離:MAF 支援應用程式域及進程級的外掛隔離,外掛執行異常不會影響整個應用程式,當外掛需要更新時不需要重啟整個應用程式。
生命週期:MAF 提供了完善的生命週期管理,可以控制外掛的啟停卸載等操作。
外掛版本:MAF 可以支援同時執行一個外掛的多個版本,這一特性可以實現外掛的動態回滾,一旦新外掛出現問題,可以瞬間回退到舊版本。
1.2.2. 缺點
複雜性:MAF 的使用和配置相對複雜。開發人員需要理解應用程式域、外掛啟用、沙箱執行等概念,並且需要編寫相應的程式碼來管理外掛的載入和卸載過程。
效能開銷:由於每個外掛都在獨立的應用程式域中執行,因此可能會產生額外的效能開銷。特別是在載入大量外掛或頻繁載入外掛時,可能會影響到應用程式的效能。
1.3 總結
透過對比,我們對外掛系統有了一個基本的認識,如果沒有外掛隔離執行的要求,那麼 MEF 是一個很好的選擇,它比較簡單,不需要理解複雜的理論,參照範例程式碼,很快就可以在專案中用起來。如果我們需要構建安全性更高,效能更好的應用程式,那麼選 MAF 就比較合適,但是 MAF 有一些很大的問題,比如就算實現一個很簡單的功能你也必須按照固定的專案結構來實現,靈活性較差,使用起來異常複雜,門檻很高。當應用程式達到一定規模以後,他的程式載入速度會是一個問題。這些缺點導致它在實際專案開發中選擇它的人屈指可數。
基於以上原因,我們需要一個融合了 MEF 與 MAF 特點的外掛系統,它應該是一個輕量級的框架並且效能不錯,有使用簡便、可擴展性強、安全可靠這些特性,這就是今天的主題。
2. 系統設計
2.1. 系統架構

2.2. 啟動流程

2.3. 詳細設計
2.3.1. 容器
容器是外掛系統的核心,它提供了外掛探測、外掛載入、跨進程通訊服務、異常報告、訊息轉發、外掛生命週期管理等服務。
2.3.2. 外掛啟動程式
它是一個控制台應用程式,負責外掛的執行,具體有外掛設定檔載入、向容器報告外掛異常資訊、外掛熱插拔等功能。
2.3.3. 外掛
外掛是一個 dll 程式集或 exe 程式,該程式集或 exe 程式必須有一個類別繼承自 Plugin 抽象類別,以供容器探測外掛時被識別到。在外掛類別中可以定義自己的 UI(可以是任何 FrameworkElement 元素)或服務,以供容器呼叫。
3. 實例分析
3.1. 容器的創建與配置
// 創建一個容器
var container = new Container();
// 配置參數
container.Configure(options =>
{
// 外掛目錄
options.PluginDirectory = "Plugins";
// 啟動外掛進程的超時時間
options.PluginProcessTimeout = 6000;
// 單個外掛是否允許多開
options.PluginAllowsMultipleInstances = false;
// 是否啟用熱插拔
options.IsEnableHotSwap = true;
// 顯示控制台
options.IsShowConsole = false;
});
// 註冊跨進程通訊服務
container.RegisterIpcService<RemotingService>();
// 外掛錯誤處理
container.PluginError += Container_PluginError;
// 啟動容器
container.Run();
3.2. 外掛執行效果

3.3. 多外掛隔離執行
每個外掛啟動後都是一個獨立的 exe 程式,它們執行不會相互影響。

3.4. 外掛異常
當外掛異常時外掛啟動進程會將異常資訊報告給容器,容器會將外掛卸載掉,並將是否重啟外掛的選擇權交給宿主程式。
3.4.1. 手動拋出異常

3.4.2. 除數為零異常

3.5. 外掛進程意外退出
外掛的執行狀態會被容器全過程監控,如果發現外掛進程被意外終止,容器會將資訊報告給宿主程式,由宿主程式決定是否重啟外掛。

3.6. 外掛的熱插拔
3.6.1. 執行時發現新外掛
預設只識別到了 4 個外掛,從另一個資料夾中複製一個外掛 dll 檔案到外掛目錄以後會通知宿主程式發現了新外掛,宿主程式可以決定是否要載入這個外掛。

3.6.2. 執行時刪除外掛
刪除外掛檔案時容器會接收到通知,但它並不會立即卸載外掛,而是將選擇權交於宿主程式,由宿主程式決定是否要卸載已刪除的外掛,如果宿主不想卸載,那麼已刪除的外掛可以繼續執行,工作不會被中斷。

3.6.3. 執行時更新外掛
外掛 1 為白色背景,外掛 1 的新版本為紅色背景,當用新版本替換舊版本後,容器會向宿主發送通知詢問是否要替換外掛。

3.7. 外掛間通訊
外掛通訊部分包含的內容有註冊訊息、接收訊息、發送訊息,訊息的接收與發送都只需要關注訊息類型,不需要關注發送者和接收者是誰,只要註冊了這個類型的訊息,一旦有這個類型的訊息就會接收到通知。外掛不僅可以和外掛通訊,也可以與宿主通訊。
3.7.1. 註冊訊息
以下程式碼註冊一個類型為 Notice 的訊息,並在註冊方法中傳入一個名為 ReceiveMessages 的回呼方法,在該方法中處理訊息接收。
plugin.ReregisterMessage<Notice>(ReceiveMessages);
3.7.2. 接收訊息
private void ReceiveMessages(Notice notice){
}
3.7.3. 訊息發送
plugin.SendMessage(notice);
3.7.4. 效果演示

3.8. 外掛未儲存提示
在宿主關閉外掛前可以根據外掛的狀態決定是否可以關閉,如果有未儲存的工作,可以通知宿主取消關閉外掛。

3.9. 外掛使用獨立的 App.config 檔案
每個應用程式預設只能載入一個與應用程式檔案名同名的設定檔,外掛可以創建自己的應用程式設定檔。

App.config
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<add key="setting1" value="value1" />
<add key="setting2" value="value2" />
</appSettings>
</configuration>
執行效果

3.10. 外掛多開
單個外掛允許同時執行多個實例可以在容器參數中配置。

3.11. 脫離宿主視窗執行

3.12. 跨進程通訊服務擴展
外掛系統預設使用 Remoting 的 IpcChannel 進行跨進程通訊,但是為了便於擴展,這裡並沒有直接把 Ipc 服務寫進容器,而是採用了開放性的設計,如果不想使用 IpcChannel,可以在創建容器以後註冊自己的 Ipc 服務。

4. 專案實戰
以下案例展示了外掛系統在一個有選單、工具列、文件的典型軟體中的應用。當外掛載入時,外掛中的選單、工具列、文件會被載入到宿主程式,當外掛意外終止或主動關閉時,外掛中的選單、工具列、文件會被自動卸載。

4.1. 選單
外掛中添加了兩個命令,分別是檔案選單下的「開啟」選單,檢視下的「文件檢視」選單,點擊選單後命令會轉發到外掛中執行。
private MSFCommand[] CreateCommands()
{
var openCommand = new MSFCommand(() => MessageBox.Show("選單"), () => true)
{
Id = Guid.NewGuid().ToString(),
Name = "開啟",
Type = "Menu",
Target = "MainWindow",
Location = "檔案(_F).開啟(_O)",
Order = 0
};
var editorViewCommand = new MSFCommand(() => MessageBox.Show("文件檢視"))
{
Id = Guid.NewGuid().ToString(),
Name = "文件檢視",
Type = "Menu",
Target = "MainWindow",
Location = "檢視(_V).文件檢視(_D)",
Order = 0
};
return new MSFCommand[]
{
openCommand,
editorViewCommand
};
}
4.2. 工具列
考慮到工具列的複雜性(可能會添加很多種型別的控制項),這裡並沒有使用命令來實現,而是將 Button 傳給了宿主程式。
internal class CopyButtonWrapper : IWrapper
{
private PluginContractElement contractElement;
public CopyButtonWrapper(DocumentViewModel documentViewModel)
{
var button = new Button()
{
Content = new Image { Width = 16, Height = 16, Source = new BitmapImage(new Uri("pack://application:,,,/EditorPlugin;component/Images/copy.png")) },
BorderThickness = new System.Windows.Thickness(0),
BorderBrush = Brushes.Transparent,
Command = documentViewModel.CopyCommand
};
contractElement = new PluginContractElement()
{
Id = Guid.NewGuid().ToString(),
Name = "複製",
Type = "ToolBar",
Order = 2,
Location = "MainWindow.ToolBar.Copy",
Description = "複製",
UIContract = new NativeHandleContractInsulator(button)
};
}
public PluginContractElement PluginContractElement => contractElement;
}
4.3. 文件檢視
文件是將一個 UserControl 傳遞給宿主程式。
internal class DocumentViewWrapper : IWrapper
{
private PluginContractElement documentContractElement;
public DocumentViewWrapper(DocumentView documentView)
{
documentContractElement = new PluginContractElement()
{
Id = Guid.NewGuid().ToString(),
Name = "文件",
Type="Document",
Location = "MainWindow.Document",
Description = "這是文件",
UIContract = new NativeHandleContractInsulator(documentView)
};
}
public PluginContractElement PluginContractElement => documentContractElement;
}
4.4. 依賴注入
實際專案中我們大多會使用 Prism 這種提供了依賴注入功能的框架,所以在設計時充分考慮了相容性,不管是在宿主中還是在外掛中都可以使用 Prism 這種框架。
public class EditorPlugin : PluginBase
{
private readonly DryIoc.Container container;
private readonly PluginContractElement[] _elements;
private readonly IMSFCommand[] _commands;
public EditorPlugin()
{
container = new DryIoc.Container();
RegisterTypes();
RegisterInstances();
_commands = CreateCommands();
_elements = CreateUIElement();
}
private void RegisterTypes()
{
container.Register<DocumentViewModel>();
container.Register<DocumentView>();
container.Register<PluginContractElementBuilder>();
container.Register<DocumentViewWrapper>();
container.Register<CopyButtonWrapper>();
container.Register<CutButtonWrapper>();
container.Register<PasteButtonWrapper>();
container.Register<SaveButtonWrapper>();
}
...........
}
5. 結束語
該外掛系統可以讓我們以較低的成本使用沙箱執行、異常隔離、進程通訊等高級功能,透過這些高級功能我們可以解決軟體開發過程中的一些頑疾(比如記憶體佔用、多核利用率、未知問題引起的軟體崩潰等問題),同時它還賦予了我們無限的想像力,讓我們能夠以此為基礎構建出功能更強大的軟體。