使用反射的简单插件引擎






4.91/5 (22投票s)
本文介绍了如何在您的应用程序中创建和使用可配置的插件。
引言
事实上,现在每个人都知道什么是插件。插件是一组软件组件,用于扩展应用程序的功能。例如,插件经常用于不同的多媒体播放器中,以扩展播放器可以播放的格式。
背景
随着 .NET 4.0 平台的发布,我们获得了托管可扩展性框架 (MEF) – 一个相当不错的扩展引擎。
但有时(虽然很少)客户会限制所使用的框架版本。当然,您可以让 Google 搜索实现插件的现成解决方案。例如,我见过用于构建插件系统的Plux.NET 平台,但我从未详细 review 过它。
因此,我们有一个任务,即创建一个解决方案,使您的应用程序能够在不重新编译的情况下扩展其功能。使用插件的近似计划如下:
- 有一个支持添加插件的应用程序。
- 您已将一些插件文件添加到了特殊的应用程序文件夹中。
- 应用程序必须启动插件的加载。为此,应用程序必须重新启动或使用显式方法调用,例如
LoadPlugins()
。 - 享受扩展的功能集吧!
有很多关于插件的文章,我会尽力保持原创。阅读完本文后,您将学习如何创建支持 .net 配置的扩展,以及如何限制授予插件代码的权限。
版本 2 的新内容
在随文章一起提供的演示项目的第一版中,加载的插件被完全信任。这意味着它们能够执行潜在危险或不受欢迎的代码。这是因为将插件程序集加载到与宿主应用程序相同的应用程序域中。这是最简单的方法,但不是最安全的方法。
第二版提供了另一种加载插件的方法。所有插件都加载到一个名为沙盒的单独应用程序域中。沙盒是一个隔离的环境,对执行的代码授予有限的访问权限。
为我们未来的插件创建基类
首先,让我们实现 PluginBase
类 – 从它的名字就可以看出,它是我们未来插件的基类。
在 Visual Studio 中创建一个名为 *PluginBase* 的新类库项目,并向该项目添加一个包含以下代码的文件:
/// <summary>
/// Base class for a plugin
/// </summary>
public abstract class PluginBase : MarshalByRefObject
{
/// <summary>
/// Plugin's name
/// </summary>
public virtual string Name { get; protected set; }
/// <summary>
/// Plugin's description
/// </summary>
public virtual string Description { get; protected set; }
/// <summary>
/// Plugin's configuration
/// </summary>
public virtual ConfigurationBase Configuration { get; set; }
/// <summary>
/// Default constructor
/// </summary>
public PluginBase()
{
Name = "Unnamed plugin";
Description = "No description";
}
/// <summary>
/// Does some work
/// </summary>
public abstract void Run();
}
请注意,PluginBase
类派生自 MarshalByRefObject
类。因此,PluginBase
类实例可以在应用程序域边界之间访问。
我们的简单插件将只包含一个清晰的方法 – Run()
。您可以实现任何您想要的方法。我说“清晰”的方法是因为这个类中有三个属性 – Name
、Description
和 Configuration
。
Run()
方法是抽象的,因此您需要在插件中实现此方法。属性只是虚拟的,因此您可以在派生的插件中覆盖它们。
拥有可配置的插件将是很好的。如果每个插件的配置都存储在宿主应用程序的 *app.config* 文件的单独部分中,那将更好。
让我们开始吧。这里是一个名为 ConfigurationBase
的抽象类,它继承自 ConfigurationSection
类(不要忘记添加对 System.Configuration
命名空间的引用)。
/// <summary>
/// Base configuration class for a plugin
/// </summary>
public abstract class ConfigurationBase : ConfigurationSection
{
/// <summary>
/// Opens the configuration section with the specified type and name
/// </summary>
/// <param name="sectionName">Configuration section's name</param>
/// <param name="configPath">Configuration file's path</param>
/// <returns>Instance of the configuration section's class</returns>
public static T Open<T>(string sectionName, string configPath) where T : ConfigurationBase, new()
{
T instance = new T();
if (configPath.EndsWith(".config", StringComparison.InvariantCultureIgnoreCase))
configPath = configPath.Remove(configPath.Length - 7);
try
{
Configuration config = ConfigurationManager.OpenExeConfiguration(configPath);
/* section not found */
if (config.GetSection(sectionName) == null)
{
config.Sections.Add(sectionName, instance);
foreach (ConfigurationProperty p in instance.Properties)
((T)config.GetSection(sectionName)).SetPropertyValue(p, p.DefaultValue, true);
config.Save();
}
else
/* section already exists */
instance = (T)config.Sections[sectionName];
}
catch (ConfigurationErrorsException)
{
if (instance == null)
instance = new T();
}
return instance;
}
}
ConfigurationBase
类包含一个泛型方法:
-
public static T Open<T>(string sectionName, string configPath) where T : ConfigurationBase, new()
此静态方法允许您打开应用程序配置文件,并读取指定类型和指定名称的配置节。当您第一次加载插件时,文件中不会有配置节。这就是为什么此方法会向配置文件添加该节,之后您就可以修改配置属性了。无论如何,该方法将返回配置节类的实例。
您可能已经注意到 if (config.GetSection(sectionName) == null)
这一行被高亮显示了。请注意这一行。稍后我们会回来讨论它。
创建插件
现在是时候创建插件了。在 Visual Studio 中打开一个新的类库项目(我们称之为 *ShowConsolePlugin*),并添加对包含 PluginBase
和 ConfigurationBase
类的先前创建项目的引用。在该项目中添加两个文件:
public sealed class ShowConsolePluginConfiguration : PluginBase.ConfigurationBase
{
/// <summary>
/// Message to show
/// </summary>
[ConfigurationProperty("Message", DefaultValue = "Hello from ShowConsolePlugin")]
public string Message
{
get
{
return (String)this["Message"];
}
set
{
this["Message"] = value;
}
}
}
此类继承自 *PluginBase* 程序集的 ConfigurationBase
类,并具有 Message
属性,该属性实际上是一个配置属性。
public sealed class ShowConsolePlugin : PluginBase.PluginBase
{
public ShowConsolePlugin()
{
Name = "ShowConsolePlugin";
Description = "ShowConsolePlugin";
}
public override void Run()
{
Console.WriteLine(String.Format("[{0}] {1}", DateTime.Now, (Configuration as ShowConsolePluginConfiguration).Message));
}
}
Name
和 Description
属性在插件的默认构造函数中设置。您有没有忘记 PluginBase
类中的 Configuration
属性?我们在重写的 Run()
方法中将其类型转换为 ShowConsolePluginConfiguration
类型,该方法将 Message
值写入系统控制台。不用担心,此时 Configuration
属性的值为 null
。稍后我们会处理这个问题。
有人问我如何禁用插件访问 Internet。嗯,通过沙盒化方法,您可以实现这一点以及许多其他安全功能。为了演示目的,我创建了 *WebDownloadPlugin* 项目。该项目的创建过程与前面的(ShowConsolePlugin)相同,只是添加了配置类。
public sealed class WebDownloadPlugin : PluginBase.PluginBase
{
public WebDownloadPlugin()
{
Name = "WebDownloadPlugin";
Description = "WebDownloadPlugin";
}
public override void Run()
{
string html = String.Empty;
using (var wc = new WebClient())
{
html = wc.DownloadString("http://codeproject.com");
}
Console.WriteLine(Regex.Match(html, @"<title>\s*(.+?)\s*</title>").Groups[1]);
}
}
如您所见,此插件下载 CodeProject 网站的主页,并将其标题写入系统控制台。
创建插件管理器
最简单的部分已经完成了。现在我们必须创建插件管理器类。它将提供插件文件的加载逻辑,以及插件与宿主应用程序之间的交互逻辑。
插件管理器类将包含在 *PluginBase* 程序集中。在我们开始编写代码之前,必须执行两个步骤。
- 首先,我们必须给 *PluginBase* 程序集一个强名称。这对于在沙盒应用程序域中为该程序集提供完全信任是必需的。附件 zip 文件中的 *PluginBase* 项目已经用密钥文件进行了签名。有关如何签名程序集的详细说明,您可以在此阅读。
- 其次,*PluginBase* 程序集必须用
AllowPartiallyTrustedCallersAttribute
(APTCA) 属性标记,因为不受信任的插件会引用受信任程序集中的基类。在附件项目中,这是在 *PluginBase/Properties* 目录下的 *AssemblyInfo.cs* 文件中完成的。
不幸的是,APTCA 属性的使用还将允许插件在 *PluginBase* 程序集中的任何类中执行任何方法。这就是为什么我们需要要求调用堆栈中更高级别的调用者提供权限,以确保调用者是受信任的。
可以通过用下一个属性标记这些“关键”方法来实现:
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
现在,在修复了安全漏洞之后,我们可以开始开发 PluginManager
类。它必须像 PluginBase
类一样继承自 MarshalByRefObject
类。
/// <summary>
/// Plugin manager class to interact with the host application
/// </summary>
public sealed class PluginManager : MarshalByRefObject
{
// Dictionary that contains instances of assemblies for loaded plugins
private readonly Dictionary<Assembly, PluginBase> plugins;
...
上面的代码中有 plugins
字段。它是 Dictionary<Assembly, PluginBase>
的实例,用于存储插件类作为值,插件程序集作为键。
现在让我们添加加载插件的方法。等等,您会问,构造函数和沙盒化怎么办?嗯,构造函数中的一些架构决策在看到下一个方法后更容易解释。
/// <summary>
/// Loads a plugin
/// </summary>
/// <param name="fullName">Full path to a plugin's file</param>
/// <returns>Instance of the loaded plugin's class</returns>
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
public PluginBase LoadPlugin(string fullName)
{
Assembly pluginAssembly;
try
{
new FileIOPermission(FileIOPermissionAccess.Read | FileIOPermissionAccess.PathDiscovery, fullName).Assert();
pluginAssembly = AppDomain.CurrentDomain.Load(AssemblyName.GetAssemblyName(fullName));
}
catch (BadImageFormatException)
{
/* Skip not managed dll files */
return null;
}
finally
{
CodeAccessPermission.RevertAssert();
}
var pluginType = pluginAssembly.GetTypes().FirstOrDefault(x => x.BaseType == typeof(PluginBase));
if (pluginType == null)
throw new InvalidOperationException("Plugin's type has not been found in the specified assembly!");
var pluginInstance = Activator.CreateInstance(pluginType) as PluginBase;
plugins.Add(pluginAssembly, pluginInstance);
var pluginConfigurationType = pluginAssembly.GetTypes().FirstOrDefault(x => x.BaseType == typeof(ConfigurationBase));
if (pluginConfigurationType != null)
{
string processPath = String.Empty;
try
{
new SecurityPermission(SecurityPermissionFlag.UnmanagedCode).Assert();
processPath = Process.GetCurrentProcess().MainModule.FileName;
}
finally
{
CodeAccessPermission.RevertAssert();
}
try
{
var pset = new PermissionSet(PermissionState.None);
pset.AddPermission(new FileIOPermission(PermissionState.Unrestricted));
pset.AddPermission(new ConfigurationPermission(PermissionState.Unrestricted));
pset.Assert();
pluginInstance.Configuration =
typeof(ConfigurationBase)
.GetMethod("Open")
.MakeGenericMethod(pluginConfigurationType)
.Invoke(null, new object[] { Path.GetFileNameWithoutExtension(fullName), processPath }) as ConfigurationBase;
}
finally
{
CodeAccessPermission.RevertAssert();
}
}
return pluginInstance;
}
在这里,我们使用当前应用程序域的 Load
方法来加载程序集。加载程序集后,我们在其中搜索第一个派生自 PluginBase
类的类 – 这是主要的插件类。然后,我们使用 Activator.CreateInstance
方法创建主插件类的实例,并将创建的实例添加到 Plugins
字典中,键等于包含插件类的程序集。
如上所述,我们将为 *PluginBase* 程序集授予完全信任。这就是为什么我们可以自由地断言任何需要的权限。但是不要忘记通过调用 CodeAccessPermission.RevertAssert()
将其从当前堆栈帧中删除。
让我们尝试以相同的方式加载插件的配置节。我们可以使用 ConfigurationBase
类的泛型方法 Open
。
pluginInstance.Configuration =
typeof(ConfigurationBase)
.GetMethod("Open")
.MakeGenericMethod(pluginConfigurationType)
.Invoke(null, new object[] { Path.GetFileNameWithoutExtension(fullName), processPath }) as ConfigurationBase;
如果插件程序集有 ConfigurationBase
的派生类的定义,它将被加载。然后我们就可以在 Run()
方法代码中使用它的配置属性,而无需再担心 null
值了。
现在让我们修改 PluginManager
类的默认构造函数:
/// <summary>
/// Default constructor. Plugins will be loaded into the same application domain as the host application
/// </summary>
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
public PluginManager()
{
plugins = new Dictionary<Assembly, PluginBase>();
// There are no any references to plugins assemblies in the project.
// That's why the ConfigurationErrorsException will be thrown during
// loading of the plugin's configuration. Actually this happens
// because an assembly defined in a configuration file couldn't be resolved.
// We need to manually resolve that assembly.
AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve;
}
您还记得 ConfigurationBase
类中高亮显示的那一行吗?
当您调用 config.GetSection(sectionName)
时,您会隐式反序列化配置文件中指定类型的配置节。此类型的搜索发生在同一配置文件中指定的程序集中。CLR 如何定位程序集,您可以在此处阅读。*PluginBase* 程序集位于自定义文件夹 *Plugins* 中,而CLR 将找不到它。这就是为什么会抛出 ConfigurationErrorsException
。我们可以通过两种方式解决这个问题:
- 处理
AppDomain.AssemblyResolve
事件。 - 向配置文件添加以下代码:
<runtime> <assemblybinding xmlns="urn:schemas-microsoft-com:asm.v1"> <probing privatepath="The path to your plugins directory"> </probing> </assemblybinding> </runtime>
程序集已加载到当前应用程序域中,并且我们已将程序集引用保存在 PluginManager
类的字典字段 plugins
中。这就是为什么我们最好处理 AppDomain.AssemblyResolve
事件。
private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
return plugins.Keys.FirstOrDefault(x => x.FullName == args.Name);
}
我们刚刚“准备”了 *PluginBase* 程序集以加载到沙盒应用程序域中。为了确保与使用插件管理器的旧应用程序的最大兼容性,最好编写一个工厂方法来创建新的 PluginManager
类的实例。
/// <summary>
/// Factory method that creates PluginManager's instance with limited permission set. Plugins will be loaded into the sandboxed application domain
/// </summary>
/// <param name="grantSet" />Permission set to grant
/// <returns>Instance of the PluginManager's class</returns>
[PermissionSetAttribute(SecurityAction.LinkDemand, Name = "FullTrust")]
public static PluginManager GetInstance(PermissionSet grantSet)
{
if (grantSet == null)
throw new ArgumentNullException("grantSet");
/* Grant "base" permissions */
grantSet.AddPermission(new ReflectionPermission(ReflectionPermissionFlag.RestrictedMemberAccess));
grantSet.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));
/* Note that the setup information is the same, i.e. ApplicationBase is the same */
var sandbox = AppDomain.CreateDomain("sandbox", null, AppDomain.CurrentDomain.SetupInformation, grantSet, getStrongName(Assembly.GetExecutingAssembly()));
return Activator.CreateInstanceFrom(sandbox, typeof(PluginManager).Assembly.ManifestModule.FullyQualifiedName, typeof(PluginManager).FullName).Unwrap() as PluginManager;
}
上面的 GetInstance
方法接受一个 PermissionSet
类型的参数。它创建一个具有该权限集并具有相同设置信息的应用程序域。然后,它使用命名程序集文件和默认构造函数在沙盒应用程序域中创建 PluginManager
类型的实例。
此外,我们还给予了 *PluginBase* 程序集完全信任。为此,我们需要获取其强名称。getStrongName
方法获取参数中传递的程序集的强名称(它是从此处获取的)。
/// <summary>
/// Get a strong name that matches the specified assembly.
/// </summary>
/// <exception cref="ArgumentNullException">
/// if <paramref name="assembly"/> is null
/// </exception>
/// <exception cref="InvalidOperationException">
/// if <paramref name="assembly"/> does not represent a strongly named assembly
/// </exception>
/// <param name="assembly">Assembly to create a StrongName for</param>
/// <returns>A StrongName for the given assembly</returns>
private static StrongName getStrongName(Assembly assembly)
{
if (assembly == null)
throw new ArgumentNullException("assembly");
AssemblyName assemblyName = assembly.GetName();
// Get the public key blob.
byte[] publicKey = assemblyName.GetPublicKey();
if (publicKey == null || publicKey.Length == 0)
throw new InvalidOperationException("Assembly is not strongly named");
StrongNamePublicKeyBlob keyBlob = new StrongNamePublicKeyBlob(publicKey);
// Return the strong name.
return new StrongName(keyBlob, assemblyName.Name, assemblyName.Version);
}
沙盒已准备就绪。所有安全属性都已设置,不受信任的插件无法调用需要完全信任的方法。一切似乎都很好,但我们错过了一个小细节。
由于宿主应用程序域和沙盒应用程序域具有相同的设置信息(AppDomainSetup
类的实例),因此有可能对宿主应用程序进行重用攻击。这意味着不受信任的插件可以加载宿主程序集(通过调用 Assembly.Load
)或位于 ApplicationBase
(AppDomainSetup
实例的属性)文件夹中的另一个程序集。因此,在授予不受信任的插件文件权限时要小心。我见过许多建议不要使用相同的 ApplicationBase
属性。但在此情况下,我们将遇到太多其他麻烦。
沙盒应用程序域将无法访问宿主程序集,即无法访问 *PluginBase* 程序集。(并且在从插件的程序集中获取类型后,会抛出 ReflectionTypeLoadException
异常)。要解决此问题,*PluginBase* 程序集应同时从宿主应用程序和插件中可访问。有一个明显的解决方法 – 将 *PluginBase* 程序集放在GAC 中。然而,我不会在这篇文章中 review 这种方式。
创建演示应用程序
打开 Visual Studio 并向解决方案添加一个名为 *PluginDemo* 的新控制台应用程序项目。打开 *Program.cs* 文件并添加两个常量:
internal class Program
{
// Directory that contains plugins files
private const string pluginDir = "Plugins";
// Plugins files extension mask
private const string pluginExtMask = "*.dll";
...
第一个常量是插件目录的相对路径。第二个常量是插件文件的扩展名掩码。
现在让我们添加一个运行插件的方法。它会迭代指定目录中的 *.*dll* 文件,并使用传递为参数的 PluginManager
类的实例来加载每个插件。
static void RunPlugins(PluginBase.PluginManager pluginMan)
{
var path = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), pluginDir);
foreach (var f in new DirectoryInfo(path).GetFiles(pluginExtMask))
{
var plugin = pluginMan.LoadPlugin(Path.Combine(path, f.Name));
try
{
plugin.Run();
Console.WriteLine("Plugin {0} has finished work\r\n", plugin.Name);
}
catch (Exception ex)
{
Console.WriteLine("An exception occurred in the {0} plugin: {1}\r\n", plugin.Name, ex.Message);
}
}
}
现在修改 Main
方法:
static void Main(string[] args)
{
Console.WriteLine("1. Old way of running the plugins - full trust:\r\n");
var pluginMan = new PluginBase.PluginManager();
RunPlugins(pluginMan);
Console.WriteLine(Environment.NewLine);
Console.WriteLine("2. New way of running the plugins - limited trust:\r\n");
var pset = new PermissionSet(PermissionState.None); /* deny all */
/* uncomment the next line to allow file operations for all the plugins */
// pset.AddPermission(new FileIOPermission(FileIOPermissionAccess.Read | FileIOPermissionAccess.Write, Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)));
/* uncomment the next line to allow working of the WebDownloadPlugin */
// pset.AddPermission(new WebPermission(PermissionState.Unrestricted));
pluginMan = PluginBase.PluginManager.GetInstance(pset);
RunPlugins(pluginMan);
Console.ReadKey();
}
现在我们必须在 *PluginDemo* 项目中创建名为“Plugins”的文件夹。请注意,文件夹的名称必须等于代码中定义的 pluginDir
常量。将 *ShowConsolePlugin.dll* 和 *WebDownloadPlugin.dll* 复制到该文件夹,并将“复制到输出目录”属性设置为“始终复制”。
我还建议将此行添加到所有插件项目的生成后事件:
xcopy /y $(ProjectDir)$(OutDir)$(TargetFileName) $(SolutionDir)PluginDemo\Plugins\
确保设置正确的项目生成顺序。所有插件项目必须在 *PluginDemo* 项目之前生成。
测试演示应用程序
所有的编码乐趣到此结束!我们可以编译并运行该应用程序。
请注意,我还有第三个插件,名为 *SaveTxtPlugin*。此插件只是将一些文本保存到指定文件中。本文不涵盖 *SaveTxtPlugin* 的创建过程,因为它与上面描述的过程类似。
运行应用程序将创建 *PluginDemo.exe.config* 文件。该文件将包含:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<section name="ShowConsolePlugin" type="ShowConsolePlugin.ShowConsolePluginConfiguration, ShowConsolePlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<section name="SaveTxtPlugin" type="SaveTxtPlugin.SaveTxtPluginConfiguration, SaveTxtPlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
</configSections>
<ShowConsolePlugin Message="Hello from ShowConsolePlugin" />
<SaveTxtPlugin FileName="textFile.txt" />
</configuration>
首先,让我们更改 *ShowConsolePlugin* 节点中的 Message
属性:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<section name="ShowConsolePlugin" type="ShowConsolePlugin.ShowConsolePluginConfiguration, ShowConsolePlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<section name="SaveTxtPlugin" type="SaveTxtPlugin.SaveTxtPluginConfiguration, SaveTxtPlugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
</configSections>
<ShowConsolePlugin Message="Hello again!" />
<SaveTxtPlugin FileName="textFile.txt" />
</configuration>
其次,让我们取消注释 *PluginDemo/Program.cs* 文件中 Main
方法的下一行并重新编译应用程序:
/* uncomment the next line to allow working of the WebDownloadPlugin */
pset.AddPermission(new WebPermission(PermissionState.Unrestricted));
再次启动 *PluginDemo.exe*,您将看到:
历史
- 2012.09.26 - 版本 2。插件现在加载到具有有限权限集的单独应用程序域中。
- 2012.06.16 - 首次发布。