使用托管扩展框架 (MEF) 构建模块化控制台应用程序
本文将引导您逐步构建一个相当大的控制台应用程序,该应用程序利用托管扩展框架 (MEF) 的原则,使其具有模块化和可扩展性。
目录
- 问题描述
- 背景
- 可能的实现方法
- 托管扩展框架 (MEF)
- MEF 中的类惰性加载
- 一个简单的控制台 EXE,它使用命令行参数和 MEF 来识别任务处理器
- 通过实现简单的帮助系统来扩展控制台 EXE
- 在单独的程序集中实现任务
问题描述
您正在开发一个命令行实用程序。这可能是一套用于支持企业应用程序管理的自定义批处理作业。您的命令行实用程序需要执行多项后端任务,并且每项任务都通过特定于任务的参数进行参数化。如果我们的需求非常明确且有限,这似乎并不构成挑战。我的经验告诉我,任何软件在开始时都很简单,但随着时间的推移会呈指数级增长。您的最终用户会要求更多功能,很快,您就会面临管理一个非常复杂的软件的开发和交付的艰巨任务。
如果您正在构建一个企业应用程序管理工具,那么您的用户将是您组织中支持 IT 运营的人员,或者如果您是一家像 Github 或 Aws/Azure 这样的公司,那么用户将是数百万开发人员。在本文中,我将通过利用微软的托管扩展框架 (MEF) 来解决上述问题。
示例 - 自定义后端管理工具
Util.exe --task Backups --from 01/01/2019 --to 30/06/3019 --destination c:\Back\
Util.exe --task IndexDocuments
Util.exe --task ExtractImages -destination c:\dump\
示例 - Github
git config –global user.name "[name]"
git commit -m "[ Type in the commit message]"
git diff –staged
git rm [file]
git checkout -b [branch name]
示例 - Azure CLI
az group create --name myResourceGroup --location westeurope
az vm create --resource-group myResourceGroup --name myVM --image UbuntuLTS --generate-ssh-keys
az group delete --name myResourceGroup
背景
必须具备 .NET 和 C# 知识。了解托管扩展框架 (MEF) 会有所帮助。
可能的实现方法
方法 1 - 自定义 PowerShell Cmdlets
PowerShell 是一个出色的框架,可以通过编写自定义 .NET 模块轻松扩展。Azure 和 AWS 都提供了 PowerShell 接口来与各自的云基础设施进行交互。PowerShell Cmdlets 是继承自 CmdletBase
的简单 C# 类。PowerShell Cmdlets 提供了两全其美的好处——以 Visual Studio .NET 的形式提供强类型开发环境,以及一个出色的脚本平台,成为您 Cmdlet(类库)的客户端。
方法 2 - 每个任务使用多个命令行应用程序
这是一种非常简单的方法。如果您的需求较小且时间紧迫,它会很有效。
方法 3 - 单个命令行应用程序,其中每个任务都是一个插件
在本文中,我将重点介绍第三种方法。一个单一的命令行应用程序,可以执行各种任务,并且每个任务都封装在其自己的类中,然后在后期阶段,任务实现被物理地隔离到程序集中。
Util.exe --task Backups --from 01/01/2019 --to 30/06/3019 --destination c:\Back\
Util.exe --task IndexDocuments
Util.exe --task ExtractImages -destination c:\dump\
简要介绍托管扩展框架 (MEF)
MEF 是构建在 Microsoft .NET Framework/.Core 之上的一个库,它简化了基于插件的应用程序的开发。MEF 可以被视为一个依赖注入框架,能够跨程序集分区发现依赖项。MEF 为将主应用程序与实现解耦提供了可能性。您可以在此处找到微软关于 MEF 的文档。MEF 解决了在软件开发生命周期中经常出现的一些非常重要的问题:
- 在应用程序发布后,是否可以在不重新编译整个代码库的情况下对其进行扩展?
- 应用程序是否可以设计成这样,以便应用程序可以在运行时而不是编译时绑定来查找其模块?
- 通过添加新模块/插件,是否可以轻松扩展您的应用程序?
步骤 1 - 设计契约
public interface IMyPlugin
{
void DoSomeWork()
}
步骤 2 - 实现实现您的契约的各种插件类
///
///Class1 in Assembly1
///
[Export(typeof(IMyPlugin))]
public class Plugin1 : IMyPlugin
{
}
///
///Class2 in Assembly2
///
[Export(typeof(IMyPlugin))]
public class Plugin2 : IMyPlugin
{
}
步骤 3 - 设计主机应用程序以接受发现的实现
///
///Host application
///
public class MyHost
{
[ImportMany(typeof(IMyPlugin))]
IMyPlugin>[] Plugins {get;set;}
}
步骤 4 - 使用 MEF 的 Catalog 类发现插件
///
///TO BE DONE - Show snippets of catalog here
///
MEF 中的类惰性加载
您可以让 MEF 延迟实例化插件类。MEF 使用 Lazy
类来发现实现并保存插件元数据的引用。实例化仅在需要时进行。Lazy
类允许插件导出元数据。例如,插件的唯一名称。
///
///Plugin class with metadata
///
[Export(typeof(IMyPlugin))]
[ExportMetadata("name","someplugin2")]
[ExportMetadata("description","Description of someplugin2")]
public class Plugin2 : IMyPlugin
{
}
ExportMetadata
属性在这里起着至关重要的作用。当 MyHost
类使用 MEF 进行组合时,每个可调用插件类的 Lazy
实例中的 Dictionary
对象将填充键 name
、description
以及它们各自的值。请记住——此时 plugin
类尚未实例化。
///
///Host application - with lazy loading of plugins
///
public class MyHost
{
[ImportMany(typeof(IMyPlugin))]
Lazy<IMyPlugin,Dictionary<string, object>>[] Plugins {get;set;}
}
第一部分 - 一个简单的控制台 EXE,它使用命令行参数和 MEF 来识别任务处理器
概述
在本小节中,我们将开发一个简单的 EXE,该 EXE 被模块化为 Task
处理器类,并且这些类驻留在可执行文件本身中。
就命令行参数的标准系统达成一致
在本文中,我们将我们的命令行应用程序命名为 _MefSkeletal.exe_,第一个参数将是任务的短名称。所有后续参数都将是特定于任务的参数。
Myutil.exe [任务名称] [任务参数 1] [任务参数 2]
MySkeletal.exe task1 arg0 arg1 arg3
MySkeletal.exe task2 arg5 arg6
MySkeletal.exe task3
.NET Core EXE
创建一个 .NET Core EXE 项目 MefSkeletal
。目前,我们将遵循一种简单的方法,即所有任务处理器类都包含在 EXE 项目中。在后期,我们将重构解决方案,以便每个任务包含在一个单独的类库项目中。
契约接口
创建一个 _Contracts_ 子文件夹并创建一个类文件 _ITaskHandler.cs_
///
///Every Task handler must implement this interface
///
public interface ITaskHandler
{
void OnExecute(string[] args)
}
Nuget 包
添加对以下包的引用
Install-Package System.ComponentModel.Composition
-Version 4.5.0
创建任务处理器类
我们将添加特定于任务的处理器类。创建一个 _Tasks_ 子文件夹并将以下类添加到该子文件夹中。每个类都实现 ITaskHandler
接口。添加 MEF 元数据 name
以使其可被发现。
///
///Task 1 -
///
[Export(typeof(ITaskHandler))]
[ExportMetadata("name","task1")]
public class Task1 : ITaskHandler
{
public void OnExecute(string[] args)
{
Console.WriteLine("This is Task 1");
}
}
///
///Task 2 - TO BE DONE - Add MEF metadata
///
[Export(typeof(ITaskHandler))]
[ExportMetadata("name","task2")]
public class Task2 : ITaskHandler
{
public void OnExecute(string[] args)
{
Console.WriteLine("This is Task 2");
}
}
///
///Task 3 - TO BE DONE - Add MEF metadata
///
[Export(typeof(ITaskHandler))]
[ExportMetadata("name","task3")]
public class Task3 : ITaskHandler
{
public void OnExecute(string[] args)
{
Console.WriteLine("This is Task 3");
}
}
创建一个容器类来导入所有任务处理器实例
在 _Contracts_ 文件夹下创建一个新类 Container.cs。
///
///Container class
///
public class Container
{
[ImportMany(typeof(ITaskHandler))]
public Lazy<itaskhandler, dictionary="">>[] Tasks { get; set; }
}
使用 AssemblyCatalog 类发现插件
MEF 提供了不同的方式来解决依赖关系。在本例中,我们将使用 AssemblyCatalog
来发现各种任务类。
///
///Container - Discover plugins
///
public class Container
{
public Container()
{
var assem = System.Reflection.Assembly.GetExecutingAssembly();
var cat = new AssemblyCatalog(assem);
var compose = new CompositionContainer(cat);
compose.ComposeParts(this);
}
}
实例化 Lazy 实例并调用 ITaskHandler 上的 OnExecute 方法
MEF 元数据是解耦实现与其实际类的一种有用方式。我们为每个可调用 ITaskHandler
实现类提供了一个短名称。我们将使用名称元数据属性来查找并实例化 ITaskHandler
的具体实例。Lazy
类的 Value
和 IsValueCreated
属性非常有用。
internal void ExecTask(string taskname,string[] args)
{
var lazy = this.Tasks.FirstOrDefault(t => (string)t.Metadata["name"] == taskname);
if (lazy == null)
{
throw new ArgumentException($"No task with name={taskname} was found" );
}
ITaskHandler task = lazy.Value;
task.OnExecute(args);
}
将所有内容整合在一起 - 从 Main 方法执行插件
我们快完成了。main
方法将整合我们刚才所做的一切。
static void Main(string[] args)
{
try
{
Container container = new Container();
string taskname = args[0];
container.ExecTask(taskname, args);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
测试 EXE
导航到 _output_ 文件夹并执行以下命令:
第二部分 - 通过实现简单的帮助系统来扩展控制台 EXE
概述
如果我们的简单 .NET 控制台 EXE 能够显示一些使用文档,那将是很好的。类似于 PowerShell 的帮助系统,我们希望文档显示在命令行上。理想情况下,我们希望每个 Task
处理器负责发布自己的文档。为什么不创建一个单一的 ITaskHandler
实现来专门显示帮助(HelpTask.cs)呢?HelpTask
类应该利用 MEF 元数据属性 name 和 description。
用于显示帮助的命令行协议
///
///When the user types any of the following commands
/// 1)Preliminary information should be displayed
/// 2)The list of available Tasks should be displayed
///
MyUtil help
MyUtil /?
MyUtil
///
///When the user types the following command,
/// 1)Display help information specific to the task.
///
MyUtil help task1
使用 MEF 元数据让每个任务发出自己的文档
我们将为每个 ITaskHandler
实现添加“help”元数据。此属性将存储有意义的使用信息。
[Export(typeof(ITaskHandler))]
[ExportMetadata("name", "task1")]
[ExportMetadata("help", "This is Task1. Usage: --arg0 value0 --arg1 value1 --arg2 value2")]
public class Task1 : ITaskHandler
{
public void OnExecute(string[] args)
{
Console.WriteLine("This is Task 1");
}
}
创建新的 ITaskHandler 实现以显示帮助
在 HelpTask
的实现中,我们有两个方法 - DisplayAllTasks
和 DisplayTaskSpecificHelp
。为了发现有关其他任务的信息,我们需要访问 Container
类的实例。MEF 属性 Import
在 Lazy
对象实例化时帮助我们注入依赖项。
[Export(typeof(ITaskHandler))]
[ExportMetadata("name", "help")]
public class HelpTask : ITaskHandler
{
public void OnExecute(string[] args)
{
if (args.Length == 0)
{
DisplayAllTasks();
}
else
{
string taskname = args[0];
DisplayTaskSpecificHelp(taskname);
}
}
///
///MEF will resolve this dependency at the time of instantiation
///
[Import("parent")]
public Container Parent { get; set; }
}
MEF Import 如何工作?
要解析由 Import
属性标记的依赖项,MEF 会查找一个用 Export
属性注释的匹配属性。
public class Container
{
///
/// Used for dependency injection. E.g. HelpTask.cs
/// would need this to discover all other Task objects
///
[Export("parent")]
public Container Parent { get; set; }
}
方法 - DisplayAllTasks
///
/// Display a short list of all Task names.
///
private void DisplayAllTasks()
{
Console.WriteLine("List of all Tasks");
foreach(var lazy in this.Parent.Tasks)
{
string task = ((string)lazy.Metadata["name"]).ToLower();
if (task == "help") continue;
Console.WriteLine("-----------------------");
string help = null;
if (lazy.Metadata.ContainsKey("help"))
{
help = lazy.Metadata["help"] as string;
}
else
{
help = "";
}
Console.WriteLine($"{task} {help}");
}
}
方法 - DisplayTaskSpecificHelp
///
/// Display the help description for the specified Task
///
private void DisplayTaskSpecificHelp(string taskname)
{
Console.WriteLine($"Displaying help on Task:{taskname}");
var lazy = Parent.Tasks.FirstOrDefault
(t => (string)t.Metadata["name"] == taskname.ToLower());
if (lazy == null)
{
throw new ArgumentException($"No task with name={taskname} was found");
}
string help = (lazy.Metadata.ContainsKey("help") == false) ?
"No help documentation found" : (string)lazy.Metadata["help"];
Console.WriteLine($"Task:{taskname}");
Console.WriteLine($"{help}");
}
将所有内容整合在一起 - 在 Main 方法中解析命令行参数
Start
|
|
|
Analyze command line arguments
|
|
|
If zero arguments OR args[0] is 'help' then execute task 'help'
static void Main(string[] args)
{
try
{
Container container = new Container();
string taskname = null;
if ((args.Length == 0) || (args[0].ToLower() == "help" ) ||
(args[0].ToLower() =="/?"))
{
taskname = "help";//This is our custom ITaskHandler
//implementation responsible for displaying Help
}
else
{
taskname = args[0];
}
container.ExecTask(taskname, args.Skip(1).ToArray());
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
测试 - 显示所有任务列表
测试 - 显示任务特定帮助
第三部分 - 在单独的程序集中实现任务
概述
现在我们掌握了将复杂的执行文件重构为多个类的知识,其中每个类执行特定任务。我们知道如何通过 MEF 惰性加载来发现这些类,并最终通过契约接口调用方法。还有最后一步。我们需要解决将各种任务处理器类与主可执行文件解耦的问题。这将使我们能够以模块化的方式扩展系统,而无需重新编译整个可执行文件。
为契约创建一个类库
添加接口 ITaskHandler
和 IParent
。接口 IParent
将为 ITaskHandler
的每个实现提供上下文信息。
///
/// Should be implemented by every custom Task implementation
///
public interface ITaskHandler
{
void OnExecute(string[] args);
}
/// Allows a Task implementation to interact with the host
/// E.g. Task1 can get to know about other Task implementations
/// that have been discovered through MEF
public interface IParent
{
Lazy<ITaskHandler,Dictionary<string,Object>>[] Tasks {get;}
}
创建一个 .NET Core EXE
添加以下类。添加对 Contracts
类库项目的引用。为简洁起见,我仅显示了部分源代码。
MefHost.cs
发现 _Plugins_ 子文件夹下的所有子文件夹,并为每个子文件夹创建一个 DirectoryCatalog
。将 AssemblyCatalog
和 DirectoryCatalog
对象合并到一个 AggregateCatalog
实例中。
public class MefHost : MefDemoWithPluginsFolder.Contracts.IParent
{
///
///Responsible for discovering plugins by using a combination
///of AggregateCatalog, AssemblyCatalog and DirectoryCatalog
///
public MefHost(string folderPlugins)
{
List<DirectoryCatalog> lstPluginsDirCatalogs = new List<DirectoryCatalog>();
///
///Create a collection of DirectoryCatalog objects
///
string[] subFolders = System.IO.Directory.GetDirectories(folderPlugins);
foreach(var subFolder in subFolders)
{
var dirCat = new DirectoryCatalog(subFolder, "*plugin*.dll");
lstPluginsDirCatalogs.Add(dirCat);
}
var assem = System.Reflection.Assembly.GetExecutingAssembly();
var catThisAssembly = new AssemblyCatalog(assem);
///
///Combine all the DirectoryCatalog and
///AssemblyCatalog using AggregrateCatalog
///
var catAgg = new AggregateCatalog(lstPluginsDirCatalogs);
catAgg.Catalogs.Add(catThisAssembly);
var compose = new CompositionContainer(catAgg);
this.Parent = this;
compose.ComposeParts(this);
}
}
Program.cs
我们将 EXE 下的 _Plugins_ 子文件夹用于所有插件程序集。
class Program
{
static void Main()
{
string exeFile = System.Reflection.Assembly.GetExecutingAssembly().Location;
string exeFolder = System.IO.Path.GetDirectoryName(exeFile);
string folderPlugins = System.IO.Path.Combine(exeFolder, "Plugins");
MefHost host = new MefHost(folderPlugins);
string taskname = null;
if ((args.Length == 0) || (args[0].ToLower() == "help") ||
(args[0].ToLower() == "/?"))
{
taskname = "help";
}
else
{
taskname = args[0];
}
host.ExecTask(taskname, args.Skip(1).ToArray());
}
}
HelpTask.cs
与先前各部分中的实现类似。
创建 Task1 和 Task2 插件类库
///
///Task 1
///
[Export(typeof(MefDemoWithPluginsFolder.Contracts.ITaskHandler))]
[ExportMetadata("name", "task1")]
[ExportMetadata("help", "This is Task1.
Usage: --arg0 value0 --arg1 value1 --arg2 value2")]
public class Class1 : Contracts.ITaskHandler
{
public void OnExecute(string[] args)
{
string sArgs = string.Join("|",args);
Console.WriteLine($"This is Task 1. Arguments:{sArgs}");
}
}
为 Task1 和 Task2 添加 CopyLocalLockFileAssemblies 元素
Task1
和 Task2
的 CSPROJ 文件需要修改一行。我们应该将 CopyLocalLockFileAssemblies
元素设置为 true。为什么要这样做?我们希望类库发出所有引用的程序集。如果这是 .NET Framework,您可以通过设置 **Copy Local** 属性来实现相同的功能。对于 .NET Standard 和 Core 项目,依赖项程序集不会立即发出。
<PropertyGroup>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
添加生成后步骤,将 Task1 和 Task2 的输出复制到 Plugins 文件夹
我们应该记住,我们正在远离“硬”引用。EXE 对 Task1
和 Task2
的存在没有编译时感知。在这种情况下,Task1
和 Task2
的输出应复制到 Plugins 文件夹。对于此项目,我们选择了 EXE 下的 _Plugins_ 子文件夹。为避免重复,我们将编写一个 _BAT_ 文件来执行 XCOPY
。 _BAT_ 文件将驻留在解决方案的根目录下。解决方案的物理布局将如下所示:
EXE----
|
|
Bin--
|
|
Release
|
|
netcoreapp2.1
|
|
Plugins
|
|
Task 1
|
| (all assemblies,PDB and other files from the Bin of Task1)
|
|
Task 2
(all assemblies,PDB and other files from the Bin of Task2)
Using the Code
需要 Visual Studio 2017。
Github
MefConsoleApplication.sln
演示了一个简单的 .NET Core 控制台 EXE,并使用 MEF 在同一可执行程序集中发现 ITaskHandler
实现。
MefConsoleApplicationWithPluginsFolder.sln
演示从外部文件夹加载插件程序集。
参考文献
历史
- 2019 年 7 月 24 日:初始版本