自研WPF外掛系統(沙箱執行及熱插拔)

qs2020發表於2024-05-19

前言

外掛化的需求主要源於對軟體架構靈活性的追求,特別是在開發大型、複雜或需要不斷更新的軟體系統時,外掛化可以提高軟體系統的可擴充套件性、可定製性、隔離性、安全性、可維護性、模組化、易於升級和更新以及支援第三方開發等方面的能力,從而滿足不斷變化的業務需求和技術挑戰。

一、外掛化探索

在WPF中我們想要開發一個外掛化的程式通常有兩種選擇,一種是MEF,另一種是MAF,它們有自己的優勢和劣勢,下面我們來分析一下。

1.1 MEF(Managed Extensibility Framework)

優點:
1.上手容易:使用相對簡單,開發人員可以透過簡單的屬性標記來定義和匯出元件,而不需要編寫大量的複雜程式碼。
2.輕量化:MEF 是一個輕量級的框架,它的效能開銷較小。
3.低耦合性:透過將應用程式拆分為多個獨立的外掛,每個外掛都負責實現特定的功能,降低了模組之間的耦合性。這使得程式碼更易於理解和維護,同時也降低了修改一個模組時對其他模組產生意外影響的風險。
4.並行開發:使用MEF,不同的開發團隊可以並行地開發不同的外掛,而無需擔心它們之間的依賴關係。每個團隊都可以專注於自己的功能實現,而無需等待其他團隊完成其工作。這可以顯著提高開發效率。
5.易於測試和維護:由於每個外掛都是一個獨立的單元,因此可以單獨對其進行測試和維護。這減少了測試和維護的複雜性,並使得在出現問題時能夠更快速地定位和解決問題。
6.易於擴充套件新功能:當需要新增新功能時,只需要開發一個新的外掛並將其新增到應用程式中即可。這避免了對整個應用程式進行大的修改和重新編譯的需要,從而縮短了開發週期並降低了成本。
缺點:
1.外掛隔離:無法支援外掛隔離,這意味著一旦其中一個外掛執行出現了問題會影響到整個應用程式。它也不能熱插拔,在執行時不能動態更新外掛。
2.生命週期:不支援外掛生命週期管理,不能細粒度控制外掛啟停。

1.2 MAF(Managed AddIn Framework)

MAF與MEF外掛一樣也擁有低耦合性、並行開發、易於測試和維護、易於擴充套件新功能等優點,當然它還有一些其它優點。
優點:
1.外掛隔離:MAF支援應用程式域及程序級的外掛隔離,外掛執行異常不會影響整個應用程式,當外掛需要更新時不需要重啟整個應用程式。
2.生命週期:MAF提供的了完善的生命週期管理,可以控制外掛的啟停解除安裝等操作。
3.外掛版本:MAF可以支援同時執行一個外掛的多個版本,這一特性可以實現外掛的動態回滾,一旦新外掛出現問題,可以瞬間回退到老版本。
缺點:
1.複雜性:MAF 的使用和配置相對複雜。開發人員需要理解應用程式域、外掛啟用、沙箱執行等概念,並且需要編寫相應的程式碼來管理外掛的載入和解除安裝過程。
2.效能開銷:由於每個外掛都在獨立的應用程式域中執行,因此可能會產生額外的效能開銷。特別是在載入大量外掛或頻繁載入外掛時,可能會影響到應用程式的效能。

1.3 總結

透過對比我們對外掛系統有了一個基本的認識,如果沒有外掛隔離執行的要求,那麼MEF是一個很好的選擇,它比較簡單,不需要理解複雜的理論,參照示例程式碼,很快就可以在專案中用起來。如果我們需要構建安全性更高,效能更好的應用程式,那麼選MAF就比較合適,但是MAF有一些很大的問題,比如就算實現一個很簡單的功能你也必須按照固定的專案結構來實現,靈活性較差,使用起來異常複雜,門檻很高。當應用程式達到一定規模以後,他的程式載入速度會是一個問題。這些缺點導致它在實際專案開發中選擇它的人屈指可數。
基於以上原因,我們需要一個融合了MEF與MAF特點的外掛系統,它應該是一個輕量級的框架並且效能不錯,有使用簡便、可擴充套件性強、安全可靠這些特性,這就是今天的主題。

二、系統設計

2.1 系統架構

2.2 啟動流程

2.3 詳細設計

2.3.1 容器

容器是外掛系統的核心,它提供了外掛探測、外掛載入、跨程序通訊服務、異常報告、訊息轉發、外掛生命週期管理等服務。

2.3.2 外掛啟動程式

它是一個控制檯應用程式,負責外掛的執行,具體有外掛配置檔案載入、向容器報告外掛異常資訊、外掛熱插拔等功能。

2.3.3 外掛

外掛是一個dll程式集或exe程式,該程式集或exe程式必須有一個類繼承自Plugin抽象類,以供容器探測外掛時被識別到。在外掛類中可以定義自己的UI(可以是任何FrameworkElement元素)或服務,以供容器呼叫。

三、例項分析

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.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>();
    }
    
    ...........
}

結束語:

該外掛系統可以讓我們以較低的成本使用沙箱執行、異常隔離、程序通訊等高階功能,透過這些高階功能我們可以解決軟體開發過程中的一些頑疾(比如記憶體佔用、多核利用率、未知問題引起的軟體崩潰等問題),同時它還賦予了我們無限的想象力,讓我們能夠以此為基礎構建出功能更加強大的軟體。

轉載自:https://www.cnblogs.com/qushi2020/p/18196259

相關文章