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

Clifton 方法 - 第一部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (22投票s)

2016 年 8 月 25 日

CPOL

12分钟阅读

viewsIcon

32609

downloadIcon

212

模块管理器 - 在运行时动态加载程序集

系列文章

引言

构建具有运行时加载程序集的应用程序是一项有用的功能,因为它提供了

  1. 从“逻辑组件”自定义应用程序的能力。
  2. 替换组件为新的或不同的行为,例如,模拟对象。
  3. 通过添加新组件来扩展应用程序的行为。

“逻辑组件”是指封装了“某物”所有功能的程序集(DLL)。要理解这个概念,一个简单的方法是思考物理设备,例如相机。相机可能提供的所有功能,例如拍照、流式传输视频、配置分辨率和其他设置,都可以组织成一个逻辑组件。由于不同的相机很可能具有不同的 API 和选项,因此可以为每个物理设备创建不同的 DLL,并为特定安装中的实际相机(或相机)自定义应用程序。

将服务创建为逻辑组件是另一件有用的事情。例如,Web服务器可以是一个组件,封装了处理 HTTP 请求。路由器可能是另一个组件。如果您正在实现提供公共服务的服务器,您可能不需要执行身份验证的路由器。如果您正在实现提供非公共页面或 REST 调用的服务器,您将需要一个执行身份验证的路由器组件。另一个好的例子是与不同数据库的接口,例如 SQL Server、Oracle 或 NoSQL 数据库。

什么时候一组类适合包装成模块作为组件,什么时候不适合?理想情况下,您在编写非模块化应用程序时就应该问自己这个问题,因为即使是此类应用程序的内部也应该以“逻辑上”结构化的方式进行组织。

什么适合模块化?

问这些问题

它像组件一样工作吗?

  1. 这些类的集合是否打算被重用?
  2. 模拟这些类提供的功能是否有用?
  3. 我是在针对特定的物理(硬件)对象还是特定的行为进行编码?
  4. 我是否可以预见到物理硬件会发生变化,或者行为会根据应用程序/客户的特定需求而变化?
  5. 这些类是否实现了高级行为?
  6. 这些类是否实现了可能因应用程序特定需求而异的业务规则?

在最后一个问题中,我们对“高级行为”的含义是什么意思?

它是高级的吗?

