C#中实现Fluent Interface的三种方法

背景知识

Fluent Interface是一种通过连续的方法调用以完成特定逻辑处理的API实现方式,在代码中引入Fluent Interface不仅能够提高开发效率,而且在提高代码可读性上也有很大的帮助。从C# 3.0开始,随着扩展方法的引入,Fluent Interface也更多地被开发人员熟悉和使用。例如,当我们希望从一个整数列表中找出所有的偶数,并将这些偶数通过降序排列的方式添加到另一个列表中时,可以使用下面的代码:


i.Where(p => p % 2 == 0)

    .OrderByDescending(q => q)

    .ToList()

    .ForEach(r => result.Add(r));


这段代码不仅看起来非常清晰,而且在编写的时候也更符合人脑的思维方式,通过这些连续的方法调用,我们首先从列表i中寻找所有的偶数,然后对这些偶数进行排序并将排序后的值逐个添加到result列表中。

在实际应用中,Fluent Interface不仅仅是使用在类似上面的查询逻辑上,而它更多地是被应用开发框架的配置功能所使用,比如在Entity Framework Code First中可以使用Fluent API对实体(Entity)和模型(Model)进行配置,此外还有流行的ORM框架NHibernate以及企业服务总线框架NServiceBus等等,都提供了类似的Fluent API,以简化框架的配置过程。这些API都是Fluent Interface的具体实现。由于Fluent Interface的方法链中各方法的名称都具有很强的描述性,而且具有单一职责的特点,所以Fluent Interface也可以看成是完成某一领域特定任务的“领域特定语言(Domain Specific Language)”,比如在上面的例子中,Fluent Interface被用于查询领域,而在Entity Framework、NHiberante和NServiceBus等框架中,它又被用于框架的配置领域。

接下来,让我们首先看一下Fluent Interface的简单实现方式,并简要地讨论一下这种实现方式的优缺点,再来了解一下一种使用装饰器(Decorator)模式和扩展接口的实现方式。

Fluent Interface的简单实现

Fluent Interface的一种简单实现就是在类型的每个方法中对传入参数进行处理,然后返回该类型本身的实例,因此,当该类型的某个方法被调用后,进而还可以连续地直接调用其它的方法而无需在调用时指定该类型的实例。现假设我们需要实现某个服务接口IService,在这个接口中,要用到一个提供缓存功能的接口ICache以及一个提供日志记录的接口ILogger,为了让IService的实例能够以Fluent Interface的方式指定自己所需要的ICache接口和ILogger接口的实例,我们可以这样定义IService接口:


public interface IService

{

    ICache Cache { get; }

    ILogger Logger { get; }

    IService UseCache(ICache cache); // return ‘this' in implemented classes

    IService UseLogger(ILogger logger); // return ‘this' in implemented classes

}

于是,对IService实例的配置就变得非常简单,比如:


IService aService = new Service();

aService.UseCache(new AppfabricCache()).UseLogger(new ConsoleLogger());

这是最简单的Fluent Interface的实现方式,对于一些简单的应用场景,使用这种简单快捷的方式的确是个不错的选择,但在体验着这种便捷的同时,我们或许还需要进行更进一步的思考:

1.直接定义在IService接口上的UseCache和UseLogger方法会破坏IService本身的单一职责性,而这又是与软件设计的思想是冲突的。到底是用哪种缓存服务和哪种日志服务,这并不是IService需要考虑的问题。当然,C#的扩展方法可以很方便地把UseCache和UseLogger等方法从IService接口中剥离出去,但更合理的做法是,使用工厂来创建IService的实例,而创建实例的依据(上下文)则应该由其它的配置信息来源提供
2.无法保证上下文的正确性。在上面的例子中,这个问题并不明显,先调用UseCache还是先调用UseLogger并不会给结果造成任何影响。但在某些应用场景中,设置的对象之间本身就存在一定的依赖关系,比如在Entity Framework Code First的Entity Type Configuration中,只有当所配置的属性是字符串的前提下,才能够进一步对该属性的最大长度、是否是Unicode等选项进行设置,否则Fluent Interface将不会提供类似的方法调用。显然目前这个简单的实现并不能满足这种需求
3.需要首先创建IService类型的实例,然后才能使用UseCache和UseLogger等方法对其进行设置,如果在实例的创建过程中存在对ICache或者ILogger的依赖的话(比如在构造函数中希望能够使用ILogger的实例写一些日志信息等),那么实现起来就会比较困难了

