使用基于插件的应用程序
托管服务扩展框架 (MSEF) 简介
有一个关于此主题的简短教程,在 YouTube (/watch?v=pvXi1lbLz-s)
提示!
请非常仔细地阅读本文,因为有些人试图使用它但并未理解其工作原理。例如:在某些情况下,您被迫将 VS 设置为在每次生成时重新编译所有 DLL!这很重要,因为启动项目和包含 DLL 的模块之间的依赖关系松散,导致 VS 可能不会再次生成它。要启用此行为,请转到工具 - 项目和解决方案 -> 生成和运行,然后将“运行项目过期时”选项设置为“始终生成”。(需要重启 Visual Studio)
所需知识
我希望您了解您的编程语言。您应该了解类继承、一些基本的控制反转和依赖注入概念。
存储库
存储库包含 4 个分支。
主干
JPB.Shell.exe
-
Contracts.DLL
-
Shell 接口和属性宿主
-
-
MEF.DLL
-
框架本身
-
以及 3 个示例
它们都依赖于主要的 Nuget 包。
控制台
- JPB.Shell.Example。
- Console.exe
- 启动应用程序
- BasicMathFunctions.DLL
- 简单计算器的实现
- Contracts.DLL
- 应用程序指定接口
- Console.exe
控制台应用程序应帮助您了解基于插件的应用程序的设计方式以及该框架通常如何在没有 UI 特定相关性的情况下使用
Foo
Contains
- JPB.Shell。
- exe
- 包含加载机制,但仅此而已
- CommonApplicationConatiner.DLL
- 包含通用的 Ribbon UI,它加载其他服务
- CommonContracts
- 仅用于接口... 它扩展了 IService 接口以扩展功能
- VisualServiceScheduler
- 一个 WPF MVVM 模块,提供一个可在任何其他应用程序中使用的表面,而无需重新构建
- exe
- JPB.Foo。
- Client.Module.DLL
- 非常简单的可视化模块
- ClientCommenContracts
- JPB.Shell.Contracts 的扩展
- CommenAppParts
- 扩展接口的单个示例实现
- Client.Module.DLL
此解决方案应向您展示一个包含如何构建完整的 WPF 基于插件的应用程序的示例应用程序。基本应用程序与某些 Web 项目或表单相同。您通过 MSEF 提供模块,然后通过访问它们的属性来加载它们。
引言
构建、维护和实际使用基于插件的应用程序对我们许多开发人员来说是新的。但在我们快速变化的需求和周围的开源项目的世界里,几乎每个开发人员都会在职业生涯中遇到它。即使这只是项目中的一个瞬间,当我们想到“嗯,这在将来会改变,让我们使用接口来确保工作量不会太大”的时候。为此,我将向您介绍一种将这种方法提升到一个新水平的方法。让我们谈谈一些流行词,如依赖注入和控制反转 (IoC)。CodeProject 上有一些很好的文章,所以我不会深入解释它们,只是为了确保我们说的是同一件事。依赖注入意味着我们将特定功能的责任从实际执行者那里移开。我们将一些逻辑注入到一个不知道实际发生了什么类的对象中。这种方法的主要限制是,大多数情况下目标执行者必须支持这种行为。至少,如果类不是为了允许这种编码模式而设计的,您就不应该使用它。如果您试图控制一个未设计为允许的进程,您很可能会破坏所需的代码结构。控制反转就像依赖注入一样是一种编码模式,旨在将进程从一个执行者转移到另一个执行者。正如名称所示,它允许将进程的控制权从原始执行者转移到其他执行者。这适用于委托用于工厂创建的简单用法,也适用于完整的 WPF 框架。我们将放弃对如何执行某事的控制。
背景
我创建此框架的原因非常简单:我遇到了创建程序的麻烦,该程序最初开发得很快,但后来,令我和利益相关者惊讶的是,它变得非常庞大。
最后,我们得到了一个程序,它非常大,几乎无法维护。我们决定重新开发,由于应用程序中包含如此多的不同功能(我们不希望包含所有这些功能),我们也决定允许这种“疯狂”的功能。
然后,某种插件/模块驱动应用程序的想法在我脑海中诞生。我做了一些研究,在 .net 框架中找到了托管扩展框架 (MEF)。但很快我发现它的局限性。在我看来,它对于这种“简单”的工作来说太慢了。我开始扩展 MEF 并编写了一些管理器类。我今天在这里探索的成果就是我的努力。由于它最初是为 UI 相关应用程序设计的,我们很快发现了我代码的全部优势。代码简单且有用,有两个作用。首先,它通过使用 Plinq 和其他一些优化来加速枚举过程。其次,它限制了访问。您现在可能会问,为什么这会是一件好事?MEF 为您提供了大量“额外”功能和一种“核武库”配置。对于一个不知道依赖注入和 IoC 的开发人员来说,这太多了。因此,限制配置并隐藏一些您不必担心的东西,正是正确的方法。一些配置已更改为更集中的方式。
MSEF
托管服务扩展框架 (MSEF) 基于 MEF 并扩展了 MEF 的使用和访问。它将所有 MEF 导出包装成可以通过其方法访问的服务。一个类代表一个或多个服务。当一个类定义一个或多个服务时,它不必实现其功能。在最坏的情况下,这会违反 OOP 原则。
结构
我们想要管理的每个共享代码都必须包装在一个程序集中。该程序集包含继承自IService
的类。IService 最有可能是一个标记接口,只有一个启动方法定义,该方法将在服务启动时调用。框架将访问的每个服务都将被视为单例。因此,技术上每个服务定义应该只有一个实例。您可以使用 new() 创建自己的实例,但 MSEF 不会观察它们。
namespace JPB.Shell.Contracts.Interfaces.Services
{
public interface IService
{
void OnStart(IApplicationContext application);
}
}
当我们谈论实现时,IService 接口是第一个重要的事情。但搜索继承会很慢。因此,我们需要像属性这样的附加项。NET 属性是向类添加元数据的理想方式。基础属性是ServiceExportAttribute
public class ServiceExportAttribute : ExportAttribute, IServiceMetadata
IServiceMetadata
属性包含一些附加信息。public interface IServiceMetadata
{
Type[] Contracts { get; }
string Descriptor { get; }
bool IsDefauldService { get; }
bool ForceSynchronism { get; }
int Priority { get; }
}
正如我所说,一个类可以用于定义一个或多个服务。为了将此信息维护给外部,而无需分析类本身,必须将信息写入属性。为此,使用 Contracts 数组。对于集合中的每种类型,定义的类将被视为该类型。
此代码的每个方面都是可扩展的。如果代码不适合您的需求,只需扩展或包装它。只需提供这些基本信息即可。
这个已实现接口的数组有利弊。优点是它非常快,无需从程序集中加载类然后分析它。但这也是一个很大的缺点。您必须重复定义服务“声明”。在元数据和实际类中。如果两者不匹配,则有两种可能的情况。您在代码中实现了一个类,但未在元数据中定义它。这不算太糟,因为最坏的情况是 MSEF 找不到它。不好,但不是世界末日。更危险的是,您在元数据中定义了一个接口,但未实现它!如果您声明了一个接口但未实际实现它,任何编译器都不会抛出错误。在这种情况下,代码会编译,但在运行时会引发异常,这实际上非常糟糕。我计划创建一个Roslyn扩展来处理这些问题,但这可能需要一些时间。
使用代码
正常的用法如下
您通过创建继承自IService
的接口来定义某种服务。
public interface IFooDatabaseService : IService
{
//CRUD
void Insert<T>(T entity);
T Select<T>(string where);
void Update<T>(T entity);
bool Delete<T>(T entity);
}
这就是您的服务定义。您可以随意扩展此接口,甚至可以继承自其他接口,或者用另一个服务扩展此服务。只要它继承自IService
,所有这些都是可能的。为了使服务可用,您必须在某个 DLL 中实现它,并使用ServiceExport
属性进行标记。
[ServiceExport(
descriptor: "Some name",
contract: new[]
{
typeof(IFooDatabaseService),
//Additional Services like services that are inherit
}
)]
class FooDatabaseService : IFooDatabaseService
{
public void Insert<T>(T entity)
{
throw new NotImplementedException();
}
public T Select<T>(string where)
{
throw new NotImplementedException();
}
public void Update<T>(T entity)
{
throw new NotImplementedException();
}
public bool Delete<T>(T entity)
{
throw new NotImplementedException();
}
public void OnStart(IApplicationContext application)
{
throw new NotImplementedException();
}
}
您可能注意到该类没有访问修饰符,因此默认情况下它是私有的。这是可能的,但非常糟糕。由于 MEF 仅使用反射,它可以并且会违反 OOP 原则。所以请记住,这里不适用这些原则,并且仅在编译时强制执行。
我们现在已经创建了所有必要的东西。我们扩展了 IService 接口以允许我们自己的服务实现,并使用ExportService
属性将该类标记为服务。下一步是枚举服务并使用该服务。
要启动初始过程,我们必须首先创建处理器。该过程由 ServicePool 类处理。
[DebuggerStepThrough]
public class ServicePool : IServicePool
{
internal ServicePool(string priorityKey, string[] sublookuppaths)
}
它只有一个内部构造函数,并且只能通过 ServicePoolFactory 创建。
namespace JPB.Shell.MEF.Factorys
{
public class ServicePoolFactory
{
public static IServicePool CreatePool()
{
return CreatePool(string.Empty, Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location));
}
public static IServicePool CreatePool(string priorityKey, params string[] sublookuppaths)
{
var pool = new ServicePool(priorityKey, sublookuppaths);
ServicePool.Instance = pool;
if (ServicePool.ApplicationContainer == null)
ServicePool.ApplicationContainer = new ApplicationContext(ImportPool.Instance, MessageBroker.Instance, pool, null, VisualModuleManager.Instance);
pool.InitLoading();
return pool;
}
public static async Task<IServicePool> CreatePoolAsync(string priorityKey, params string[] sublookuppaths)
{
return await new Task<IServicePool>(() => CreatePool(priorityKey, sublookuppaths));
}
}
}
该工厂负责所有内部属性和InitLoading.
的初始调用。
另一种方法是访问静态ServicePool.Instance
属性,这与调用 CreatePool 相同。
Service pool 将通过使用优先级键来定义具有 1 级优先级的程序集,开始枚举给定路径中的所有文件。这些文件被视为您的程序“必需”的。它们应该包含所有基本逻辑,例如可视化应用程序中的窗口或其他启动过程(稍后详细介绍)。过程将如下进行
假设
PriorityKey = "*Module*"
- 枚举 shell 目录中的所有 DLL
- 搜索名称中的特定部分,并将它们标记为高优先级,以提高性能(这只是意味着名称为“FooClientModule.DLL”的 DLL 在启动时加载,而名称为“FooClient.DLL”的 DLL 则不加载!)
- 搜索这些高优先级程序集以导出并将它们添加到我的
StrongNameCatalog
中,跳过所有非高优先级程序集 - 等待 3 完成
- 启动默认服务
IApplicationContainer
- 主窗口打开
- 将搜索所有不带高优先级标志的程序集以导出
处理IApplicationContainer
。
此接口由框架本身提供,并指示一个必须启动的服务(当它可用时)。如果枚举过程完成,将实例化此服务。对于此过程,ServiceExport 属性包含可选参数ForceSyncronism
和Priority
。如果IApplicationProvider
未提供此信息,则ForceSyncronism
将为False
,优先级将非常低。第一个参数非常重要,因为它会阻塞调用者,直到有服务未执行。
- 枚举所有标记为异步执行的
IApplicationProvider
并无观察地启动它们 - 在调用线程中同步执行所有标记为同步执行的服务
当使用相互依赖的多个应用程序时,此信息很重要。当 ApplicationSerivce A 尝试从 ApplicationService B 加载数据但服务 B 未加载时,它将失败并导致奇怪的行为,请记住,仅仅因为它在您的机器上有效,并不意味着它在所有机器上都有效。这是由于多线程和任务处理的巨大影响。每台机器自行决定如何处理任务和线程。
IApplicationProvider 的用法和含义。
这个想法很简单。只要我们有一个只通过接口松散连接的应用程序,就没有启动机制。为了支持这种来自调用者的无观察启动,我们使用此接口。有用的实现可能是从数据库拉取数据的服务,该数据库在启动时必须可用。因此,当我们回顾FooDatabaseService
时,它提供了一种访问数据库和预加载请求数据的方法。
但这给我们带来了基于服务的应用程序的下一个问题。服务之间的通信是一个非常复杂的问题。只要我们不能真正期望某个服务存在,框架就会带来自己的。这种通信是在 IService 接口内部实现的。
public interface IService
{
void OnStart(IApplicationContext application);
}
每个服务都包含此启动逻辑并处理 IApplicationContext。这些接口为我们提供了基本功能,如 DataStorage(DataBroker)、DataCommunication(MessageBroker)、ServiceHandling(ServicePool) 等。想法是服务彼此隔离,并且与应用程序的主逻辑隔离。它们无法了解您,您也不知道它们。因此,框架提供了一种通信方式。
public interface IApplicationContext
{
IDataBroker DataBroker { get; set; }
IServicePool ServicePool { get; set; }
IMessageBroker MessageBroker { get; set; }
IImportPool ImportPool { get; set; }
IVisualModuleManager VisualModuleManager { get; set; }
}
IDataBroker
为您的服务提供了一个集中的接口,用于以持久的方式存储数据。在当前的开发状态下,这是唯一一个默认值为 null 的 Context 属性。如果您想为服务提供 DataBroker,则需要从外部或从您的某个服务设置它。一种可能的解决方案是
[ServiceExport(
descriptor: "AppDataBroker",
isDefauld: false,
forceSynchronism: true,
priority: 0,
contract: new[] {
typeof(IDataBroker),
typeof(IApplicationProvider)
})]
class FooDataBroker : IDataBroker, IApplicationProvider
{
#region IDataBroker Implementation
...
#endregion
/// <summary>
///
/// </summary>
/// <param name="application"></param>
void Contracts.Interfaces.Services.IService.OnStart(IApplicationContext application)
{
//Do some Initial things like open a database and pull application settings
...
//add yourself as the DataBroker
application.DataBroker = this;
}
}
从顶部开始:我们定义了 Export 属性来将此类标记为服务。强制同步和优先级为 0 以在所有其他接口之前加载(取决于您的应用程序逻辑,此服务也可能依赖于另一个)。至少我们定义了 2 个要导出的接口。首先是 IDataBroker,所以如果其他组件请求此接口(也继承自 IService),它们将获得这个,其次是 IApplicationProvider,以便我们尽快被调用。
下一件事是 IMessageBroker,它允许我们将数据从一个客户传输到另一个客户。它有一个标准实现,允许任何人基于类型作为键将自己添加为消费者。然后,如果另一个消费者发布的数据是键的类型,所有消费者都将收到通知。
public interface IMessageBroker
{
void Publish<T>(T message);
void AddReceiver<T>(Action<T> callback);
}
另一种有用的实现可能是一个服务,它检查 T 是否是某种特殊消息,如果是,它就可以发布消息,而不是在本地发布到 WCF 服务,并将消息传播到其他客户。这可以将您本地应用程序从基于插件的扩展到远程应用程序。
由于没有人知道调用者,因此没有人能够使用应用程序的其他部分。对于这种情况,每个服务都必须知道全局 ServicePool。为了摆脱每个服务与 MSEF 框架处理器之间的依赖关系,ServicePool 包含在 ApplicationContext 中。有了这个基础设施,调用者就不知道任何服务,我们将所有逻辑集中在 ServicePool 中。就像好莱坞原则“别找我们,我们找你”一样,我们实现了对所有逻辑的绝对清晰的抽象。
但这也有一个缺点。如果您的应用程序中除了 .exe 中的原始启动器之外,没有人知道 IServicePool,他们该如何查询它?此时,开发人员就派上用场了。没有“必须这样做”的解决方案,只有一个限制。为了维护您的插件方法,请不要直接引用 JPB.Shell 或您的 exe,反之亦然。
引用存储Contracts.Interfaces.Services.IService.OnStart(IApplicationContext application)
这很简单。对于包含像您的
FooDatabaseService
这样的服务的每个 DLL,创建一个第二个服务并将其命名为 Module。此服务是IApplicationProvider
,并将IApplicationContext
存储在静态变量中。由于 Module 服务将始终在所有其他服务之前调用,因此该变量(我们称之为 Context)永远不会为 null,您始终拥有 MSEF 的有效引用,而无需知道调用者或 MSEF DLL。
最后但并非最不重要的一点是,我们将讨论最基本的部分,即直接调用 IServicePool。例如,我们如何从框架获取服务。
//Module is my VisualModule that is invoked before
var infoservice = Module
//Context is my static variable of IApplicationContext
.Context
//ServicePool is simply the current instance of ISerivcePool
.ServicePool
//GetSingelService gets a singel service of the requested Interface it has the FirstOrDefauld behavior
.GetSingelService<IFooDatabaseService>();
//Check if null
if (infoservice == null)
return;
//For example get the last Entry in the ImportPool that logs every actively that is done be ServicePool
var logEntry = Module.Context.ImportPool.LogEntries.Last();
//Call a Function on that service that we got
//in that case we want to insert the Log into the DB
infoservice.Insert(logEntry);
元数据和属性
有两种使用元数据的方法。基于服务实现和基于服务本身。每个服务都可以通过使用 ServiceExport 及其属性来定义自己的元数据,或者服务(由其接口表示)可以定义一些标准属性,这些属性将应用于所有继承的服务。
在这种情况下,服务接口不包含任何元数据,实现自己定义元数据。
在这种情况下,我们将元数据从实现反转到服务的声明。
这提供了以下用法
public class FooMessageBox : IFooMessageBox
{
Contracts.Interfaces.IApplicationContext Context;
public void showMessageBox(string text)
{
MessageBox.Show("Message from Service: " + text);
}
public void OnStart(Contracts.Interfaces.IApplicationContext application)
{
Context = application;
}
}
在这种情况下,我们可以跳过类级别的元数据声明,并将元数据移至接口。
[InheritedServiceExport("FooExport", typeof(IFooMessageBox))]
public interface IFooMessageBox : IService
{
void showMessageBox(string text);
}
这有利有弊
Good
我们无需关心元数据,因为我们在创建接口时已经处理过一次。
错误
我们无法再控制或修改元数据。由于属性需要常量值,IFooMessageBox
的每个实现都提供相同的元数据,对于框架来说,它们都相同。因此,我们遇到了这个问题
您可以看到,由于我们使用了InheritedServiceExportAttribute
,我们不知道谁是谁,因为它们都声明了相同的元数据。
使用 UI 代码
对于使用插件来扩展其表面和逻辑的 UI 应用程序来说,没有明显的好处。即使是出于这个原因,框架最初也是为此设计的。我谈了很多关于如何普遍使用框架,现在我将向您介绍 UI 应用程序的可能用法。
通过设置一个全新的项目,您需要一种启动逻辑来调用模块和目录的枚举。因此,即使在创建新的控制台应用程序时,您也需要启动逻辑。但让我们更具体一些。正如它所设计的那样,该框架包含一个易于使用的服务,适用于使用 MVVM 的应用程序。
它包含一个接口IVisualService
和一个服务IVisualModuleManager
。两者都构建以支持 MVVM。
IVisualModuleManager
由VisualModuleManager
实现,它很可能是一个ServicePool
的包装器,用于过滤IVisualService
。
一种可能的用法是,可执行文件调用ServicePool
并启动进程。DLL 包含一个IApplicationProvider
,它将显示一个带列表的窗口。然后,ApplicationProvider 将使用给定的VisualModuleManager
来请求所有IVisualServiceMetadata
实例,并将它们填充到 Listbox 中。由于我们只查询元数据,因此不会创建实际的服务!这对性能有极大的影响。选择列表框中的项目时,AppProvider 将调用VisualModuleManager
来基于给定的元数据创建一个IVisualService
。然后将创建另一个服务,我们可以通过调用IVisualService
的 View 属性来访问可视化元素。最后,VisualServiceManager
需要一些继承的元数据,称为VisualServiceExport
。如果您愿意,可以扩展元数据或IVisualService
以添加您自己的属性或函数。
GitHub 上有一个带有 Ribbon 的合适示例。
在您开始之前
为确保您的应用程序和模型进入同一个文件夹(这非常重要,因为 MEF 如何知道您的 DLL 在哪里?;-),请将 BuildPath 设置为与 Shell 相同(对于 Release,默认为“..\..\..\..\bin\VS Output\”,对于 Debug,默认为“..\..\..\..\bin\VS Debug\”。然后确保将“运行项目过期时”选项设置为“始终生成”。另外请记住,MEF 不支持从 .exe 加载服务。因此,只支持 .DLL。
您应该了解的功能
有两个预处理器指令控制着一些行为。
#define PARALLEL4TW
允许 StrongNameCatalog 使用 PLinq 来搜索并将程序集包含到目录中,混合使用立即加载和延迟加载。依我看,您不应该在您的版本中禁用它,因为它经过测试、安全且非常快。仅出于调试目的才有意义。
#define SECLOADING
正如此链接所述,目录包含一种保证少量安全的方法。我从未用过它,所以我无法对此发表评论。
可能的情景
有很多可能的情景。我将解释一些,以给您一些关于系统如何工作以及它适用于哪种工作的想法。我们将从一个执行一些工作的简单应用程序开始。例如计算器,它可以进行数字乘法。由于系统是使用一些接口逻辑设计的,因此它包含一个名为ICalculatorTask
的接口。此接口仅定义一个名为Calculate()
的方法。此方法只接受字符串类型的两个参数。现在有两种方法可以实现这种插件方法。
1. 重新实现 ICalculatorTask 并继承自 IService
这种方法将导致所有现有和未来的任务被“识别”为服务。这有时并非完全是我们想要的。这可能是简单的方法,有时也是最好的方法,但这取决于您的编码风格。通过实现IService
然后定义 export 属性,我们可以一次性加载所有接口,而无需任何依赖。
2. 继承自 ICalculatorTask 并创建一个服务适配器
这种方法将使用适配器模式,并将所有服务功能包装到一个自己的类中。如果您不想修改现有代码并阻止目标类知道它们实际上被用作服务,这将很有用。为此,我们将创建一个名为CalculatorWrapper
的类。此基类将接受ICalculatorTask
实例并将所有任务委托给它。然后我们继承这个包装器类,并创建一个名为 Multiply 或任何确切实例的新类。
这个简单的场景使用基于插件的系统是一个好主意。
关注点
我在创建这个项目时非常开心,我认为值得与我的开发人员以及所有对此感兴趣的人分享这个想法。当我开始使用 .NET 4.0 中的 MEF 时,我被搞得抓狂,因为纯 MEF 系统非常复杂。我维护这个项目已经一年多了,仍然有想法如何扩展或改进它。
我非常欢迎您提出任何新想法和看法。
我也想听听/看看您用它制作的应用程序。
请在此处与我联系。提前感谢。
Github
正如用户 John Simmons 所建议的,我清理了存储库,并从主干解决方案中删除了所有 WPF 特定的内容。现在它只包含 2 个主要程序集
- JPB.Shell.MEF
- JPB.Shell.Contracts
帮助我!
由于我非常希望提高我的技能和代码质量,因此我创建了一个表单来直接接收您的输入。如果您正在使用此项目,请随时填写,只需一分钟。
https://docs.google.com/forms/d/1LX68Simcv8naq_hOf7jkOSKbT_QysE0jqMSM_appG2s/viewform?usp=send_form
历史
V1:初始创建
V1.1:次要 bug 修复,例如
- 修复了 MEF 容器 bug,该 bug 导致 IService 方法 OnLoad 可能被多次调用
- 修复了 IServicePool 的 INotifyPropertyChanged 实现的不当使用
- 由于第一次调用,所有服务都被包装在一个新的 Lazy 实例中,该实例将负责 OnStart。
V2 次要更改
- 从主干中删除了不必要的内容
- 制作了一个控制台示例
- 已添加为 Nuget 包(参见顶部)
V3 更改
重写了 CodeProject 上的文章
V3.1 热修复
- 向 Master 添加了一个异常,在未提供有效路径时触发
- 将 RibbonWindow 安装程序添加到 WPF 分支。请在尝试执行示例之前运行它
新功能
- IImportPool 中用于加载回调的新功能