高级行为通常

  1. 与系统中的其他高级组件进行交互。
  2. 与(例如数据库)的其他应用程序通信。
  3. 处理异步事件,例如 HTTP、TCP/IP、串行、USB 或其他传输。
  4. 与物理硬件进行交互。
  5. 作为原型(已实例化)实现,但作为单例用于其提供的特定功能。
  6. 为所有相同“种类”的逻辑组件实现一个通用接口(因此 #5,已实例化)。
  7. #6 意味着行为通过接口进行抽象。

它是低级的吗?

低级行为通常属于“实用程序”类别,实现的类

  1. 扩展方法
  2. 转换方法
  3. 可以,并且经常被实现为静态方法
  4. 通常不实现接口来抽象行为

你基本上对“它像组件一样工作吗?”和“它是高级的吗?”有所有“是”的答案,而对“它是低级的吗?”有所有“否”的答案。

模块化组件 vs. 可继承

构建“组件”的典型方法是使用继承和工厂模式。

典型的继承架构

在这里,代码以整体方式实现了每个具体的服务器实现,并要求工厂方法创建所需的具体对象。

典型的模块化组件架构

在这种情况下,应用程序根据单独的配置文件中指定的实现具体类型来获取实例。具体类型被实现为单独的程序集。

你注意到这一点了吗?

  1. 模块加载器类似于工厂模式,但您不会要求它为您获取任何东西,而是应用程序通过配置文件来确定要加载什么。 “获取某物”的工厂模式被“你得到这个”所取代,更像是策略模式。
  2. 在模块中实现的具体类通常不继承自基类,而是实现接口。
  3. 继承被模块化取代。

最后一点值得重申:继承被模块化取代。 与其创建可能复杂的专门化继承树,不如让每个专门化独立地存在于自己的模块中,并且继承通常非常浅,实际上通常只是实现接口需求。

模块化架构的烦人之处

为了利用模块化架构,接口类必须共享,要么通过引用同一个文件,通常在某个公共文件夹中,要么将共享文件包装在应用程序和模块都共享的程序集中。关键点是,文件或文件的程序集必须共享。为什么?因为

  1. 应用程序需要知道如何与模块通信,这通过接口来实现。
  2. 模块需要知道它实现了什么,同样通过接口。

整体应用程序没有这个问题,因为一切都在同一个应用程序解决方案中。

一旦您开始编写代码,以便为您的组件拥有不同的项目(程序集),您就必须在一个共享程序集中共享接口源代码或抽象类代码。

当您开始将组件编写为运行时加载的模块时,这也同样成立。

在这里,我们再次看到与整体应用程序开发相反的情况。而不是

  • 所有东西都在一个项目中(高度整体化)

  • 许多项目,每个项目都引用它需要了解的其他项目(编译时模块化)。

我们转而拥有

  • 一个应用程序共享接口规范和独立的模块(程序集),应用程序不引用这些模块,模块之间也不互相引用,除了通过共享接口。

当然,不同模块的项目文件可以存在于同一个解决方案中(甚至在多个解决方案中,因为这些模块经常被共享),但关键是,应用程序和模块都不会直接引用其他模块。

实现

经过冗长的介绍后,我们可以查看实现细节。

附注

需要注意的一点是,在此代码中,我有时会不一致地尝试使用语义类型,正如我在 使用语义类型进行强类型检查中所写的那样。因此,您会看到几个类型,如XmlFileNameAssemblyFileName,它们是字符串的类型包装器。我不确定这是否有好处——在这里可能是不必要的复杂性,尽管我确实喜欢根据类型而不是变量名来指定参数中预期的字符串类型的想法。

我还依赖于存储库中的一些 Linq 扩展方法和断言方法,但此处未进行讨论。

指定模块

我在 XML 文件中指定模块。您可以同样轻松地将其放入应用程序的配置文件、数据库表、JSON 文档或其他内容中。我使用的 XML 格式如下所示。

<?xml version="1.0" encoding="utf-8" ?>
<Modules>
  <Module AssemblyName='[some module name].dll'/>
  <Module AssemblyName='[another module name].dll'/>
</Modules>

我将 XML 文件加载到模块注册所需的List<AssemblyFileName>中(见下文),在您的应用程序中有两个辅助方法。

/// <summary>
/// Return the list of assembly names specified in the XML file so that
/// we know what assemblies are considered modules as part of the application.
/// </summary>
static private List<AssemblyFileName> GetModuleList(XmlFileName filename)
{
  Assert.That(File.Exists(filename.Value), 
             "Module definition file " + filename.Value + " does not exist.");
  XDocument xdoc = XDocument.Load(filename.Value);

  return GetModuleList(xdoc);
}

/// <summary>
/// Returns the list of modules specified in the XML document so we know what
/// modules to instantiate.
/// </summary>
static private List<AssemblyFileName> GetModuleList(XDocument xdoc)
{
  List<AssemblyFileName> assemblies = new List<AssemblyFileName>();
  (from module in xdoc.Element("Modules").Elements("Module")
    select module.Attribute("AssemblyName").Value).ForEach
          (s => assemblies.Add(AssemblyFileName.Create(s)));

  return assemblies;
}

模块注册

使用上述代码(用于 XML 文件),我通过以下方式注册模块:

IModuleManager moduleMgr = serviceManager.Get<IModuleManager>();
List<AssemblyFileName> modules = GetModuleList(XmlFileName.Create("modules.xml"));
moduleMgr.RegisterModules(modules);

模块注册在此处执行。

/// <summary>
/// Register modules specified in a list of assembly filenames.
/// </summary>
public virtual void RegisterModules(
  List<AssemblyFileName> moduleFilenames, 
  OptionalPath optionalPath = null, 
  Func<string, Assembly> assemblyResolver = null)
{
  List<Assembly> modules = LoadModules(moduleFilenames, optionalPath, assemblyResolver);
  List<IModule> registrants = InstantiateRegistrants(modules);
  InitializeRegistrants(registrants);
}

OptionalFolder 参数

这样做的目的是允许应用程序指定一个子文件夹,其中包含模块(DLL)。这有助于将模块程序集与“静态链接”的依赖项进行更清晰的分离。

AssemblyResolver 参数

这是一个有趣的可选参数。它是一个函数,接受模块名称并返回Assembly。这样做的目的是,程序集可能位于一个奇怪的地方,例如应用程序的资源文件中。我还没有写过关于这个技术的文章,但当我写的时候,我会回到这里并提供该概念的链接。总的来说,您可以使用这个可选函数来尝试解析位于应用程序子文件夹之外的程序集。

加载模块

LoadModules方法遍历模块列表。

/// <summary>
/// Load the assemblies and return the list of loaded assemblies. In order to register
/// services that the module implements, we have to load the assembly.
/// </summary>
protected virtual List<Assembly> LoadModules(List<AssemblyFileName> moduleFilenames, 
OptionalPath optionalPath, Func<string, Assembly> assemblyResolver)
{
  List<Assembly> modules = new List<Assembly>();

  moduleFilenames.ForEach(a =>
  {
    Assembly assembly = LoadAssembly(a, optionalPath, assemblyResolver);
    modules.Add(assembly);
  });

  return modules;
}

这将返回一个Assembly实例列表。

LoadAssembly尝试实际加载程序集,并可选择将“我需要这个程序集”传递给您在注册调用中提供的程序集解析器函数。

/// <summary>
/// Load and return an assembly given the assembly filename so we can proceed with
/// instantiating the module and so the module can register its services.
/// </summary>
protected virtual Assembly LoadAssembly(
  AssemblyFileName assyName, 
  OptionalPath optionalPath, 
  Func<string, Assembly> assemblyResolver)
{
  FullPath fullPath = GetFullPath(assyName, optionalPath);
  Assembly assembly = null;

  if (!File.Exists(fullPath.Value))
  {
    Assert.Not(assemblyResolver == null, "AssemblyResolver must be defined 
              when attempting to load modules from the application's resources.");
    assembly = assemblyResolver(assyName.Value);
  }
  else
  {
    try
    {
      assembly = Assembly.LoadFile(fullPath.Value);
    }
    catch (Exception ex)
    {
      throw new ModuleManagerException("Unable to load module " + 
                                       assyName.Value + ": " + ex.Message);
    }
  }

  return assembly;
}

GetFullPath方法中,可选路径被附加到执行程序集的位置。

/// <summary>
/// Return the full path of the executing application 
/// (here we assume that ModuleManager.dll is in that path) 
/// and concatenate the assembly name of the module.
/// .NET requires the full path in order to load the associated assembly.
/// </summary>
protected virtual FullPath GetFullPath
         (AssemblyFileName assemblyName, OptionalPath optionalPath)
{
  string appLocation;
  string assyLocation = Assembly.GetExecutingAssembly().Location;

  if (assyLocation == "")
  {
    Assert.Not(optionalFolder == null, "Assemblies embedded as resources require that 
              the optionalPath parameter specify the path to resolve assemblies.");
    appLocation = optionalPath.Value; // Must be specified! Here the optional path 
    //is the full path. This gives two different meanings to how optional path is used!
  }
  else
  {
    appLocation = Path.GetDirectoryName(assyLocation);
    appLocation = Path.GetDirectoryName(assyLocation);

    if (optionalPath != null)
    {
      appLocation = Path.Combine(appLocation, optionalPath.Value);
    }
  }

  string fullPath = Path.Combine(appLocation, assemblyName.Value);

  return FullPath.Create(fullPath);
}

不幸的是,在上面的代码中,可选路径存在双重用法。当模块管理器本身是一个嵌入式资源程序集时,加载程序集存在一个细微之处——在这种情况下,执行路径是一个空字符串,因为由 .NET 程序集通过单独实现的程序集解析器(此处未讨论)加载的程序集与执行程序集无关!这是一个非常奇怪的行为,在我写关于将程序集嵌入为资源的文章之前,应该忽略整个情况。在这种情况下,可选路径是解析程序集位置的完整路径。老实说,整个问题需要重构嵌入式资源程序集的处理方式。

实例化注册器

一旦程序集加载完毕,模块管理器就会实例化注册器。“注册器”是每个模块中的一个特殊类(只有一个这样的类),它实现了IModule接口中的方法。换句话说,我们的模块“知道”它们是模块,并且可以做一些特殊的事情,因为它们是模块。这些特殊的事情基本上取决于您。在我的库中,模块使用服务管理器进行初始化,以便模块可以注册它提供的服务。这在本书的下一篇文章中进行了介绍。现在,我们只需要知道每个模块都必须提供一个实现IModule的类。

/// <summary>
/// Instantiate and return the list of registrants -- assemblies with classes 
/// that implement IModule.
/// The registrants is one and only one class in the module that implements IModule, 
/// which we can then
/// use to call the Initialize method so the module can register its services.
/// </summary>
protected virtual List<IModule> InstantiateRegistrants(List<Assembly> modules)
{
  registrants = new List<IModule>();
  modules.ForEach(m =>
  {
    IModule registrant = InstantiateRegistrant(m);
    registrants.Add(registrant);
  });

  return registrants;
}

/// <summary>
/// Instantiate a registrant. A registrant must have one and only one class 
/// that implements IModule.
/// The registrant is one and only one class in the module that implements IModule, 
/// which we can then
/// use to call the Initialize method so the module can register its services.
/// </summary>
protected virtual IModule InstantiateRegistrant(Assembly module)
{
  var classesImplementingInterface = module.GetTypes().
    Where(t => t.IsClass).
    Where(c => c.GetInterfaces().Where(i => i.Name == "IModule").Count() > 0);

  Assert.That(classesImplementingInterface.Count() <= 1, 
             "Module can only have one class that implements IModule");
  Assert.That(classesImplementingInterface.Count() != 0, 
             "Module does not have any classes that implement IModule");

  Type implementor = classesImplementingInterface.Single();
  IModule instance = Activator.CreateInstance(implementor) as IModule;

  return instance;
}

初始化注册器

每个模块中的注册器在实例化后都可以进行初始化。初始化方法是ModuleManager类中的一个虚拟存根。

/// <summary>
/// Initialize each registrant. This method should be overridden by your application needs.
/// </summary>
protected virtual void InitializeRegistrants(List<IModule> registrants)
{
}

如果您的模块需要初始化,您可以派生自ModuleManager并实现您所需的特定初始化。

示例程序

我们的演示解决方案包含这四个项目。

  1. CommonInterface - 这包含了IModule定义。
  2. ModuleConsoleSpeak - 将消息输出到控制台。
  3. ModuleManager - 模块管理器的控制台演示。
  4. ModuleVoiceSpeak - 使用 .NET 的语音合成器说出消息。

示例程序实现了两个“扬声器”,一个输出控制台消息,另一个使用 .NET 的语音合成器说出消息。应用程序非常简单。

static void Main(string[] args)
{
  IModuleManager mgr = new ModuleManager();
  List<AssemblyFileName> moduleNames = GetModuleList(XmlFileName.Create("modules.xml"));
  mgr.RegisterModules(moduleNames, OptionalPath.Create("dll"));

  // The one and only module that is being loaded.
  IModule module = mgr.Modules[0];
  module.Say("Hello World.");
}

请注意应用程序如何从“dll”子文件夹加载模块。

dll”文件夹包含两个模块的程序集。

另请注意,生成后步骤会将模块复制到“dll”文件夹。

copy ModuleConsoleSpeak.dll ..\..\..\ModuleManager\bin\Debug\dll
copy ModuleVoiceSpeak.dll ..\..\..\ModuleManager\bin\Debug\dll

现在,通过更改modules.xml文件,应用程序将响应控制台窗口的消息输出或通过语音说出来。要说出文本,请使用此:

<?xml version="1.0" encoding="utf-8" ?>
<Modules>
  <Module AssemblyName='ModuleVoiceSpeak.dll'/>
</Modules>

要在控制台窗口上输出文本,请使用此:

<?xml version="1.0" encoding="utf-8" ?>
<Modules>
  <Module AssemblyName='ModuleConsoleSpeak.dll'/>
</Modules>

应用程序不引用模块。

请注意,在演示应用程序(称为“ModuleManager”,一个糟糕的名称!)中,没有对我们正在加载的模块的引用。

IModule

此实现仅用于演示目的!

namespace Clifton.Core.ModuleManagement
{
  public interface IModule
  {
    void Say(string text);
  }
}

在这里,我们看到了我之前谈到的关于运行时模块化应用程序的问题——通用接口必须存在于单独的程序集中!如果您不小心,这可能会导致大量仅定义接口和其他共享类型的程序集爆炸。我们将在其他文章中进一步讨论这个问题。

IModule实际实现与服务管理器(下一篇文章)相关联。

namespace Clifton.Core.ModuleManagement
{
  public interface IModule
  {
    void InitializeServices(IServiceManager serviceManager);
  }
}

ModuleConsoleSpeak

using System;

using Clifton.Core.ModuleManagement;

namespace ModuleConsoleSpeak
{
  public class Speak : IModule
  {
    public void Say(string text)
    {
      Console.WriteLine(text);
    }
  }
}

ModuleVoiceSpeak

using System.Speech.Synthesis;

using Clifton.Core.ModuleManagement;

namespace ModuleVoiceSpeak
{
  public class Speak : IModule
  {
    public void Say(string text)
    {
      SpeechSynthesizer synth = new SpeechSynthesizer();
      synth.SetOutputToDefaultAudioDevice();
      synth.Speak(text);
    }
  }
}

关于 IModule 接口

这仅为演示!将模块特定要求放入IModule中是推荐的。模块旨在实现各种各样的东西,您不能也不应该在IModule接口中描述这些实现。我在这里这样做只是因为演示模块管理器很简单,而且我知道我实现的两个模块非常有限并且具有共同的行为。在任何实际应用程序中,我都永远不会这样做,这就是为什么下一篇文章是关于服务管理器。

结论

演示说明了模块管理器的目的——能够改变应用程序的行为。

  1. 无需重新编译应用程序。
  2. 通过配置文件。

这就是我打算在这篇文章中做的,因为模块管理器是 Clifton 方法的核心组件。

关于模块与依赖注入

另一种解决此问题的方法是使用依赖注入——使用反射和类型信息,实例化一个实现特定接口的对象,并为其分配该接口类型的属性。我个人认为 DI 过于复杂(您也可以说我正在做的事情!),而且我见过的 DI 框架都臃肿、缓慢,并且调试应用程序变得困难。

此外,我以一种特定方式使用模块管理器——加载实现服务的程序集。注册过程简单易懂,易于用调试器跟踪,并且整个实现代码行数很少,这使得维护更加容易。

其他特性

延迟加载

在撰写本文时,模块管理器会立即加载所有模块——没有延迟加载的概念。未来可能会有用——“按需”加载模块,但这存在某些复杂性,因为服务管理器(这是下一篇文章)需要知道哪个模块用于特定实现。我实际上没有遇到过这种行为的需求,所以目前真的没有理由实现它。

卸载模块

同样,在运行时能够卸载模块的想法很有吸引力,因为您可能能够替换一个模块而无需关闭应用程序。但是,正如我在关于应用程序域的文章中所写的那样,性能和潜在的事件连接问题使得此功能有些不理想。

历史

  • 2016 年 8 月 25 日:初始版本
© . All rights reserved.