鉴于以上三点分析,当需要在应用程序或开发框架中更为合理地引入Fluent Interface时,上述简单的实现方式就无法满足所有需求了。为此,我采用装饰器模式,并结合C#的扩展方法特性来实现Fluent Interface,这种方式不仅能够解决上面的三种问题,而且面向对象的设计会使Fluent Interface的扩展变得更加简单。

使用装饰器模式和扩展方法实现Fluent Interface

仍然以上文中的IService接口为例,通过分析我们可以得到两个启示:首先,对于IService的实例究竟应该是采用哪种缓存机制以及哪种日志记录机制,这就是一种对IService的实例进行配置的过程;其次,这种配置过程就相当于在每个配置阶段逐渐地向已有的配置信息上添加新的信息,比如最开始创建一个空的配置信息,在第一阶段确定了所选用的缓存机制时,就会在这个空的配置信息基础上添加与缓存相关的配置信息,而在第二阶段确定了所选用的日志记录机制时,又会在前一阶段获得的配置信息基础上再添加与日志记录相关的配置信息,这个过程正好是装饰器模式的一种应用场景。最后一步就非常简单了,程序只需要根据最终得到的配置信息初始化IService接口的实例即可。为了简化实现过程,我选择Microsoft Patterns & Practices Unity Application Block的IoC容器来实现这个配置信息的管理机制。选用Unity IoC容器的好处是,对接口及其实现类型的注册并没有先后顺序的要求,IoC容器会自动分析类型之间的依赖关系并对类型进行注册。事实上在很多应用程序开发框架中,也是用这种方式在框架的配置部分实现Fluent Interface的。

装饰器模式的引入

首先我们引入“配置器”的概念,配置器的作用就是对IService实例初始化过程中的某个方面(例如缓存或者日志)进行配置,它会向调用者返回一个Unity IoC容器的实例,以便调用方能够在该配置的基础上进行其它方面的配置操作(为了简化起见,下文中所描述的“配置”仅表示选择某种特定类型的实现,而不包含其它额外的配置内容)。我们可以使用如下接口对配置器进行定义:


public interface IConfigurator

{

    IUnityContainer Configure();

}

为了实现的方便,我们还将引入一个抽象类,该抽象类实现了IConfigurator接口,并将其中的Configure方法标识为抽象方法。于是,对于任何一种配置器而言,它只需要继承于该抽象类,并且重载Configure方法即可实现配置逻辑。该抽象类的定义如下:


public abstract class Configurator : IConfigurator

{

    readonly IConfigurator context;

 

    public Configurator(IConfigurator context)

    {

        this.context = context;

    }

 

    protected IConfigurator Context

    {

        get

        {

            return this.context;

        }

    }

 

    public abstract IUnityContainer Configure();

}

接下来就是针对不同的配置环节实现各自的配置器了。我们以缓存机制的配置为例,简要介绍一下“缓存配置器”的实现方式。

先定义一个名为ICacheConfigurator的接口,该接口实现了IConfigurator的接口,但它是一个空接口,并不包含任何属性、事件或方法的接口定义。引入这个接口的目的就是要在接下来的扩展方法定义中能够实现面向该接口的方法扩展,于是上文中讨论的第二个问题就能引刃而解,这将在接下来的“扩展方法的引入”部分进行讨论。事实上在很多成熟的应用程序和框架中也有类似的设计,比如将接口用作泛型约束类型等。因此,ICacheConfigurator的实现代码非常简单:


public interface ICacheConfigurator : IConfigurator

{

}

而作为“缓存配置器”而言,它只需要继承于Configurator类并实现ICacheConfigurator接口就可以了,代码如下:


public class CacheConfigurator<TCache> : Configurator, 

    ICacheConfigurator

    where TCache : ICache

{

 

    public CacheConfigurator(IConfigurator configurator)

        : base(configurator)

    {

    }

 

    public override IUnityContainer Configure()

    {

        var container = this.Context.Configure();

        container.RegisterType<ICache, TCache>();

        return container;

    }

}

