使用装饰器模式[GoF95]和C#扩展方法实现配置系统的领域特定语言(DSL)

By | 2022年5月7日

这两天在整理两年前写的一个打字练习的小游戏的代码,发现其中有个写法挺有意思:

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,它是领域特定语言,因为它仅用于关系型数据库的操作和管理这一领域,由数据库引擎负责解析与执行

在很多情况下,计算机系统的设计人员并不会为某一外部DSL专门设计一套程序语法,而是利用诸如XML或者YAML这样的通用描述性语言来定义一种专用于某个特定领域的语法规则,然后在系统中根据输入的XML或者YAML文件来干预系统的行为。比较常见的有:游戏开发领域中的基于XML的地图块(Tiles)定义、.NET桌面程序的app.config文件、Kubernetes的配置系统等等。

其实我们也可以在平时设计系统的时候,利用面向对象和开发语言本身的特点来设计一些DSL,以简化开发人员的编程体验。设想我们打算设计一套应用程序开发框架,通常对于最终用户(框架的最终用户就是程序员)来说,要使用一套开发框架,首先就要对其进行配置。我们可以设计一套DSL来专门完成框架的配置,以便开发人员能够使用几近自然语言的方式来配置开发框架。

一个简单的配置对象

假设我们的框架需要有下面三个配置信息:

  • 数据库的类型
  • 数据库的连接字符串
  • 是否需要启用缓存

那么,我们可以很容易地设计出下面这个类,以保存配置信息:

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类图展示了这样的设计:

image

 

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#的扩展方法都是基于某个特定的类型进行扩展,因此,我们可以引入一些接口,然后针对这些接口来提供扩展方法。下面的类图展示了在加入这些接口之后的设计:

image

以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数据库访问组件:

image

 

代码下载

【点击此处】下载本文案例代码

(总访问量:61;当日访问量:1)

发表评论

您的电子邮箱地址不会被公开。

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据