.NET应用框架架构设计实践 - 为应用程序框架提供多样化的配置方式
Microsoft .NET为应用程序开发人员提供了丰富的处理配置数据的编程模型与类型库。拥有这些组件,开发人员及用户可以方便地在无需重新编译应用程序的情形下,通过对配置数据的设置,对应用程序的执行行为与结果进行干预,从而使得相同的应用程序能够在不改变源程序的情况下,满足不同应用场景的特殊需求。就应用程序框架而言,在大多数情况下,开发人员也需要能够对其进行配置,以便获得不同的框架功能特性。比如,我们会在.NET应用程序的配置文件中加入对NHibernate框架的配置数据,如果应用程序还用到了Microsoft Patterns & Practices Enterprise Library(EntLib)的话,还需要加入对EntLib的配置数据,以使得各种不同的框架能够满足应用程序正常运行的需要。因此,对配置数据的读取、管理和使用,是每个应用程序框架所必备的功能。
在此我们不打算进一步细究基于Microsoft .NET的应用程序是如何读取和管理配置数据的。做过.NET应用程序开发的读者朋友都知道,配置数据都是写在应用程序的配置文件里的,比如app.config或者web.config,然后使用System.Configuration命名空间下提供的类来读取配置文件以获得配置数据;也可以使用ConfigurationSection、ConfigurationElement等类来定义应用程序自己的配置数据结构。有关这部分内容请读者朋友自己参阅相关文档或者网页资料。需要说明的是,app.config也好,web.config也好,都是基于AppDomain的,具体表现是:在可执行应用程序中,app.config以.exe.config或者.dll.config的扩展名形式,与主程序并存在同一个目录下;在Web应用程序中,web.config则在虚拟目录的根目录下。当然,这些都是一般情况,事实上.NET是允许开发人员改变app.config或者web.config的文件名的。
现在,让我们来简单地考虑一下单体测试工具的实现,这是件非常有趣的事情。最简单的方式就是,在这个单体测试工具中提供一些用于指定测试类型和测试方法的Attributes,比如类似Microsoft Visual Studio 2010单体测试框架中的TestClassAttribute、TestInitializeAttribute、TestMethodAttribute等,然后创建一个用于单体测试的Class Library,在这个Class Library中使用这些Attributes定义测试类型以及测试方法。接着,你会使用一个基于Windows Forms或者WPF的测试工具来加载这个Class Library,通过.NET反射技术读取Class Library中所有的测试类型与测试方法并执行这些方法。在执行的过程中,使用try…catch捕获来自Assert的异常以此判断单体测试是否执行通过。这个过程看似简单,但实际上还是有不少细节是需要仔细琢磨的,其中最重要的一个问题就是配置文件。如果你需要测试的仅仅是一些数值运算或者算法,那恭喜你,你无须为这个配置文件的问题而烦恼;但如果你需要测试的是一个应用程序框架,而这个框架的运行是需要依赖一些配置数据的,那你就会头疼了:我应该把这些配置数据写在哪里?是写在这个测试工具的配置文件里吗?肯定不合理,否则每次做测试前就需要对测试工具的配置文件进行修改;那我应该写在Class Library的配置文件里?对不起,这样做不奏效,因为测试工具会将Class Library加载到自己的AppDomain中,于是Class Library就无法读取它自己的配置文件了。
要解决这个问题,就需要变更测试工具对Class Library的加载方式,由简单的Assembly.Load方式转换成将其加载到另一个AppDomain中。在创建这个新的AppDomain时,使用AppDomainSetup.ConfigurationFile来设置配置文件,类似代码如下:
private void LoadRemoteProxy() { AppDomainSetup ads = new AppDomainSetup(); ads.ApplicationBase = Path.GetDirectoryName(this.AssemblyFile); ads.ApplicationName = Path.GetFileName(this.AssemblyFile); string configFileName = this.AssemblyFile + ".config"; if (File.Exists(configFileName)) { ads.ConfigurationFile = configFileName; } ads.DisallowBindingRedirects = false; ads.DisallowCodeDownload = true; ads.ShadowCopyFiles = "true"; ads.ShadowCopyDirectories = Environment.GetFolderPath(Environment.SpecialFolder.InternetCache); clientDomain = AppDomain.CreateDomain(Guid.NewGuid().ToString(), null, ads); object[] args = new object[]{ this.AssemblyFile, typeof(TestClassAttribute), typeof(ClassInitializeAttribute), typeof(ClassCleanupAttribute), typeof(TestInitializeAttribute), typeof(TestCleanupAttribute), typeof(TestMethodAttribute) }; IRemoteProxy p = (IRemoteProxy)clientDomain .CreateInstanceAndUnwrap( typeof(IRemoteProxy).Assembly.FullName, "VisualBenchmark.Proxies.RemoteProxy", false, 0, null, args, null, null); if (p != null) { this.AssemblyName = p.AssemblyName; this.Proxy = p; } }
这段代码来自之前我所写的一个基于单体测试的性能比较工具Visual Benchmark。该工具的主页地址是:http://visualbenchmark.codeplex.com。有兴趣的读者可以在该主页上获得源代码以进一步参考学习。
在此我们讨论的重点并不是这个单体测试工具,而是那个被测试的框架。通过将这个被测试的框架加载到单独的AppDomain,我们可以使其在被测试的过程中成功地读取配置数据。然而问题又来了:如果我们需要在同一次测试中,检验被测框架对不同的配置数据所产生的不同行为,而配置文件却又只能有一个,那么这样的测试需求又如何实现呢?解决该问题的答案就是:我们应该为应用程序框架提供多样化的配置方式,而不仅仅是对配置文件的支持。
设计
在使用.NET技术开发应用程序框架的时候,我们通常会设计一种配置数据结构,在代码中使用继承于ConfigurationSection、ConfigurationElement以及ConfigurationElementCollection的类来表示这样的数据结构,之后,在应用程序的配置文件中,就可以很方便地使用与之相对应的XML标记(Tags)来表示配置数据了。这些内容对于一个资深的.NET开发人员来说,应该是非常熟悉的。可以说,.NET中的配置文件是一种最基本最常见的配置数据提供方案,于是,当我们希望为应用程序框架提供多样化的配置方式时,这种基于配置文件的方式就成为了其中一种必不可少的选择。除此之外,我们还可以根据框架本身的特性,提供诸如基于其它XML文件、基于数据库或者直接代码编写的配置方式。
为了能让框架同时支持配置文件以及其它的配置方式,在设计上就需要将这些不同的配置方式统一起来。上面也分析过,配置文件的方式是必不可少的,因此,我们可以让这些方式对框架透明,而使得框架仅感知到当前只有配置文件这样一种方式。换句话说,在框架的实现过程中,当需要用到配置数据时,框架代码仅会使用到那些继承于ConfigurationSection、ConfigurationElement以及ConfigurationElementCollection的类型。
首先,定义一个配置源(Configuration Source)接口,该配置源接口会对配置节(Configuration Section)进行封装,由于配置节包含了对其它配置元素(Configuration Element)和配置元素集(Configuration Element Collection)的引用,因此,配置节实际上是整个配置信息聚合的聚合根。然后,针对不同的配置方式,创建实现配置源接口的类,并在这些类中以不同的方式初始化配置节对象,比如可以通过System.Configuration.ConfigurationManager来读取配置文件中的配置节,或者通过访问数据库来获得配置数据等。最后,框架仅需以依赖注入等方式获得配置源的具体实现,即可获得配置数据。下面的类图展示了这种设计的参与者及其之间的关系,为了简化描述,该类图仅包含了基于配置文件和基于源代码编写的两种配置实现方式。
上图中的IConfigurationSource接口就是配置源接口,ConfigFileConfigurationSource和RegularConfigurationSource是实现了该接口的两个类,ConfigFileConfigurationSource类通过使用标准的.NET配置系统以从配置文件中读入配置节;而RegularConfigurationSource则向调用方提供了SetElement、AddElement等公有方法,以便开发人员可以直接在代码中调用这些方法来向框架提供配置数据。应用程序框架的Application类会在构造函数中接收IConfigurationSource的具体实现以便初始化其ConfigSource属性。ApplicationFactory类是一个单例类(Singleton),它的CreateApplication静态方法同样接收IConfigurationSource的具体实现以便创建Application实例,与此同时,ApplicationFactory会向外公开CurrentApplication属性,以便在框架的任何地方都能够获得当前运行的Application实例,进而获得定义在Application实例中的配置源。
采用这种设计,要为框架提供新的配置方式就变得轻而易举了。例如,假设我们希望框架还能够从数据库读取配置数据,那么我们只需要创建一个DatabaseConfigurationSource的类,使其实现IConfigurationSource接口,而在这个类中,通过访问数据库来设置Config属性即可。
实现
假设某框架的配置节定义如下:
using System.Configuration; public class ApplicationElement : ConfigurationElement { [ConfigurationProperty("provider", IsKey=true, IsRequired=true)] public string Provider { get { return (string)base["provider"]; } set { base["provider"] = value; } } } public class ObjectContainerElement : ConfigurationElement { [ConfigurationProperty("provider", IsKey=true, IsRequired=true)] public string Provider { get { return (string)base["provider"]; } set { base["provider"] = value; } } } [ConfigurationCollection(typeof(ObjectContainerElement), AddItemName="objectContainer", CollectionType=ConfigurationElementCollectionType.BasicMap)] public class ObjectContainerElementCollection : ConfigurationElementCollection { protected override string ElementName { get { return "objectContainer"; } } public override ConfigurationElementCollectionType CollectionType { get { return ConfigurationElementCollectionType.BasicMap; } } protected override ConfigurationElement CreateNewElement() { return new ObjectContainerElement(); } protected override object GetElementKey(ConfigurationElement element) { return (element as ObjectContainerElement).Provider; } public void Add(ObjectContainerElement element) { this.BaseAdd(element); } } public class FrameworkConfigSection : ConfigurationSection { [ConfigurationProperty("application", IsRequired=true)] public ApplicationElement Application { get { return (ApplicationElement)base["application"]; } set { base["application"] = value; } } [ConfigurationProperty("objectContainers")] public ObjectContainerElementCollection ObjectContainers { get { return (ObjectContainerElementCollection)base["objectContainers"]; } set { base["objectContainers"] = value; } } }
根据上面的设计分析,配置源的实现如下:
public interface IConfigurationSource { FrameworkConfigSection Config { get; } } public class ConfigFileConfigurationSource : IConfigurationSource { private FrameworkConfigSection config; public ConfigFileConfigurationSource() { this.ReadFromConfigFile(); } private void ReadFromConfigFile() { this.config = (FrameworkConfigSection) ConfigurationManager.GetSection("frameworkConfig"); } public FrameworkConfigSection Config { get { return this.config; } } } public class RegularConfigurationSource : IConfigurationSource { private FrameworkConfigSection config; public RegularConfigurationSource() { config = new FrameworkConfigSection(); config.Application = new ApplicationElement(); config.ObjectContainers = new ObjectContainerElementCollection(); } public void AddElement(ObjectContainerElement element) { config.ObjectContainers.Add(element); } public void SetElement(ApplicationElement element) { config.Application = element; } public void AddObjectContainer(string provider) { config.ObjectContainers.Add(new ObjectContainerElement { Provider = provider }); } public void SetApplication(string provider) { config.Application = new ApplicationElement { Provider = provider }; } public FrameworkConfigSection Config { get { return this.config; } } }
在框架中,通常需要一个引导器(BootStrapper)来启动一些框架所依赖的服务或者进行一些数据准备工作。为了简化描述,在此我们仅用上面实现部分介绍的Application以及ApplicationFactory来模拟引导器的这部分功能。首先定义IApplication接口,然后创建一个实现该接口的类Application:
public interface IApplication { IConfigurationSource ConfigSource { get; } } public class Application : IApplication { private readonly IConfigurationSource configSource; public Application(IConfigurationSource configSource) { this.configSource = configSource; } public IConfigurationSource ConfigSource { get { return this.configSource; } } }
最后,ApplicationFactory单件类(Singleton)会通过CreateApplication方法创建IApplication的实例,并将已创建的实例返回给调用者,同时保持对已创建实例的引用,以便在框架中任何地方都能够通过ApplicationFactory单件来获得当前执行的IApplication实例。
public class ApplicationFactory { private static readonly ApplicationFactory instance = new ApplicationFactory(); private IApplication currentApplication; private static readonly object lockObj = new object(); private ApplicationFactory() { } public static ApplicationFactory Instance { get { return instance; } } public static IApplication CreateApplication(IConfigurationSource source) { lock(lockObj) { if (instance.currentApplication == null) { lock(lockObj) { instance.currentApplication = new Application(source); } } return instance.currentApplication; } } public IApplication CurrentApplication { get { return currentApplication; } } }
以上是框架中对多样化配置方式支持的实现部分。在框架的其它部分,则可以使用下面的方式获得所需的配置信息:
// 框架初始化时,可以用以下的方式创建IApplication实例。比如可以在控制台 // 应用程序或Windows Forms应用程序的Main函数,或者Web应用程序的Global.asax // 中执行这部分代码: IConfigurationSource configFileConfigSource = new ConfigFileConfigurationSource(); // 使用web/app.config配置文件 IApplication application = ApplicationFactory.CreateApplication(configFileConfigSource); // TODO: 在application上执行其它操作 // 在框架中,使用下面的方式访问配置数据: FrameworkConfigSection configSection = ApplicationFactory.Instance.CurrentApplication.ConfigSource.Config; // TODO: 使用configSection获取配置数据
当然,我们完全可以不依赖于app/web.config配置文件,而直接使用上面定义的RegularConfigurationSource,以编写代码的方式向框架提供配置数据。这种方式不仅能解决上面提到的框架测试问题,而且还能为开发人员提供强类型和智能感知的支持。比如:
IConfigurationSource regularSource = new RegularConfigurationSource(); regularSource.SetApplication(typeof(Application).AssemblyQualifiedName); regularSource.AddObjectContainer(typeof(UnityContainer).AssemblyQualifiedName); IApplication application = ApplicationFactory.CreateApplication(regularSource);
面向领域特定语言的配置开发模式
通过上面的分析不难得知,要实现多样化的配置方式,无论是基于配置文件的,还是采用其它的实现方式,都离不开配置节(Configuration Section)以及配置数据结构的开发和维护。对于小型的应用程序框架,通常可以使用手工的方式来编写这些配置代码;但对于中大型应用程序框架,为了提供强大的开发能力,配置节和配置数据结构往往比较复杂,此时使用手工方式来维护配置代码就显得费时费力了。为了能够有效地维护这些配置代码,我们可以采用某些领域特定语言(Domain-Specific Language, DSL)来开发配置节和数据结构,然后通过各种代码产生手段实现自动化代码生成。于是,在开发应用程序框架的过程中,当需要变更配置节或配置数据结构时,开发人员就无需去面对繁杂的代码,只需修改领域特定语言即可,剩下的工作都可直接交给自动化代码生成引擎去处理。在设计和开发框架的配置系统时,使用领域特定语言的好处不仅仅在于自动化代码生成,它还能为如下两种应用场景提供支持:
- 在产生代码的同时,可以产生配置数据结构的XML Schema,以便在使用Visual Studio编辑配置文件的时候使用智能感知(IntelliSense)技术
- 在生成的代码中加入组件模型(Component Model)相关的CLR特性,这将大大简化配置文件编辑器的开发工作
开源社区中有一款配置节设计工具,基本上可以满足上面两条需求,该工具的官方地址是:http://csd.codeplex.com。它能够同时支持 Visual Studio 2005、2008和2010等多个版本。对Visual Studio 2010的支持是以扩展包的方式实现的。在我的个人博客中,有篇文章对该工具进行了简要的介绍,文章地址是:http://www.cnblogs.com/daxnet/archive/2011/09/16/2178377.html,有兴趣的读者可以阅读参考。
领域特定语言在应用程序框架配置系统中的另一个应用就是Fluent Interface。根据《DSLs in Boo Domain Specific Languages in .NET》一书中的介绍,领域特定语言基本上可以分为四大类:External DSL、Graphical DSL、Internal/Embedded DSL以及Fluent Interface。结合上面所述设计,在IConfigurationSource接口上应用Fluent Interface可以为应用程序框架的使用者带来更直观的编程体验。现在,让我们对上面的IConfigurationSource进行扩展,看看Fluent Interface为编程带来的便捷。我们采用.NET 3.0所提供的扩展方法(Extension Methods)来实现这一效果。
public static class FluentInterfaceProvider { public static IConfigurationSource SetApplication(this IConfigurationSource source, string provider) { source.Config.Application = new ApplicationElement { Provider = provider }; return source; } public static IConfigurationSource AddObjectContainer(this IConfigurationSource source, string provider) { source.Config.ObjectContainers.Add(new ObjectContainerElement { Provider = provider }); return source; } } // 在使用的时候,就可以: IConfigurationSource configSource = new RegularConfigurationSource(); configSource .SetApplication(typeof(Application).AssemblyQualifiedName) .AddObjectContainer(typeof(UnityContainer).AssemblyQualifiedName);
对于领域特定语言本身的相关知识,在此不作过多讨论,我会在其它的文章中进行详细介绍。
总结
本章节首先以一个单体测试工具为例,论证了为应用程序框架提供多样化的配置方式的必要性,然后针对该需求给出了一种可行的设计方案,同时以伪代码的方式实现了这种设计。在章节的最后,我们还讨论了应用程序框架中配置系统设计与开发的最佳实践,即使用领域特定语言来维护配置节和配置数据结构,并在使用框架的过程中,利用领域特定语言来获得开发上的便捷。开发应用程序框架,同时也需要为之设计一套合理的、完整的配置数据结构和配置方式,而本章节则对此给出了一种解决方案。在实际中,开发人员完全可以不必按此解决方案来实现框架的配置系统,但它作为配置系统设计的最佳实践,为开发人员带来了一定的参考价值。