专栏名称: 逸言
文学与软件,诗意地想念。
目录
相关文章推荐
程序员小灰  ·  这款AI编程工具,将会取代Cursor! ·  昨天  
程序员的那些事  ·  热搜第一!DeepSeek百万年薪招AI人才 ... ·  2 天前  
OSC开源社区  ·  国内AI适配再下一城:天数智芯加入,Deep ... ·  4 天前  
程序员的那些事  ·  o3-mini ... ·  6 天前  
程序员小灰  ·  如何用DeepSeek来变现?90%的人都不知道 ·  5 天前  
51好读  ›  专栏  ›  逸言

第三章 封装变化

逸言  · 公众号  · 程序员  · 2023-03-17 20:00

正文


◇ 作者:张逸

◇ 字数:1万5千字

◇ 阅读时间:37分钟

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

3.1  设计模式之鹄的

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

GOF对设计模式的分类,已经彰显了“封装变化”的内涵与精髓。创建型模式的目的就是封装对象创建的变化。例如工厂方法模式和抽象工厂模式,通过建立抽象的工厂类,封装对象创建所引起的可能变化。建造者模式则是对对象内部的创建进行封装,由于细节对抽象的可替换性,使得将来面对对象内部创建方式的变化时,可以灵活地进行扩展或替换。封装还意味着有效的重用,将那些可能重复的实现逻辑封装为方法或单独的类,则可以避免因为这些实现的变化,而导致所谓“霰弹式修改”[ 代码坏味道的一种,参见Martin Fowler的著作《重构——改善既有代码的设计》。]的代码坏味道。原型模式与单例模式无疑遵循了这样的设计思想,将创建原型或单例对象的实现逻辑封装了起来。
至于结构型模式,它关注的是对象之间组合的方式。本质上,如果对象结构可能存在变化,主要在于其依赖关系的改变。若要封装对象结构的变化,就需要尽可能解除或弱化对象之间的依赖关系。从依赖关系的强弱性来看,继承的耦合度要强于组合(合成或聚合),因为继承的子类受制于父类,一旦父类发生变化,就会影响到所有的子类。因此,应该优先选择对象的组合,而不是继承,即合成/聚合复用原则。此外,为了进一步降低组合关系的耦合度,还应该遵循依赖倒置原则,对要组合的类进行抽象,使得调用者依赖于抽象,而不是具体的实现。在结构型模式中,桥接模式体现的就是这样的思想。当然,我们也绝不可低估继承的力量,它是多态与抽象的实现基础。若能合理地利用继承与组合,则可以更加灵活地表达对象之间的依赖关系。例如装饰器模式,描述的就是对象间可能存在的多种组合方式,这种组合方式是一种装饰者与被装饰者之间的关系。封装这种组合方式,抽象出专门的装饰对象,仍然是“封装变化”的体现。
行为模式关注的是对象的行为。该类型的模式需要做的是对可能变化的行为进行抽象,通过封装达到整个架构的可扩展性。例如策略模式,就是将可能变化的策略或算法抽象为一个独立的接口或抽象类,从而实现未来策略的扩展。命令模式、状态模式、访问者模式、迭代器模式概莫如是,或者封装一个请求(命令模式),或者封装一种状态(状态模式),或者封装“访问”的方式(访问者模式),或者封装“遍历”算法(迭代器模式)。这些模式所要封装的行为,恰恰是软件架构中最不稳定的部分,扩展的可能性也最大。将这些行为封装起来,利用抽象的特性,就提供了扩展的可能。
设计模式之鹄的,正是通过封装变化的方法,最大限度地保证软件的可扩展性。面对纷繁复杂的需求变化,虽然不可能完全解决因为变化带来的可怕梦魇;然而,如能在设计之初预见某些变化,仍有可能在一定程度上避免未来存在的变化给软件架构带来的灾难性伤害。即使在设计之初无法完全预见这些变化,我们也可以通过重构来改善既有代码的软件结构,以满足需求的变化。

3.2  如何封装变化

3.2.1  封装对象行为的变化

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

