封装变化

本文为《软件设计精要与模式》第二章

软件设计最大的敌人,就是应付需求不断的变化。变化有时候是无穷尽的,于是项目开发就在反复的修改更新中无限期地延迟交付的日期。变化如悬在头顶的达摩克斯之剑,令许多软件工程专家一筹莫展。正如无法找到解决软件开发的“银弹”,要彻底将变化扼杀在摇篮之中,看来也是不可能完成的任务。只有积极地面对“变化”,方才是可取的态度。极限编程(Extreme Programming,XP)的倡导者与布道者Kent Beck提出要“拥抱变化”,从软件工程方法的角度,给出了应对“变化”的解决方案。如果从软件设计方法的角度出发,要在开发过程中应对未来可能的变化,解决之道则是——封装变化。

设计模式之鹄的

设计模式是“封装变化”思想的最佳阐释。无论是创建型模式、结构型模式还是行为型模式,归根结底都是寻找软件中可能存在的“变化”,然后利用抽象的方式对这些变化进行封装。由于抽象没有具体的实现,就代表了一种无限的可能性,使得扩展成为可能。所以在设计之初,我们除了要实现需求所设定的用例之外,还需要标定可能或已经存在的“变化”之处。封装变化,最重要的一点就是发现变化,或者说是寻找变化。

GOF对设计模式的分类,已经彰显了“封装变化”的内涵与精髓。创建型模式的目的就是封装对象创建的变化。例如Factory Method模式和Abstract Factory模式,通过建立抽象的工厂类,封装未来对象的创建所引起的可能变化。Builder模式则是对对象内部的创建进行封装,由于细节对抽象的可替换性,使得将来面对对象内部创建方式的变化,可以灵活地进行扩展或替换。

至于结构型模式,它关注的是对象之间组合的方式。从本质上说,如果对象结构可能存在变化,主要在于其依赖关系的改变。当然,对于结构型模式来说,处理变化的方式不仅仅是封装与抽象那么简单,还要合理地利用继承与聚合的方法,灵活地表达对象之间的依赖关系。例如Decorator模式,描述的就是对象间可能存在的多种组合方式,这种组合方式是一种装饰者与被装饰者之间的关系。因此,封装这种组合方式,抽象出专门的装饰对象显然是“封装变化”的体现。同样地,Bridge模式封装的则是对象实现的依赖关系,而Composite模式所要解决的则是对象间存在的递归关系。

行为型模式关注的是对象的行为。该类型的模式需要做的是对变化的行为进行抽象,通过封装达到整个架构的可扩展性。例如Strategy模式,就是将可能存在变化的策略或算法抽象为一个独立的接口或抽象类,从而实现未来策略的扩展。Command模式、State模式、Visitor模式、Iterator模式概莫如是,或者封装一个请求(Command模式),或者封装一种状态(State模式),或者封装“访问”的方式(Visitor模式),或者封装“遍历”算法(Iterator模式)。这些模式所要封装的行为,恰恰是软件架构中最不稳定的部分,扩展的可能性也最大。将这些行为封装起来,利用抽象的特性,就提供了扩展的可能。

所以,设计模式之鹄的,正是通过封装变化的方法,最大限度地保证软件的可扩展性。面对纷繁复杂的需求变化,虽然不可能完全解决因为变化带来的可怕梦魇,然而,如能在设计之初预见某些变化,仍有可能在一定程度上避免未来存在的变化为软件架构带来的灾难性伤害。软件开发最大的遗憾是没有“银弹”,但从软件设计方法的角度来看,设计模式也是一枚不错的“铜弹”了。

如何封装变化

封装用户请求的变化

考虑一个日志记录工具。目前需要提供一个方便的日志API,使得客户可以轻松地完成日志的记录。该日志要求被记录到指定的文本文件中,记录的内容属于字符串类型,其值由客户提供。我们可以非常容易地定义一个日志对象:

