65.9K
CodeProject 正在变化。 阅读更多。
Home

使用反射的简单插件引擎

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (22投票s)

2012年6月17日

CPOL

11分钟阅读

viewsIcon

72439

downloadIcon

1139

本文介绍了如何在您的应用程序中创建和使用可配置的插件。

引言

事实上,现在每个人都知道什么是插件。插件是一组软件组件,用于扩展应用程序的功能。例如,插件经常用于不同的多媒体播放器中,以扩展播放器可以播放的格式。

背景

随着 .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()。您可以实现任何您想要的方法。我说“清晰”的方法是因为这个类中有三个属性 – NameDescriptionConfiguration

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*),并添加对包含 PluginBaseConfigurationBase 类的先前创建项目的引用。在该项目中添加两个文件:

ShowConsolePluginConfiguration.cs
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 属性,该属性实际上是一个配置属性。

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

NameDescription 属性在插件的默认构造函数中设置。您有没有忘记 PluginBase 类中的 Configuration 属性?我们在重写的 Run() 方法中将其类型转换为 ShowConsolePluginConfiguration 类型,该方法将 Message 值写入系统控制台。不用担心,此时 Configuration 属性的值为 null。稍后我们会处理这个问题。

有人问我如何禁用插件访问 Internet。嗯,通过沙盒化方法,您可以实现这一点以及许多其他安全功能。为了演示目的,我创建了 *WebDownloadPlugin* 项目。该项目的创建过程与前面的(ShowConsolePlugin)相同,只是添加了配置类。

WebDownloadPlugin.cs
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)或位于 ApplicationBaseAppDomainSetup 实例的属性)文件夹中的另一个程序集。因此,在授予不受信任的插件文件权限时要小心。我见过许多建议不要使用相同的 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* 复制到该文件夹,并将“复制到输出目录”属性设置为“始终复制”

Plugin file properties

我还建议将此行添加到所有插件项目的生成后事件:

xcopy /y $(ProjectDir)$(OutDir)$(TargetFileName) $(SolutionDir)PluginDemo\Plugins\

确保设置正确的项目生成顺序。所有插件项目必须在 *PluginDemo* 项目之前生成。

测试演示应用程序

所有的编码乐趣到此结束!我们可以编译并运行该应用程序。

请注意,我还有第三个插件,名为 *SaveTxtPlugin*。此插件只是将一些文本保存到指定文件中。本文不涵盖 *SaveTxtPlugin* 的创建过程,因为它与上面描述的过程类似。

Console app window 1

运行应用程序将创建 *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*,您将看到:

Console app window 2

历史

  • 2012.09.26 - 版本 2。插件现在加载到具有有限权限集的单独应用程序域中。
  • 2012.06.16 - 首次发布。
© . All rights reserved.