public class Log{     public Log(string logContent)     {         this.Content = logContent;     }     public string Content     {         get;         set;     }
public void Write() { //实现内容 }}
当客户需要调用日志的功能时,可以创建日志对象,完成日志的记录。
Log log = new Log("log");log.Write();
随着日志记录的频繁使用,日志文件逐渐增多,日志的查询与管理也变得越来越不方便。此时,客户提出改变日志的记录方式,将日志内容写入到指定的数据表中,以利于日志的查询与管理。显然,此前的设计无法从容应对新的需求变化。
现在我们回到设计之初,想象一下日志API的设计,需要考虑到这样的变化吗?如本书第1章所述,软件设计存在两种理念,即演进的设计和计划的设计。分析本例,要求设计者在设计之初就考虑到日志记录方式在未来的可能变化,并不容易。再者,如果在最开始就考虑全面的设计,会产生设计冗余。此时,采用计划的设计固然具有一定的前瞻性,但它对设计者的要求过高,同时还会产生过度设计的缺陷。如果采用演进的设计,在遇到需求变化时,可以利用重构技术改进现有的设计。那么,在演进的设计过程中,还需要考虑未来的再一次变化吗?这是一个见仁见智的问题。对于本例而言,完全可以直接修改Write()方法,通过接收一个类型判断的参数来解决问题。但这样的设计,自然要担负因为未来可能的再一次变化,导致代码大量修改的危险。例如,我们要求日志记录到指定的xml文件中,那么之前的修改又将再一次陷入困境。
所以,变化是完全可能发生的。在时间和技术能力允许的情况下,我更倾向于将变化给设计带来的影响降到最低。此时,就需要封装变化。
在封装变化之前,需要弄清楚究竟是什么发生了变化?从需求看,是日志记录的方式发生了变化。从这个概念分析,可能会导致两种不同的结果。一种情形是我们将日志记录的方式视为一种行为;另一种情形则从对象的角度来分析,我们将各种方式的日志看作不同的对象,它们调用相同的接口方法,区别仅在于创建的日志对象不同。前者需要我们封装“日志行为的变化”,后者需要我们封装“日志对象创建的变化”。
封装“对象行为的变化”,在这里就意味着封装日志记录行为的变化。也就是说,需要把日志记录行为抽象为一个单独的接口,然后再分别定义不同的实现,如图3-1所示。
图3-1  封装日志记录行为的变化
如果熟悉设计模式,可以看到图3-1所表示的结构正是访问者模式[ 如果将记录日志的行为看做是用户的一种命令请求,则可以认为是命令模式的应用。也可以认为它是策略模式,封装的日志记录行为实则是记录日志的某种策略。由此可见,在应用设计模式时,不应拘泥于模式的名称,而应关注模式的思想。]的体现。因为我们将Log对象传递到了ILogWriter接口中,同时又在Log类的Write()方法中接收了ILogWriter类型对象,实现了双重委派。此时,ILogWriter接口相当于日志对象的访问者。
由于我们对日志记录行为进行了接口抽象,用户就可以自由地扩展日志记录的方式,只需要新增的类实现ILogWriter接口即可。至于Log对象,仅存在与ILogWriter接口的弱依赖关系。
public class Log{     public Log(string logContent)     {         this.Content = logContent;     }     public string Content     {         get;         set;     }
public void Write(ILogWriter logWriter) { logWriter.Write(this); }}
利用访问者模式实现日志记录,显著的好处是对于日志记录的行为是可扩展的。当定义一个新的类去实现ILogWriter接口时,Log类的实现并不需要做任何改变。我们利用了面向对象思想的多态原理,Log类的Write()方法中传递的参数,其编译时类型为ILogWriter接口类型;运行时类型则根据具体实例化的对象类型而定。例如:
Log log = new Log("log");log.Write(new XmlLogWriter());
此时,传入Write()方法中的ILogWriter参数对象,运行时类型为XmlLogWriter类型,因此执行Log对象的Write()方法,实际上被委派到XmlLogWriter类的Write()方法。

3.2.2  封装对象创建的变化

面对同样的需求扩展,我们也可以封装“日志对象创建的变化”来支持日志API的可扩展性。在这种情况下,日志会根据记录方式的不同,被定义为不同的对象。当需要记录日志时,就创建相应的日志对象,然后调用该对象的Write()方法,实现日志的记录。此时,可能发生变化的是日志对象的创建。这样为创建逻辑定义的专门类,在设计模式中通常被称为“工厂类”,创建的对象则被称为产品。如果目标对象的创建是变化的,就需要封装这种变化,因而定义的工厂类也应该是抽象的,修改后的设计如图3-2所示。

图3-2  封装日志对象创建的变化
图3-2是工厂方法模式的体现,类LogFactory是所有工厂类的抽象父类,专门负责Log对象的创建。如果用户需要记录相应的日志,例如要求日志记录到数据库,则需要先创建具体的LogFactory对象。
LogFactory factory = new DBLogFactory();
然后在应用程序中,通过LogFactory对象来创建新的Log对象。
Log log = factory.Create();log.Write("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所示。






请到「今天看啥」查看全文