public class Log
{
    public void Write( string target, string log)
    {
        //实现内容
    }
}

当客户需要调用日志的功能时,可以创建日志对象,完成日志的记录。

Log log = new Log();
log.Write( "error.log", "log");

然而,随着日志记录的频繁使用,日志文件逐渐增多,日志的查询与管理也变得越来越不方便。此时,客户提出,需要改变日志的记录方式,将日志内容写入到指定的数据表中,以利于日志的查询与管理。显然,此前的设计无法从容应对新的需求变化。

现在我们回到设计之初,想象一下日志API的设计,需要考虑到这样的变化吗?软件设计存在两种理念,即演进的设计和计划的设计。分析本例,要求设计者在设计之初就考虑到日志记录方式在未来的可能变化,并不容易。再者,如果在最开始就考虑全面的设计,会产生设计上的冗余。此时,采用计划的设计固然具有一定的前瞻性,但它对设计者的要求过高,同时还会产生过度设计的缺陷。如果采用演进的设计,在遇到需求变化时,我们可以利用重构技术改进现有的设计。那么,在演进的设计过程中,我们还需要考虑未来的再一次变化吗?这是一个见仁见智的问题。对于本例而言,我们完全可以直接修改Write方法,接受一个类型判断的参数以解决问题。但这样的设计,自然要担负因为未来可能的再一次变化,导致代码大量修改的危险,例如,我们要求日志记录到指定的xml文件中,那么之前的修改又再一次陷入困境。

所以,变化是完全可能发生的。在时间和技术能力允许的情况下,我更倾向于将变化对设计带来的影响降低到最低。此时,我们就需要封装变化。

在封装变化之前,我们需要弄清楚究竟是什么发生了变化?从需求看,是日志记录的方式发生了变化。从这个概念分析,可能会导致两种不同的结果。一种情形是我们将日志记录的方式视为一种行为,确切地说,是用户的一种请求。另一种情形则从对象的角度来分析,我们将各种方式的日志看作不同的对象,它们调用相同的接口方法,区别仅在于创建的日志对象不同。前者需要我们封装“用户请求的变化”,后者需要我们封装“日志对象创建的变化”。

封装“用户请求的变化”,在这里就意味着封装日志记录行为的变化。也就是说,我们需要把日志记录行为抽象为一个单独的接口,然后再分别定义不同的实现,如图1所示。


图1  封装日志记录行为的变化

如果熟悉设计模式,可以看到图1所表示的结构正是Strategy模式的体现。由于我们对日志记录行为进行了接口抽象,用户就可以自由地扩展日志记录的方式,只需要新增的类实现ILogWriter接口即可。至于Log对象,仅存在与ILogWriter接口的弱依赖关系。

public class Log
{
    private ILogWriter m_logWriter;
    public Log(ILogWriter logWriter)
    {
        m_logWriter = logWriter;
    }
    public void Write( string target, string logValue)
    {
        m_logWriter.Write(target, logValue);
    }
}

利用Strategy模式实现日志记录,显著的好处是对于日志记录的行为是可扩展的。当我们定义一个新的类去实现ILogWriter接口时,Log类的实现并不需要做任何改变。在这里,实则是利用了面向对象思想的多态原理,Log类的构造函数中传递的参数,其编译期类型为ILogWriter接口类型;至于运行期类型,则根据具体实例化的对象类型而定。例如:

Log log = new Log( new XmlLogWriter());
log.Write( "error.log", "log");

此时,传递入Log类构造函数的ILogWriter参数对象,运行期类型为XmlLogWriter类型,因此执行log对象的Write方法,实际是执行XmlLogWriter类的Write方法。

封装对象创建的变化

面对同样的需求扩展,我们也可以通过封装“日志对象创建的变化”来支持日志API的可扩展性。在这种情况下,日志会根据记录方式的不同,被定义为不同的对象。当我们需要记录日志时,就创建相应的日志对象,然后调用该对象的Write方法,实现日志的记录。此时,可能会发生变化的是日志对象的创建。在C#中,对象的创建由new操作符完成,然而,我们可以定义专门的类来管理对象的创建,这个类在设计模式中被称为“工厂类”。由于目标对象的创建是变化的,因此要封装这种变化,就需要定义的工厂类同时也是抽象的,修改后的设计如图2所示。


图2  封装日志对象创建的变化

图2是Factory Method模式的体现,类LogFactory是所有工厂类的抽象父类,专门负责Log对象的创建。如果用户需要记录相应的日志,例如要求日志记录到数据库,则需要先创建具体的LogFactory对象。

LogFactory factory = new DBLogFactory();

然后在应用程序中,通过LogFactory对象来创建新的Log对象。

Log log = factory.Create();
log.Write( "error.log", "log");

如果用户需要修改日志记录的方式为文本文件时,仅需要修改LogFactory对象的创建即可。

LogFactory factory = new TxtLogFactory();

为了更好地理解“封装对象创建的变化”,我们再来看一个例子。假如,我们需要设计一个数据库组件,它能够访问微软的SQL Server数据库。根据ADO.NET的知识,我们需要使用SqlConnection、SqlCommand、SqlDataAdapter等对象。

如果仅就SQL Server而言,在访问数据库时,我们可以直接创建这些对象。

SqlConnection connection = new SqlConnection(strConnection);
SqlCommand command = new SqlCommand(connection);
SqlDataAdapter adapter = new SqlDataAdapter(command);

显然,在一个数据库组件中,到处充斥着这样的语句,是很不合理的。它充满了僵化的坏味道,一旦要求支持其他数据库时,原有的设计就需要彻底地修改,这为扩展带来了巨大的困难。

我们来思考一下,以上拙劣的设计应该做怎样的修改?假定该数据库组件要求或者将来要求支持多种数据库,那么对于Connection、Command、DataAdapter等对象而言,就不能具体化为SQL Server对象。也就是说,我们需要为这些对象建立一个继承的层次结构,并为它们分别建立抽象的父类或者接口。然后针对不同的数据库,定义不同的具体类。以Connection对象为例,如图3所示。


图3  Connection对象的层次结构

我为Connection对象抽象了一个统一的IConnection接口,支持各种数据库的Connection对象都实现该接口。同样的,Command对象和DataAdapter对象也采用了相似的结构。现在,我们要创建对象时,可以利用多态的原理创建。

IConnection connection = new SqlConnection(strConnection);

随着访问数据库的不同,对象的创建可能会发生变化,如果采用现在的结构,会存在无法应对对象创建发生变化的问题。利用“封装变化”的原理,我们有必要把创建对象的责任单独抽象出来,进行有效地封装。例如,我们为相关的数据库对象建立一个专门的抽象工厂类DBFactory,并由它负责创建Connection、Command、DataAdapter对象。至于具体的工厂类,则与目标对象的结构相同,并根据数据库类型的不同,定义不同的工厂类,类图如图4所示。


图4  DBFactory的类图

图4是一个典型的Abstract Factory模式的体现。类DBFactory中的各个方法均为抽象方法,所以我们也可以用接口来代替该类的定义。各个具体工厂类,负责创建相对应的数据库类型对象。以SqlDBFactory类为例,创建各自对象的代码如下。

public class SqlDBFactory: DBFactory
{
    public override IConnection CreateConnection( string strConnection)
    {
         return new SqlConnection(strConnection);
    }
    public override ICommand CreateCommand(IConnection connection)
    {
         return new SqlCommand(connection);
    }
    public override IDataAdapter CreateDataAdapter(ICommand command)
    {
        return new SqlDataAdapter(command);
    }
}

现在要创建访问SQL Server数据库的相关对象,就可以利用工厂类来获得。首先,我们可以在程序的初始化部分创建工厂对象。

