2 开闭原则(Open-Closed Principle,OCP)
2.1 什么是开闭原则
开闭原则是面向对象设计中“可复用设计”的基石,是面向对象设计中最重要的原则之一,其它很多的设计原则都是实现开闭原则的一种手段。
1988年,Bertrand Meyer在他的著作《Object Oriented Software Construction》中提出了开闭原则,它的原文是这样:“Software entities should be open for extension,but closed for modification”。翻译过来就是:“软件实体应当对扩展开放,对修改关闭”。这句话说得略微有点专业,我们把它讲得更通俗一点,也就是:软件系统中包含的各种组件,例如模块(Modules)、类(Classes)以及功能(Functions)等等,应该在不修改现有代码的基础上,引入新功能。开闭原则中“开”,是指对于组件功能的扩展是开放的,是允许对其进行功能扩展的;开闭原则中“闭”,是指对于原有代码的修改是封闭的,即不应该修改原有的代码。
2.2 如何实现开闭原则
实现开闭原则的关键就在于“抽象”。把系统的所有可能的行为抽象成一个抽象底层,这个抽象底层规定出所有的具体实现必须提供的方法的特征。作为系统设计的抽象层,要预见所有可能的扩展,从而使得在任何扩展情况下,系统的抽象底层不需修改;同时,由于可以从抽象底层导出一个或多个新的具体实现,可以改变系统的行为,因此系统设计对扩展是开放的。
我们在软件开发的过程中,一直都是提倡需求导向的。这就要求我们在设计的时候,要非常清楚地了解用户需求,判断需求中包含的可能的变化,从而明确在什么情况下使用开闭原则。
关于系统可变的部分,还有一个更具体的对可变性封装原则(Principle of Encapsulation of Variation, EVP),它从软件工程实现的角度对开闭原则进行了进一步的解释。EVP要求在做系统设计的时候,对系统所有可能发生变化的部分进行评估和分类,每一个可变的因素都单独进行封装。
我们在实际开发过程的设计开始阶段,就要罗列出来系统所有可能的行为,并把这些行为加入到抽象底层,根本就是不可能的,这么去做也是不经济的,费时费力。另外,在设计开始阶段,对所有的可变因素进行预计和封装也不太现实,也是很难做得到。所以,开闭原则描绘的愿景只是一种理想情况或是极端状态,现实世界中是很难被完全实现的。我们只能在某些组件,在某种程度上符合开闭原则的要求。
通过以上的分析,对于开闭原则,我们可以得出这样的结论:虽然我们不可能做到百分之百的封闭,但是在系统设计的时候,我们还是要尽量做到这一点。
对于软件系统的功能扩展,我们可以通过继承、重载或者委托等手段实现。以接口为例,它对修改就是是封闭的,而对具体的实现是开放的,我们可以根据实际的需要提供不同的实现,所以接口是符合开闭原则的。
2.3 开闭原则能够带来什么好处
如果一个软件系统符合开闭原则的,那么从软件工程的角度来看,它至少具有这样的好处:
可复用性好。
我们可以在软件完成以后,仍然可以对软件进行扩展,加入新的功能,非常灵活。因此,这个软件系统就可以通过不断地增加新的组件,来满足不断变化的需求。
可维护性好。
由于对于已有的软件系统的组件,特别是它的抽象底层不去修改,因此,我们不用担心软件系统中原有组件的稳定性,这就使变化中的软件系统有一定的稳定性和延续性。
2.4 开闭原则与其它原则的关系
开闭原则具有理想主义的色彩,它是面向对象设计的终极目标。因此,针对开闭原则的实现方法,一直都有面向对象设计的大师费尽心机,研究开闭原则的实现方式。后面要提到的里氏代换原则(LSP)、依赖倒转原则(DIP)、接口隔离原则(ISP)以及抽象类(Abstract Class)、接口(Interace)等等,都可以看作是开闭原则的实现方法。
实例分析
开闭原则:设计一个模块的时候,应当使这个模块可以在不修改原有代码的前提下被扩展。
这个原则是保证系统具有扩展性的基本原则。我理解有几个要点:1、要能够复用;2、扩展时只增加新方法、新类;3、不得不修改代码时,修改的范围必须是局部的、隐藏的;
通常变更有三种方式,一种是横向变更,例如售票系统,原来只能售火车票,现在要可以售机票;第二种是纵向变更,例如在某个流程里插入新活动或跳过活动;第三种是局部修改,就是原有功能的业务规则发生了变化。对于前两种变更比较容易处理,只要在设计时注意抽象,通过接口、继承、override或event即可扩充。对于第三种变更,估计要修改代码了。虽然可以这样分类,但实际上以上三种变更通常是同时发生的、相互交织的。
以库存管理业务单据为例,有出库单、入库单、移库单等。通常单据结构都很相似,包含头表,行表,但个别字段有差异。新增一个单据,先在头表插入一条记录,然后在行表插入若干记录。更新单据时,先更新头表记录,然后清空词单据在行表里的原有记录,再插入新的行表记录,删除单据的过程也非常相似。此外,在单据增删改时要记录日志,在单据提交时,还要修改库存。所有这些操作十分相似,可以抽象出来。
现在要设计一个单据处理通用业务类,负责单据暂存、修改、提交、删除几个基本业务,以及相关的日志和库存操作。
1、分析
单据操作: Save()/Submit()/Delete()/Log()/ChangeStock()
相关数据: 头表实体、行表实体集;与日志有关的一些属性;与库存有关的一些属性;插入头表时,要有一个接口获取单据编号;因为插入行表时外键值需要用到头表记录的主键值,所以需要有个接口获取头表主键值,并有一个接口给行表实体的外键赋值;
可能的变更: 1、将来可能增加审批功能,单据提交后审判通过才改变库存;2、增加新的单据类型;3、将来增加订单管理,那么库存操作除了出入库、还会增加在途转入库存、库存转出在途等功能。
2、设计
方案1、抽象类+子类,只关注操作,不关注数据;
单据处理类 BillProcess (抽象类):
公有虚方法 Save()/Submit()/Delete()
私有虚方法 Log()/ChangeStock()
扩展:增加审批功能,只需在基类增加新的虚方法Audit,子类实现新的虚方法; 增加单据类型时,只需实现新的单据,增加在途功能,只需修改 ChangeStock;
评价:此方案虽然很容易扩展,除了需要对ChangeStock作修改外,基本符合开闭原则;但过于抽象,子类需要实现全部操作,基类仅仅起到规范方法名称的作用;此方案的复用度太低;
方案2、抽象类实现部分模板方法,所有方法都没有参数,抽象类没有任何字段,全部给子类实现;
单据处理类 BillProcess (抽象类):
公有虚方法 Save(): 调用IsNew()判断是新增还是更新,如果是新增,设置单据编号SetBillNumber,插入头表InsertHeader,设置行表外键SetBillLineHeaderID;若是更新,则更新头表UpdateHeader,删除行表DeleteLines(),设置行表外键SetBillLineHeaderID,插入行表InsertLines(); 调用 Log();
公有虚方法 Submit(): 调用 Save(),调用 ChangeStock(); 调用 Log();
公有虚方法 Delete(): 调用 DeleteHeader(),调用 DeleteLines(); 调用 Log();
保护虚方法 IsNew,判断是新增单据还是更新单据;
保护虚方法 SetBillNumber,设置头表单据编号;
保护虚方法 SetBillLineHeaderID, 设置行表所属头表的外键值;
保护虚方法 InsertHeader/UpdateHeader/DeleteHeader, InsertLines/DeleteLines
保护虚方法 Log,记录操作员、操作时间、操作类型、单据编号;
保护虚方法 ChangeStock,改变库存
扩展:增加审批功能,需在基类增加新的虚方法Audit和NeedAudit虚属性,子类实现新的虚方法和虚属性; 增加单据类型时,只需实现新的单据,增加在途功能,需修改 ChangeStock;
评价:此方案也很容易扩展,并且基类实现了部分操作,但子类要实现的方法过多,数据库访问方法其实只是表名有点差别,子类却要全部重写;另外子类 ChangeStock 仍要修改;此方案有一定的复用度,但也不高,而且每个子类的ChangeStock都要修改,不满足“开闭原则”的封闭性。
方案3、一些通用的操作尽量放到 BillProcess,库存操作独立出来成为一个类;抽象出头表实体、行表实体接口;BillProcess可以操作相关数据接口。
单据头实体接口 IBillHeader:
BillID 获取或设置主键ID, 如果获取的ID为0,表示为新单据;这样就不需要IsNew()方法了
BillType 获取单据类型
BillNumber 获取或设置单据编号, 如果是新单据,BillProcess类可以向单据编号生成器BillNumberGenerator.GetBillNumber(BillType)传入BillType参数,获得单据编号,赋值给此属性;
DBField[] 获取实体的字段值数组,便于插入和更新到头表;
单据行实体接口 IBillLine:
BillID 设置所属单据头ID
ProductID 商品ID
SiteID 库位ID
Count 商品数量
DBField[] 获取实体的字段值数组,便于插入到头表;
单据处理类 BillProcess (抽象类):
保护虚属性: 头表名HeaderTableName、行表名LineTableName、头表主键名HeaderTablePKName、行表外键名LineTableFKName、单据类型BillType、头表实体接口IBillHeader、行表集合接口List<IBillLine> BillLines;单据编号 BillNumber;
公有虚方法 Save(): 根据IBillHeader的BillID判断,如果是新增,调用BillNumberGenerator.GetBillNumber(BillType)获取新单据编号,并把单据号设置到头表实体,通过数据库会话类把头表实体DBField[]插入头表,设置行表外键BillID;若是更新,则数据库会话类用DBField[]更新头表,调用DeleteLines()删除行表记录,设置行表外键IBillLine的BillID,通过数据库会话类把行表实体DBField[]插入行表; 调用 Log();
公有虚方法 Submit(): 调用 Save(),调用 ChangeStock(); 调用 Log();
公有虚方法 Delete(): 调用数据库会话类删除头表记录,调用 DeleteLines(); 调用 Log();
保护虚方法 DeleteLines(): 调用数据库会话类根据LineTableName、LineTableFKName删除行表记录
保护虚方法 Log,记录操作员、操作时间、操作类型、单据编号;
保护虚方法 ChangeStock,改变库存, 先调用GenerateStockChanges()把 List<IBillLine> 转为 List<StockChange>, 调用 StockProcess 实现库存操作,便于库存操作扩展
保护虚方法 GenerateStockChanges(), 把 List<IBillLine> 转为 List<StockChange>;
单据号生成器 BillNumberGenerator:
生成单据号 GetBillNumber(BillType)
库存处理类 StockProcess:
属性 List<StockChange>, 库存操作数组
库存改变类 StockChange: 商品ID 库位ID 库存量改变个数 (可扩充增加“在途量改变个数”)
扩展:增加审批功能和新单据时,方法同方案1;增加在途量,需要扩展StockChange实体类和StockProcess业务类,BillProcess修改 GenerateStockChanges,子类不需要修改.
评价:基类包含了较多实现,代码复用度高; 子类只需要实现 IBillHeader, IBillLine 和 一些属性即可(这些属性还可以配置到XML文件中,由BillProcess根据BillType读取XML配置信息,这样子类就不必关心这些细节,方便开发); 同时又保留了较强的扩展性,ChangeStock 方法被细化了,在扩充时,要修改的部分也只是底层的、局部的。不足之处是,基类过多地关注了细节,限制了扩展能力和变更的自由度;另外因为是要修改基类,影响的子类较多,需要投入较多的回归测试时间。此方案基本满足开闭原则。
总体评价,方案3 提供了一个非常强大的业务基类BillProcess,但过于特殊化。目前的支持的流程是 暂存->提交->审批->改变库存,如果再增加一些中间环节,比如审批后增加发货环节,那么就又要修改BillProcess类了。还有,如果不同的单据有不同的流程,那么 BillProcess 就要增加大量属性信息来描述流程。有一种比较理想的设计思路是,把BillProcess变为“业务引擎”,把各种单据的流程配置到文件中,流程引擎读取这些配置信息执行对应的操作。这样增加一个单据或修改一个单据的流程,只需修改配置文件。实际上,这样有点像“依赖倒置”了,用框架来解决问题。