从上面的代码中可以看到,TCache约束于ICache接口类型,而在Configure方法中,首先调用配置上下文(也就是配置器本身所包含的上一层配置器实例)的Configure方法,同时获得已配置的Unity IoC容器实例container,之后在container上继续调用RegisterType方法,将给定的缓存机制实现类型注册到container中,最后将container返回给调用者。

整个配置器部分的实现,可以用下面的类图进行总结:

扩展方法的引入

前面已经提到过,扩展方法可以将职责无关的方法定义从类型中移出,并在一个静态类中进行集中实现。在目前的这个例子中,扩展方法还能够帮助我们将类型继承的层次结构“扁平化”,使得Fluent Interface中各方法的衔接逻辑变得更加清晰。仍然以缓存配置部分为例,假设我们希望在获得了服务的配置之后,能够接着对缓存机制进行配置,在完成了缓存机制的配置后,才能开始对日志记录机制进行配置,那么我们就可以定义扩展方法如下:


public static ICacheConfigurator WithDictionaryCache(this IServiceConfigurator configurator)

{

    return new CacheConfigurator<DictionaryCache>(configurator);

}

public static ILoggerConfigurator WithConsoleLogger(this ICacheConfigurator configurator)

{

    return new LoggerConfigurator<ConsoleLogger>(configurator);

}

上面的WithDictionaryCache方法表示需要在Service的配置上采用基于字典的缓存机制,而WithConsoleLogger则表示在缓存配置的基础上,还需要选用控制台作为日志记录机制。

从上面的代码中我们还能了解到,扩展方法还能够很直观地定义各种配置之间的先后顺序,更改起来也非常方便。例如,如果缓存机制和日志记录机制的配置没有一个前后关系的话,那么我们可以将IServiceConfigurator作为WithConsoleLogger的第一个参数类型,而无需去修改代码中的其它任何部分。

接下来要做的,就是设计一个工厂类,使其能够根据我们的配置信息创建一个新的IService实例。

工厂类的实现

工厂类的实现就非常简单了,同样使用扩展方法,对IConfigurator类型进行扩展,在获得了Unity IoC容器的实例之后,只需要调用Resolve方法直接返回IService类型的实现类型就可以了。Resolve方法的使用,直接解决了上文中提到的第三个问题。工厂类的代码如下:


public static class ServiceFactory

{

    public static IToConfigConfigurator ToConfig()

    {

        return new ToConfigConfigurator();

    }

 

    public static IService Create()

    {

        return ToConfig().Service().Create();

    }

 

    public static IService Create(this IConfigurator configurator)

    {

        var container = configurator.Configure();

        if (!container.IsRegistered<ICache>())

            container.RegisterType<ICache, DictionaryCache>();

        if (!container.IsRegistered<ILogger>())

            container.RegisterType<ILogger, ConsoleLogger>();

        if (!container.IsRegistered<IService>())

            container.RegisterType<IService, Service>();

        return container.Resolve<IService>();

    }

}

测试

创建一个测试项目以便对我们所做的工作进行测试,比如下面的测试方法将会对IService的实现所采用的缓存机制类型和日志记录机制类型进行测试:


[TestMethod]

public void UseAppfabricCacheAndDatabaseLoggerTest()

{

    var service = ServiceFactory

        .ToConfig()

        .Service()

        .WithAppfabricCache()

        .WithDatabaseLogger()

        .Create();

    Assert.IsInstanceOfType(service.Cache, typeof(AppfabricCache));

    Assert.IsInstanceOfType(service.Logger, typeof(DatabaseLogger));

}

现在我们已经可以使用Fluent Interface对IService实例的初始化过程进行配置了。Fluent Interface的引入,更像是在使用一种自然语言对配置过程进行表述:Service factory, to config (the) service with Appfabric Cache (mechanism) (and) with Database Logger (mechanism)。

总结

本文首先介绍了Fluent Interface的相关知识,并给出了一种简单的实现方式。通过对简单实现方式的讨论,引出了可能存在的设计问题,进而选择了一种更为合理的实现方式,即通过使用装饰器模式和C#的扩展方法特性来实现Fluent Interface。这种全新的实现方式不仅能够解决所讨论的设计问题,而且这种面向对象的设计方式还为Fluent Interface的实现带来了一定的可扩展性。文章最后对这种实现方式进行了简单测试,同时也展示了Fluent Interface在实际中的应用。