DBFactory factory = new SqlDBFactory();

然后利用该工厂对象创建相应的Connection、Command、DataAdapter对象。

IConnection connection = factory.CreateConnection(strConnection);
ICommand command = factory.CreateCommand(connection);
IDataAdapter adapter = factory.CreateDataAdapter(command);

可以看到,当我们引入工厂类后,在Connection等对象的创建语句中,已经成功地消除了与具体的数据库类型相依赖的关系。在上面的代码中,并未出现如SqlConnection、SqlCommand等具体类型对象。也就是说,现在创建对象的方式是完全抽象的,是与具体实现无关的。无论是访问何种数据库,都与这几行代码无关,那么面对未来可能的变化,这些实现仍然“放之四海而皆准”,实现了一劳永逸的理想模式。

在抽象工厂模式中,涉及到的对象创建的变化,将全部抽象到抽象工厂类中。因此,在本例中,如果需要更改访问数据库的类型,我们只需要修改创建工厂对象的那一行代码即可,例如,将SQL Server类型修改为Oracle类型。

DBFactory factory = new OracleDBFactory();

很显然,这样的方式提高了数据库组件的可扩展性。我们将可能发生变化的部分封装起来,放到程序固定的部分,例如初始化部分,或者作为全局变量。一种更佳的方式,是将这些可能发生变化的地方,放到配置文件中,通过读取配置文件的值,创建相对应的对象。如此一来,不需要修改代码,也不需要重新编译,仅仅是修改xml文件,就能实现数据库类型的改变。例如,我们创建如下的配置文件。


    "db" value= "SqlDBFactory"/>

创建工厂对象的代码相应修改如下:

string factoryName = ConfigurationManager.AppSettings[ "db"];

//DBLib为数据库组件的程序集名
DBFactory factory =
     (DBFactory)Activator.CreateInstance( "DBLib",factoryName).Unwrap();

当需要将访问的数据库类型修改为Oracle数据库时,只需将配置文件中的value值修改为“OracleDBFactory”即可。我们将变化的内容转交到易于修改的配置文件中,使得程序结构具有很好的可扩展性,较好地解决了未来可能发生的需求变化所带来的问题。

封装算法实现的变化

我们再来设想这样一个需求,我们需要为自己的框架提供一个负责排序的组件。目前需要实现的是冒泡排序算法和快速排序算法,根据“面向接口编程”的思想,我们可以为这些排序算法提供一个统一的接口ISort,在这个接口中有一个Sort方法,它能接受一个object数组参数。对数组进行排序后,返回该数组。接口的定义如下:

public interface ISort
{
    object[] Sort( object[] values);
}

类图如图5所示。


图5  排序算法的类图

排序是有顺序之分的,例如升序,或者降序,返回的结果也不相同。我们可以利用if语句来实现这一目的,例如,在QuickSort类中:

public class QuickSort:ISort
{
    private string m_sortType;
    public QuickSort( string sortType)
    {
        m_sortType = sortType;
    }
    public object[] Sort( object[] values)
    {
        object[] objs = new object[values.Length];
            if (m_sortType.ToUpper().Trim() == "ASCENDING")
         {
            //执行升序的快速排序
         }
         else
         {
            //执行降序的快速排序
         }
         return objs;
    }
}

当然,我们也可以将string类型的SortType定义为枚举类型,以减少出现错误的可能性。仔细阅读代码,我们发现这样的代码是非常僵化的。一旦需要扩展,要求我们增加新的排序顺序,例如字典顺序,那么我们面临的工作会非常繁重。如果变化增加,在我们的Sort方法中,if语句的长度会随之而增加,我们会像一个暮年的老者,喋喋不休地重复着自己的修改,直到我们失去耐性。而使用框架的客户则已经没有耐心发出烦人的抱怨了,永无休止地修改使得他们已经做出了放弃的选择。

