使用装饰器模式[GoF95]和C#扩展方法实现配置系统的领域特定语言(DSL)
这两天在整理两年前写的一个打字练习的小游戏的代码,发现其中有个写法挺有意思:
private IEnumerable<LetterSprite> LetterSprites => from p in this where p is LetterSprite select p as LetterSprite;这段代码的意思是,从当前的类型(游戏场景)中找到所有的字母精灵(LetterSprite)。其实,要获取当前游戏场景中的所有字母精灵,代码可以有很多种写法,但这里采用了C#语言的LINQ形式,使得代码更加贴近于自然语言,让程序员一看就懂。 看到这里,也就让我想起了很多年前读过的一本书,叫《领域特定语言》,这本书的作者就是大名鼎鼎的马丁·福勒(Martin Fowler),在这本书中,福勒将领域特定语言(Domain Specific Language, DSL)定义为一种专门解决特定领域问题的计算机语言,它通常可以被分为两种类型:
- 内部DSL(Internal DSL):它的实现需要依赖某种计算机编程语言,这种计算机编程语言就是DSL的一个宿主语言,DSL的执行需要通过其宿主语言的编译。例如上面所示的LINQ语句。内部DSL还有两个更有趣的名字:内嵌式DSL(Embedded DSL)以及流畅接口(Fluent Interfaces)
- 外部DSL(External DSL):它不依赖于任何一种编程语言,外部DSL有自己的语法,执行需要依赖于自己的编译器或者解释程序。例如SQL,它是领域特定语言,因为它仅用于关系型数据库的操作和管理这一领域,由数据库引擎负责解析与执行
一个简单的配置对象
假设我们的框架需要有下面三个配置信息:- 数据库的类型
- 数据库的连接字符串
- 是否需要启用缓存
public class AppConfig { public string ConnectionString { get; set; } public bool EnableCaching { get; set; } public string DatabaseDriver { get; set; } }于是,在初始化框架的时候,我们可以直接新建一个AppConfig对象,然后将这个AppConfig对象传入框架以便初始化:
var config = new AppConfig { ConnectionString = "Server=localhost; Database=abc;", DatabaseDriver = "mssql", EnableCaching = true };这是一种非常直接方便的做法,不过在此我们打算尝试使用自定义的DSL来完成AppConfig对象的构建。举个例子,可能我们希望能够提供下面的这种编程代码来让AppConfig配置对象的创建变得更为易懂:
var appConfig = new AppConfigCreator() .Create() .UseDatabase("mssql") .WithConnectionString("Server=localhost; Database=abc;") .EnableCaching() .Configure();从这个例子可以看到,代码中的每一步所做的操作语义上都是非常清晰的:UseDatabase,WithConnectionString,表示我希望使用mssql数据库引擎来配置这个框架,并使用给定的字符串作为数据库连接字符串。在配置完数据库之后,我使用EnableCaching调用来表明我希望在框架中启用缓存技术,最后一个Configure调用就可以根据之前的输入来产生最终的AppConfig对象。这样的领域特定语言的设计,还有一个好处就是,从编程上能够保证语义的连贯性(或者说合理性):在没有指定使用哪种数据库之前,指定数据库连接字符串或许并没有什么意义,DSL能够保证当程序员输入“.”的时候,编辑器的智能提示会直接指引下一步需要配置的内容,当AppConfig对象变得非常复杂的时候,这种DSL能够给程序员提供很大的便利。 回到DSL的定义,由于这样的DSL需要依赖于C#语言本身面向对象的特点,而且它的执行是需要由C#编译器进行编译并由.NET CLR负责执行,所以很明显它是一种内部DSL;更进一步,它实现了Fluent Interface设计模式(也就是为什么有时候内部DSL被称为Fluent Interfaces的原因)。 仔细思考不难发现,在上面的代码中,我们是一步一步地对AppConfig对象进行设置,最后的Configure方法才返回真正配置好的AppConfig对象。这就好像是在对AppConfig对象进行装修:先吊顶,再刷墙,再铺地。很明显,我们可以使用GoF95装饰器模式来实现这样的结构。
使用GoF95装饰器模式来构造AppConfig对象
首先引入一个装饰器的结构,在设计模式相关的文章和书籍中,往往就是用“Decorator”一词进行介绍,这个词语用在这里显得太宽泛了。由于我们是对AppConfig对象进行设置,那就使用“Configurator”这个词语吧,表示是一个对于某种对象的“配置器”。下面的UML类图展示了这样的设计: Configurator抽象类实现了IConfigurator接口,同时它也聚合了一个IConfigurator接口的对象,以便通过所聚合的IConfigurator来获取上一步的AppConfig设置,然后对设置好的AppConfig对象做进一步处理。ConnectionStringConfigurator、DatabaseDriverConfigurator和EnableCachingConfigurator都是Configurator的子类,分别负责对AppConfig对象的不同部分进行配置。完整代码如下:public interface IConfigurator { AppConfig Configure(); } public abstract class Configurator : IConfigurator { private readonly IConfigurator _context; public Configurator(IConfigurator context) => _context = context; public AppConfig Configure() { var config = _context.Configure(); return DoConfigure(config); } protected abstract AppConfig DoConfigure(AppConfig config); } public sealed class ConnectionStringConfigurator : Configurator { private readonly string _connectionString; public ConnectionStringConfigurator(IConfigurator context, string connectionString) : base(context) => _connectionString = connectionString; protected override AppConfig DoConfigure(AppConfig config) { config.ConnectionString = _connectionString; return config; } } public sealed class DatabaseDriverConfigurator : Configurator { private readonly string _driverName; public DatabaseDriverConfigurator(IConfigurator context, string driverName) : base(context) => _driverName = driverName; protected override AppConfig DoConfigure(AppConfig config) { config.DatabaseDriver = _driverName; return config; } } public sealed class EnableCachingConfigurator : Configurator { private readonly bool _enableCaching = false; public EnableCachingConfigurator(IConfigurator context, bool enableCaching) : base(context) => _enableCaching = enableCaching; protected override AppConfig DoConfigure(AppConfig config) { config.EnableCaching = _enableCaching; return config; } }于是,我们可以使用下面的代码来创建一个AppConfig对象:
[Test] public void UseDecoratorPatternTest() { var appConfig = new DatabaseDriverConfigurator( new ConnectionStringConfigurator( new EnableCachingConfigurator( new AppConfigConfigurator(), true), "Server=localhost; Database=abc;"), "mssql") .Configure(); Assert.IsTrue(appConfig.EnableCaching); }或许你会感觉有些复杂,有点过度设计了,对于这个简单的例子确实如此,不过这样的设计达到了一种关注点分离的目的,在一个框架的设计中,也能够很好地帮助扩展(后面我会介绍这部分)。
利用C#扩展方法实现领域特定语言
C#的扩展方法都是基于某个特定的类型进行扩展,因此,我们可以引入一些接口,然后针对这些接口来提供扩展方法。下面的类图展示了在加入这些接口之后的设计: 以ConnectionStringConfigurator的类的层次为例,它的代码如下:public interface IConnectionStringConfigurator : IConfigurator { } public sealed class ConnectionStringConfigurator : Configurator, IConnectionStringConfigurator { private readonly string _connectionString; public ConnectionStringConfigurator(IConfigurator context, string connectionString) : base(context) => _connectionString = connectionString; protected override AppConfig DoConfigure(AppConfig config) { config.ConnectionString = _connectionString; return config; } }然后,我们就可以对这些新增的接口来实现扩展方法:
public static class Extensions { public static IDatabaseDriverConfigurator UseDatabase( this IAppConfigConfigurator configurator, string databaseDriverName) => new DatabaseDriverConfigurator(configurator, databaseDriverName); public static IConnectionStringConfigurator WithConnectionString( this IDatabaseDriverConfigurator configurator, string connectionString) => new ConnectionStringConfigurator(configurator, connectionString); public static IEnableCachingConfigurator EnableCaching( this IConnectionStringConfigurator configurator) => new EnableCachingConfigurator(configurator, true); }注意:上面代码中的IAppConfigConfigurator接口主要目的就是产生一个新的AppConfig的实例,以便这个实例能够在接下来的处理中被逐步初始化。代码如下:
public interface IAppConfigConfigurator : IConfigurator { } public sealed class AppConfigConfigurator : IAppConfigConfigurator { private readonly AppConfig _appConfig = new AppConfig(); public AppConfig Configure() => _appConfig; }再引入一个AppConfigCreator的类:
public class AppConfigCreator { private readonly IAppConfigConfigurator _configurator = new AppConfigConfigurator(); public IAppConfigConfigurator Create() => _configurator; }于是,一个简单的DSL就设计完成了,现在就可以使用类似下面的流畅接口来创建一个AppConfig的实例:
[Test] public void UseDslTest() { var appConfig = new AppConfigCreator() .Create() .UseDatabase("mssql") .WithConnectionString("Server=localhost; Database=abc;") .EnableCaching() .Configure(); Assert.IsTrue(appConfig.EnableCaching); }
框架设计的扩展性
在开发框架的设计中,框架的扩展性是一个非常重要的部分,对于上面的这种DSL的设计,它也能很好地支持扩展。假设我们为我们自己设计的框架提供PostgreSQL的数据库访问组件,并且当初始化数据库驱动的时候,需要指定PostgreSQL的版本,那么,我们就可以在提供PostgreSQL数据库访问组件的类库中,实现定制化的Configurator:public interface INpgsqlDriverConfigurator : IConfigurator { } public sealed class NpgsqlDriverConfigurator : Configurator, INpgsqlDriverConfigurator { private readonly Version _dbVersion; public NpgsqlDriverConfigurator(IConfigurator context, Version dbVersion) : base(context) => _dbVersion = dbVersion; protected override AppConfig DoConfigure(AppConfig config) { config.DatabaseDriver = $"npgsql;version={_dbVersion.Major}.{_dbVersion.Minor}"; return config; } }然后在相同的程序集中,设计一套自定义的扩展方法:
public static class Extensions { public static INpgsqlDriverConfigurator UseNpgsql( this IAppConfigConfigurator configurator, Version dbVersion) => new NpgsqlDriverConfigurator(configurator, dbVersion); public static IConnectionStringConfigurator WithConnectionString( this INpgsqlDriverConfigurator configurator, string connectionString) => new ConnectionStringConfigurator(configurator, connectionString); }那么在使用的时候,就可以直接用UseNpgsql这个方法来指定我们希望框架使用PostgreSQL数据库访问组件: