自研可熱插拔的wpf插件系統(msf)

自研可熱插拔的wpf插件系統(msf)

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

最后更新 2024/5/7 下午11:15
趋时软件
预计阅读 12 分钟
分类
WPF
标签
.NET WPF 架構設計 安全 插件系統

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

1. 插件化探索

在 wpf 中我們想要開發一個插件化的程式通常有兩種選擇,一種是 mef,另一種是 maf,它們有自己的優勢和劣勢,下面我們來分析一下。

1.1. MEF(Managed Extensibility Framework)

1.1.1.優點

  1. 上手容易:使用相對簡單,開發人員可以通過簡單的屬性標記來定義和導出組件,而不需要編寫大量的複雜代碼。

  2. 輕量化:mef 是一個輕量級的框架,它的性能開銷較小。

  3. 低耦合性:通過將應用程式拆分為多個獨立的插件,每個插件都負責實現特定的功能,降低了模塊之間的耦合性。這使得代碼更易於理解和維護,同時也降低了修改一個模塊時對其他模塊產生意外影響的風險。

  4. 並行開發:使用 mef,不同的開發團隊可以並行地開發不同的插件,而無需擔心它們之間的依賴關係。每個團隊都可以專注於自己的功能實現,而無需等待其他團隊完成其工作。這可以顯著提高開發效率。

  5. 易於測試和維護:由於每個插件都是一個獨立的單元,因此可以單獨對其進行測試和維護。這減少了測試和維護的複雜性,並使得在出現問題時能夠更快速地定位和解決問題。

  6. 易於擴展新功能:當需要添加新功能時,只需要開發一個新的插件並將其添加到應用程式中即可。這避免了對整個應用程式進行大的修改和重新編譯的需要,從而縮短了開發周期並降低了成本。

1.1.2.缺點:

  1. 插件隔離:無法支持插件隔離,這意味著一旦其中一個插件運行出現了問題會影響到整個應用程式。它也不能熱插拔,在運行時不能動態更新插件。

  2. 生命周期:不支持插件生命周期管理,不能細粒度控制插件啟停。

1.2. MAF(Managed AddIn Framework)

maf 與 mef 插件一樣也擁有低耦合性、並行開發、易於測試和維護、易於擴展新功能等優點,當然它還有一些其他優點。

1.2.1.優點

  1. 插件隔離:maf 支持應用程式域及進程級的插件隔離,插件運行異常不會影響整個應用程式,當插件需要更新時不需要重啟整個應用程式。

  2. 生命周期:maf 提供的了完善的生命周期管理,可以控制插件的啟停卸載等操作。

  3. 插件版本:maf 可以支持同時運行一個插件的多個版本,這一特性可以實現插件的動態回滾,一旦新插件出現問題,可以瞬間回退到老版本。

1.2.2.缺點

  1. 複雜性:maf 的使用和配置相對複雜。開發人員需要理解應用程式域、插件激活、沙箱執行等概念,並且需要編寫相應的代碼來管理插件的加載和卸載過程。

  2. 性能開銷:由於每個插件都在獨立的應用程式域中執行,因此可能會產生額外的性能開銷。特別是在加載大量插件或頻繁加載插件時,可能會影響到應用程式的性能。

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.跨進程通訊服務擴展

插件系统默认使用RemotingIpcChannel进行跨进程通讯,但是为了便于扩展,这里并没有直接把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. 結束語

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

Keep Exploring

延伸阅读

更多文章
同分类 / 同标签 2025/1/26

wpf 藉助自定義 xml 文件實現國際化

本文詳細居間了在wpf程式中使用自定義xml文件實現國際化的方法,包括安裝必備nuget包、動態獲取語言列表、動態切換語言、在代碼和xaml界面中使用翻譯字符串等內容,同時提供了源碼連結,幫助開發者輕鬆實現wpf應用的國際化。

继续阅读