作为框架的开发者,自然不愿意看到这样的结果。其实我们可以通过良好的设计将变化所带来的修改扼杀在摇篮之中。实际上,如果我们仔细分析排序方法,会发现数组元素大小的比较,才是排序算法中最关键的一环,它决定了谁排列在前,谁排列在后。它并不属于排序算法,而是一种比较的策略,或者说是比较的算法。

再仔细分析实现ISort接口的类,例如QuickSort类,它在实现排序算法的时候,需要对两个对象做比较。按照重构Extract Method原则,我们可以在Sort方法中抽取出一个私有方法Compare,通过返回的布尔值,决定哪个对象在前,哪个对象在后。如此一来,我们成功地将排序算法中最不稳定的一环,转移到了Compare方法中。

上述方法还不够彻底。既然比较算法是最可能发生变化的,我们就应该利用“封装变化”的原理,抽象该行为,定义一个专有的接口ICompare,然后分别定义实现升序、降序或者字典排序的类对象,如图6所示。


图6  实现比较算法

我们可以为每一个实现了ISort接口的类构造函数引入ICompare接口对象,从而建立起排序算法与比较算法的弱耦合关系,例如QuickSort类。

public class QuickSort:ISort
{
    private ICompare m_compare;
    public QuickSort(ICompare compare)
    {
        m_compare = compare;
    }
    public object[] Sort( object[] values)
    {
        object[] objs = new object[values.Length];

        //……实现略

