在所有关于软件维护的故事中,功能的扩展是一个永恒的话题。正因为软件系统需要功能的扩展,需要新功能的加入,才使我们的编程需要那么多的设计。可以说,正是因为新功能的扩展,使得原有的系统质量下降;正是因为软件质量的下降,才使我们需要进行深入的分析与研究,制订设计原则,总结设计模式;正是因为要解决软件质量下降的问题,经过一番艰苦卓绝的摸索过程,我们才认识到系统重构才是解决该问题的最佳方案。
然而,事情总是这样的,每个系统当我们进行初次的设计时,设计思路、程序结构总是比较完美的。可是当初次设计结束后,我们在日后的维护中,开始往系统里添加新功能时,系统开始不完美了,甚至开始出现问题了,新增的功能总是或多或少有些水土不服。怎么办呢?要保证每次需求的变更时软件质量不会下降,必须记住这样一个原则:先重构再添加新功能。
添加新功能前先重构原有系统,其目的有两个:
- 软件的设计总是与软件的复杂程度有关的,原有的设计是在原有需求不复杂的条件下做出的,但随着新功能的加入,软件复杂度在发生着变化,因此必须要调整原有的设计以适应新的需求;
- 为了提高软件的可维护性与易变更性,添加新功能应遵循OCP原则。而要遵循OCP原则,我们应当在不添加新功能的前提下先进行重构,设计出可扩展点出来,然后再添加新功能。
是的,明白这两点非常重要。软件维护越来越困难,不是因为客户提出了需求变更,而是因为我们没有随着软件复杂度的增加改变我们的软件结构。软件需求起初比较简单,是几乎所有软件的共性。但随着软件复杂度的增加,我们却不敢有效地调整现有的软件结构,以适应新的需求,这就真正是我们的问题了。不敢调整,是害怕原有功能会出错,但不调整,则意味着我们软件设计的问题会越来越大,进而越来越难于维护。改与不改,我们面临着两难的抉择。
解决这个两难的难题其实不难,实际上就是一层窗户纸一桶就破了。试想,我们不敢修改原有代码的真正原因是因为害怕改出问题影响原有功能的正常运行。那么,我们找一个方法使我们在修改原有代码时不会出现问题,换句话说是出现问题以后会及时发现,则问题就可以解决,这个方法就是重构与测试。
客户判断一个功能是否正常运行的标准,就是当输入一个值后,能得到客户期望的结果,不管系统内部是怎样运行的。因此,建立这样一个测试用例,让软件系统在重构前后都能通过这些测试用例,就可以保证重构的正确性(关于如何建立,我们还会在后面仔细讨论)。重构以后,外部功能是一致的,但内部程序结构却变得更加易于添加新的功能,使新的功能与原有系统可以有机地融为一体,这才是我们的目的。说起来比较抽象,我们来举一个示例吧:
在许多系统中,只要有报表出现就有需求要实现Excel数据导出功能。在一个系统中,客户起初提出的需求是实现“全部导出”、“按选择导出”、“导出本页”。为此,我们设计了一个单选框,并在后台程序中编写了一个if语句,如果选择的是“全部导出”,则查询所有记录并导出;如果选择的是“按选择导出”,则从前端获得一个主键列表,即用户已选择的行,以此作为条件查询导出;如果选择的是“导出本页”,则查询本页数据并导出。
1 String exportTypeName = (String)params.get("exportType"); 2 if("exportAll".equals(exportTypeName)) { 3 //全部导出的代码 4 } else if("exportChoosen".equals(exportTypeName)) { 5 //按选择导出的代码 6 } else if("exportOnePage".equals(exportTypeName)) { 7 //导出本页的代码 8 }
这样的设计没有问题,也是大多数人首先想到的设计。但是,多个不同的选择放在一个类中必将为功能的扩展带来麻烦。随后,客户提出了新的需求,按页导出,即根据客户的要求,从第几页到第几页进行导出。按照前面的设计,我们必然是在原有基础上再增加一个if语句,实现按页导出。
1 String exportTypeName = (String)params.get("exportType"); 2 if("exportAll".equals(exportTypeName)) { 3 //全部导出的代码 4 } else if("exportChoosen".equals(exportTypeName)) { 5 //按选择导出的代码 6 } else if("exportOnePage".equals(exportTypeName)) { 7 //导出本页的代码 8 } else if("exportPageRange".equals(exportTypeName)) { 9 //按页导出的代码 10 }
但这样的设计违反了OCP原则,也是大多数系统代码质量下降的重要原因之一。在一些文章中,if语句被称为“罪恶之源”,因为大量使用if语句将会大大降低系统的可读性、可维护性与易变更性,使系统难于维护。因为不断添加的if语句很快会使代码由数百行膨胀到几千行,还会大量掺杂各种重复代码与糟糕设计。其根本原因就在于,它让一个类承载了过多的职责,降低了功能内聚而提高了功能耦合。它不仅加大了我们修改代码的难度,也将加大我们测试代码的成本,因为任何一项修改都必须要对所有功能进行测试。
因此,我们需要调整我们的代码结构,改变我们的设计(到这里也许你开始理解我所说的改变代码结构以适应新的需求的含义了吧)。我们说扩展新功能的设计应当符合OCP原则。怎样的设计才是符合OCP原则的呢?首先可以想到的是,让“按页导出”这个功能的代码放到另一个类中,而不写在原有类中。比如,我们可以创建一个新类ExportPageRange,通过接口Exporter接入到原类ExportBus,让ExportBus调用其相应的方法:
但是,这样的设计我们依然需要修改原类ExportBus,在if语句中调用接口:
1 String exportTypeName = (String)params.get("exportType"); 2 if ("exportAll".equals(exportTypeName)) { 3 //全部导出的代码 4 } else if ("exportChoosen".equals(exportTypeName)) { 5 //按选择导出的代码 6 } else if ("exportOnePage".equals(exportTypeName)) { 7 //导出本页的代码 8 } else if ("exportPageRange".equals(exportTypeName)) { 9 //按页导出的代码 10 Exporter exporter = new ExportPageRange(); 11 exporter.doExport(resultset); 12 return exporter.getFileInfo(); 13 }
加粗部分是我们不得不在原类中添加的代码。如果不使用这个if语句而让Exporter接口的实现类与判断条件建立一种联系,则问题可以得到解决。要实现这种联系有很多方法,其中一个方法就是建立配置文件,让配置文件中的名称与实现类关联起来就可以了,为此我们需要这样设计:
然后进行这样的配置:
1 <bean id="exportBus" class="com...reporter.bus.impl.ExportBusImpl"> 2 <description>导出数据BUS</description> 3 <property name="exportTypes"> 4 <map> 5 <entry key="exportAll"><!-- 全部导出 --> 6 <bean class="com...reporter.export.ExportAll"/> 7 </entry> 8 <entry key="exportOnePage"><!-- 导出本页 --> 9 <bean class="com...reporter.export.ExportOnePage"/> 10 </entry> 11 <entry key="exportChosen"><!-- 按选择导出 --> 12 <bean class="com...reporter.export.ExportChosen"/> 13 </entry> 14 15 <entry key="exportPageRange"><!-- 按页导出 --> 16 <bean class="com...reporter.export.ExportPageRange"/> 17 </entry> 18 </map> 19 </property> 20 </bean>
这样,配置文件中的entrykey就与导出程序的实现类建立了联系,因此在ExportBus中原来的那个if语句就演变成了这样:
1 String exportTypeName = (String)params.get("exportType"); 2 Exporter exporter = exportTypes.get(exportTypeName); 3 exporter.doExport(resultset); 4 return exporter.getFileInfo();
加粗的部分实质性替代了原来那个if语句。这样的设计,让各个不同类型的导出程序得到有效解耦,然后通过接口与配置文件实现动态地装配。这就是一种典型的可扩展点设计,当我们还有新的导出类型的功能需要扩展的时候,不需要修改原有的任何代码,而只需添加一个Exporter接口新的实现类,再进行相应的配置,功能就可以实现。这样的设计是可以满足OCP原则的,而在系统中实现这种可扩展性设计的功能点,我们就称之为“可扩展点”。
以上的设计是我们最终应当实现的设计,是结果。但要达到这样的设计,即分析整个设计的过程,我们真的没有修改原程序吗?不,我们修改了。那么这怎么叫符合OCP原则呢?问题十分犀利哈。转了那么大一圈,现在才是我要真正提出我的观点的时候了。我认为,要改善遗留系统的可维护性,要遵守OCP原则,并不是意味着实现新需求时不能修改原有代码。要遵从“两顶帽子”的设计原则,先重构原有的代码,使其具有可扩展功能,然后再添加新程序,使其满足OCP原则,这才是可扩展设计的关键之所在。
具体来说,就是第一步修改代码,第二步添加功能。第一步,修改原有代码,在保证原有功能不变的前提下,设计出可扩展点,使其在以后添加新功能时不必修改原有代码。在本例中就是将原有的三种导出方式从原有代码中抽取出来,形成Exporter接口与ExportAll、ExportOnePage、ExportChosen三个实现类,以及它们的配置文件。这样,Exporter接口就是数据导出方式的可扩展点。这时,由于没有添加任何新功能,我们可以编写测试代码进行测试,或者手工测试。
第二步,就是添加新功能。由于可扩展点已经做出来了,剩下的工作其实就很简单了:编写实现类ExportPageRange,然后配置到系统中,整个设计符合OCP原则。功能扩展就应当这样做,才能使我们的软件在维护中始终能保持高质量的代码。
(续)
相关文档:
遗留系统:IT攻城狮永远的痛
需求变更是罪恶之源吗?
系统重构是个什么玩意儿
我们应当改变我们的设计习惯
小步快跑是这样玩的(上)
小步快跑是这样玩的(下)
代码复用应该这样做(1)
代码复用应该这样做(2)
代码复用应该这样做(3)
做好代码复用不简单
软件可以这样维护
特别说明:希望网友们在转载本文时,应当注明作者或出处,以示对作者的尊重,谢谢!