        for ( int i = 0; i < beSorted.Length - 1; i++)
         {
            if (m_Compare.Compare(beSorted[i], beSorted[i+1))
            {
                //略
            }
        }

        //……实现略

        return objs;
    }  
}

类图如图7所示。


图7  利用Strategy模式实现排序算法

通过对比较算法的封装以应对它的变化,显然是Strategy模式的设计,它使得我们对比较算法的扩展成为可能。由于ISort接口类型的对象与ICompare对象是弱耦合关系,它的定义并不因ICompare对象的变化而变化。当需求发生更改时,如上的结构可以安然无恙。

事实上,这里的排序算法也可能是变化的,例如,框架要求实现二叉树排序。由于我们已经引入了“面向接口编程”的思想,我们完全可以轻易地添加一个新的类BinaryTreeSort,实现ISort接口。对于调用方而言,ISort接口的实现,同样是一个Strategy模式。此时的类结构,是一个对扩展开放的状态,完全能够适应类库调用者新需求的变化。

图7还体现了另外一种思想,就是排序接口与比较接口间的组合关系。利用聚合方式而非继承方式,我们可以随意将排序算法对象与比较算法对象进行排列组合,这实际上是一种对象间组合关系的封装。以上的实现也可以看作是Bridge模式的体现。

再以一个电子商务网站的设计为例。在该项目中要求对客户的订单进行管理,例如插入订单。考虑到访问量的关系,系统为订单管理提供了同步和异步的方式。显然,在实际应用中需要根据具体的应用环境,决定使用这两种方式的其中一种。为了应对这样一种可能会很频繁的变化,如果我们不采取“封装变化”的设计思想,那么实现的困难是不可想象的。

我们当然应该利用Strategy模式,因为插入订单的行为,实则就是一种插入订单的策略。因此,我们为此策略建立抽象级别的对象,如IOrderStrategy接口。

public interface IOrderStrategy
{
    void Insert(OrderInfo order);
}

然后分别定义两个类OrderSynchronous和OrderAsynchronous实现IOrderStrategy接口。类结构如图8所示。


图8  订单策略的实现

当领域业务对象Order类需要插入订单时,将根据IOrderStrategy接口的运行期类型,执行相关的订单插入策略,如下代码所示。

public class Order
{
    private IOrderStrategy m_orderStrategy;
    public Order(IOrderStrategy orderStrategy)
    {
        m_orderStrategy = orderStrategy;
    }
     public void Insert(OrderInfo order)
    {
        m_orderStrategy.Insert(order);
    }
}

依赖注入

在前面的叙述中,我避重就轻地绕过了一个可能出现的问题,那就是具体的策略对象究竟应该怎样建立?需知,一个外部具体对象的引入,必然会给一个模块带来与该外部模块的依赖。这样一来,封装变化所带来的丰功伟绩也许会因为这样一个瑕疵,而变得荡然无存,至少,设计上的缺陷也会令我们心有戚戚焉。

确实如此,以订单管理为例。由于用户随时都可能会改变插入订单的策略,因此对于业务层的订单领域对象而言,绝不能与具体的订单策略对象产生耦合关系。也就是说,在领域对象Order类中,不能new一个具体的订单策略对象,如下面的代码:

IOrderStrategy orderStrategy = new OrderSynchronous();

虽然在前面的实现中,我们是利用领域对象的构造函数传递IOrderStrategy接口对象。但这样的实现仅仅是将具体订单策略对象的创建推迟到了领域对象的调用者那里而已,可谓是“治标不治本”的做法。我们当然也期望能够有一种理想的状态,就是具体对象的创建永远都不要在代码中出现。事实上,模块与模块间之所以产生依赖关系,正是因为有具体对象的存在。一旦在一个模块中创建了另一个模块中的具体对象,依赖就产生了。现在,我们的目的就是要将这些依赖消除。

依赖存在于模块外部,而对象的创建又是必需的。我们需要利用相关技术将外部的依赖注入到模块中,这就是所谓的“依赖注入”(Dependency Injection)。有关“依赖注入”技术,设计大师Martin Fowler撰有专文《IoC容器和Dependency Injection模式》,对依赖注入有着很好的描述。目前,实现了依赖注入的轻量级容器已经应用在许多框架产品中,如Java平台下的Spring、PicoContainer等。在.NET平台下,也有Spring.NET等容器支持依赖注入的方式。

如果没有使用诸如Spring.NET等轻量级IoC(Inversion of Control,控制反转)容器,若要实现依赖注入,则可以利用配置文件结合反射技术来完成。例如,在领域对象Order类中,可以如此实现:

public class Order
{
    private static readonly IOrderStategy orderInsertStrategy =
        LoadInsert Strategy();
    private static IOrderStrategy LoadInsertStrategy()
    {
        //通过配置文件找到具体的订单策略对象
            string path = ConfigurationManager.AppSettings[ "OrderStrategyAssembly"];
            string className = ConfigurationManager.AppSettings[ "OrderStrategyClass"];

        //通过反射创建对象实例
           return (IOrderStrategy)Assembly.Load(path).CreateInstance(className);
    }
}

在配置文件web.config中,配置如下的节:

"OrderStrategyAssembly" value= "BLL"/>
"OrderStrategyClass" value= "BLL.OrderSynchronous"/>

这其实是一种折中的Service Locator模式。将定位并创建依赖对象的逻辑直接放到对象中,不失为一种好方法,尤其是需要依赖注入的对象不太多时。但我们也可以认为这是一种无奈的妥协办法,一旦这种依赖注入的逻辑增多,就会给开发者带来一定的麻烦。此时,也许我们需要考虑应用专门的轻量级IoC容器了。

利用抽象的方式封装变化,固然是应对需求变化的王道,但它也仅仅能解除调用者与被调用者相对的耦合关系,只要还涉及具体对象的创建,即使引入了创建型模式,例如Factory Method模式,具体的工厂对象的创建依然是必不可少的。不要小看这一点点麻烦,需知“千里之堤,溃于蚁穴”,牵一发而动全身,小麻烦可能会酿成大灾难。对于那些业已被封装变化的对象,我们还应该学会利用“依赖注入”的方式来彻底解除两者之间的耦合;至于封装变化,则是实现模块解耦的重要前提。

你可能感兴趣的:(封装变化)