6.3 开放封闭原则(OCP)
开闭原则(Open-Closed Principle, OCP)指的是,一个类或者模块,如果在业务修改或者功能需要扩展时,应尽可能保证只通过新添加代码,而不是修改原有代码的情况下完成。
该原则是Eiffel 语言的设计者,法国计算机科学家Meyer提出的,最开始的描述是:当向模块添加字段或方法时,不可避免地需要对调用这个模块的程序进行修改,解决这问题的方法是采用依赖于面向对象继承(特别是从实际父类进行的实现继承)的方法,而不是直接修改原来的程序。
从20世纪90年代开始,OCP的原则依然被大家遵循,只是解决的方式变了。随着抽象类和接口等方式的大量应用,开放封闭原则开始提倡采用抽象类或接口的方法,而不是实现继承来解决问题。
解决方式是现存的接口或抽象类对于修改原有的方法和属性是封闭的,但对新添加的方法和属性则是开放和允许的。
为什么要对原有的代码进行保护,对新加的代码开放呢?
所有的软件和程序都有一个生命周期,当需求和业务发生扩展和更新时,需要更新软件(修复缺陷和软件重构时的更新除外),要尽可能保证软件的基本框架不变,尽可能不修改现有的代码,而是添加新的实现代码,使得软件具有好的稳定性和可维护性。
实现扩展而不修改原有代码的基础就是采用接口或者抽象类等机制完成的。经过良好的定义,系统可以拥有一个相对稳定的抽象层,将业务行为下沉到具体的实现层中。
如果业务有扩展,可以将扩展的业务放到实现层中,只添加新的代码,不修改已有的代码,通过抽象层,将新的业务逻辑指定到新添加的业务实现层中。
如果业务有修改,无须对抽象层进行任何改动,添加实现代码,替换固定的实现层代码,而不会影响实现层其他模块的行为。
设计实例:
一个公司中有多种物料类型,每种物料都要根据物料类型打印不同的检验说明单据,这个检验说明是由一个非面向对象的程序来控制的,当公司中的物料类型增加一种时,IT部门就被要求为这个程序添加一种打印方法。IT部门每次修改这个打印程序后,都要为以前的每一种的打印都做一个回退测试,保证新加的代码没有影响以往的业务。
如图6-4所示,程序流程图如下,当添加一种新物料类型时,程序就又多一组判断:
代码如示例程序6.1所示。
"示例程序6.1 |
REPORT zrep_cls_021. |
当每次公司出现一个新的物料类型时,就要添加一个针对这个新类型的相关检验的要求,我们就需要去修改这个IF ELSE代码添加一个新的逻辑,主程序就要每一次都进行修改,这就不符合开放封闭原则。
对于不断变化的业务,如果我们每次修改原有的代码,除了要对增加的功能进行测试,对原有的功能还必须要做回退测试(Regression Test),保证新的逻辑没有影响以前的业务逻辑。但如果软件能够做到仅增加新的代码,不修改,或基于一定的规则的修改原有代码,这样就能保证工作量和代码出错的几率大幅下降。这就是"开放-关闭"原则,即软件扩展时只添加新代码而不是修改原有代码。
我们用面向对象的程序设计,并采用一个"改进的简单工厂" 设计模式(也就是基于配置表的简单工厂模式,具体结构见第7章第3节)来对系统进行重构,说明一下如何做到重构中的开放封闭原则。(例子中的这个程序因为太简单,其实是没必要进行面向对象重构的,但这个例子是足够能说明实际业务中的重构过程和如何做到的遵循开放封闭原则。重构后,代码量会增加,但却是十分值得的。)
如图6-5所示,首先将业务抽象为三层,第一层为物料对象生成工厂类,相当于主程序中的操作者,用于创建物料类的对象实例。第二层为抽象的物料类,由物料对象生成工厂类直接调用。第三层是具体的物料类,代表具体的物料类型,可以不断从抽象类中继承和添加新的子类,具体的解释如表6-2所示。
其中第二层抽象类隔离开了操作类和具体物料类,使得主程序可以不必修改,就可以处理新添加的业务逻辑。
程序和实体列表 |
解释 |
第一层类:物料对象生成工厂类 |
一个类,用于创建(生产)物料类的对象实例。 |
第二层抽象类:抽象的物料类 |
一个类,抽象的物料类。 |
第三层类:具体的物料类 |
可以有多个类,每个代表一个具体的物料类型和相应的处理方法。 |
数据表:物料类型和相应具体物理类的表 |
表中记录了SAP物料的类型和对应具体类的类名。 |
我们逐步介绍如何创建这些类和实体:
第一步,如图6-6所示,创建抽象的物料类ZCL_MATERIAL,这是第二层的抽象类,将多样的具体业务抽象为一种模式。设定类的类型为"Abstract"抽象类。
如图6-7所示,创建一个属性MV_MATERIAL,为受保护可见度, 类型为物料号码(MARA-MATNR)。
如图6-8所示,设定方法PRINT_INSP_DESC,可见类型为Public,无参数。不必定义具体逻辑,该方法表明了物料类拥有打印检验说明的能力,但具体实现代码是由其子类各自实现的。
第二步,如图6-9所示,创建第三层的抽象的子类。分别为"成品物料类"ZCL_MATERIAL_FIN,"原料物料类"ZCL_MATERIAL_RAW,这一层是最底层的实现类,代表了具体的实现方法,如果有新的业务添加时,也是在这一层次创建新的类。
创建成品物料类ZCL_MATERIAL_FIN,从抽象物料类继承。
如图6-10所示,子类自动继承属性MV_MATERIAL,为受保护可见度, 类型为物料号码。MARA-MATNR。
如图6-11所示,重定义继承来的方法PRINT_INSP_DESC。
如图6-12所示,该方法是对应的成品物料类的具体操作方法,比如获取固定数据,生成固定格式,并发送到相应的输出打印机等。我们这里就简单的写成"此物料为成品,需检查销售合同",用于代表现实中复杂的逻辑。
如图6-13所示,创建原料物料类ZCL_MATERIAL_RAW,从抽象物料类继承。
如图6-14所示,自动继承属性MV_MATERIAL,为受保护可见度, 类型为物料号码。MARA-MATNR。
如图6-15所示,重定义继承来的方法PRINT_INSP_DESC。
如图6-16所示,该方法是对应的原料物料类的具体操作方法,比如获取采购或生产数据,生成固定格式的报表,并发送到相应的输出打印机等。我们写成"此物料为原料,需检查采购合同",用于代表现实中复杂的逻辑。
第三步,如图6-17所示,创建表ZINSP_DESC_T:
列SORT_NUM代表序号。
列MATERIAL_TYPE存储着SAP标准的物料类型。
列CLASS_TYPE存储着处理相应物料类型的相应的物料类类名(类名需要大写)。然后参照"数据字典"章节,维护列的Domain,数据类型和维护表等工作。
如图6-18所示,运行SM30,将SAP标准的物料类型"ROH原料类型",及其对应的处理类"ZCL_MATERIAL_RAW",物料类型"FERT成品类型",极其对应的处理类"ZCL_MATERIAL_FIN"。添加到表中。
在Java和.NET中,大多使用XML文件来保存类似的服务注册,SAP ABAP中最为方便的就是用数据库表。
第四步,如图6-19所示,创建简单工厂类ZCL_MATL_SPL_FACTORY,这是第一层的调用类,模式控制的核心代码定义在这一层 ,这个类将实现根据物料动态创建物料子类类型(也就是用于创建物料类对象的简单类工厂),并实现多态的能力,多态在这个架构中将展现其威力。
类ZCL_MATL_SPL_FACTORY是普通的Public类型的非抽象类。
如图6-20所示,设定方法GET_MATERIAL_OBJ,可见类型为Public,该方法是根据物料号码,获取物料类型,继而获得对应的物料类的名称并创建类对象实例,方法可以是静态类型方法(Static Method),即可以用类名来访问方法(用=>符调用)。
如图6-21所示,设定方法的输入参数 IV_MATERIAL_ID,是传入的物料号码MARA-MATNR。
设定方法的返回参数 RO_MATERIAL,类型是抽象类物料对象类型ZCL_MATERIAL。方法将其实例化后作为返回值传出,实例化的对象都是物料类的可实例化的子类对象,然后赋值给父类物料类对象进行上转型,此处就是典型的多态的应用。
代码根据传入的物料号码,在标准表MARA中查询出物料类型,然后根据物料类型去自定义表格中查询对应的处理类,然后创建对应类对象实例作为返回的参数,代码如示例程序6.2所示,代码中的"动态创建类对象 ro_material,类型为注册表中的类的类型"即是按子类类型创建父类对象,即向上转型的多态的实现。
第五步,创建最终的调用程序,对于工厂类的方法GET_MATERIAL_OBJ,因为该方法为静态方法,可以不创建类对象直接通过类名称调用,调用后可以取得子类的对象实例。
然后再调用类的方法PRINT_INSP_DESC,根据多态,自动根据物料类型,实现子类具体的业务处理。
代码如下:
"示例程序6.3 |
REPORT zrep_cls_022. |
如图6-22所示,运行程序,输入物料号"FIN1001",其物料类型为"FERT
成品类型",代码如示例程序6.3所示。
如图6-23所示,执行结果就是子类"ZCL_MATERIAL_FIN"成品物料类的打印方法。
如图6-24所示,运行程序,输入物料号"RAW2010",物料类型为"ROH原料类型",运行程序。
如图6-25所示,执行结果就是子类"ZCL_MATERIAL_RAW"原料物料类的打印方法。
如图6-26所示,运行程序,输入物料号"SEMI001",物料类型为"HALB半成品类型",运行程序。
如图6-27所示,执行结果就是未能找到子类,打印"对应物料类型未维护打印信息."的信息。
物料类的代码如示例程序6.4所示。
"示例程序6.4 |
为了节约篇幅,物料类的code based 代码请参照Github代码6.4 <https://github.com/ABAPOOP/ABAP_OOP_SAMPLE/blob/master/ABAP_OOP_Sample_6.04.txt > |
看到上面的实现方法,大家可能会说"莫名其妙啊?例子中的代码原来一共就一个程序,20多行的代码,每次扩展我就复制黏贴,然后改一改就好了。
现在用了这些原则和模式,需要创建几个类,又要创建表,并且全部代码变成了200多行,而其中近100行都是和打印无关的模式和控制的代码——这不是画蛇添足,自找麻烦吗?"
这是一个好问题,以下我们要讲的所有原则和模式都是会引入一些"冗余的"模式和控制代码,我们干脆就在这一节里阐述一下作者对为什么采用这些原则和模式控制的理解。
为什么要采用这些原则和模式呢?
1. 为了建立一个良好的系统管理架构
如果你开了一家有一定的规模公司,你一定会架构好你的企业管理架构,管理学中的管理架构有管理层级和管理宽度的概念,层级就是从最高层到最底层的层级,管理宽度就是每层能够有效监督的下属人数。越是高层,对智力和抽象能力的要求越高,管理的方式就越是关注方向性和抽象性的问题,而不是具体化的业务。最高层一般就是3到13人(想想大多数公司董事会的人数,基本也就这么多),作为董事长的你,主要管理好这几个高层就可以了。而越往下的管理层级,管理的下属人数就越多,他们关注的问题也就越为具体和琐碎。越高层的人员替换和业务流程修改的代价越高(比如一个公司替换CEO的代价),而越低层级的人员替换和业务流程修改的代价却越低。
一旦建立起来扁平而有效的管理架构和宽度,公司的业务发展就有了骨架,在管理层的运作下,基层员工的业务是很容易拓展的。从企业的人员"收益率"的角度出发,管理人员的收益率要远大于具体的业务人员。
这些和具体业务无关的模式控制代码就像是公司的管理架构和管理人员,在业务简单的时候,模式控制控制代码有100行,占了50%的代码量,管理层当然低效而冗余。
而现实中的业务从来都不是这么简单的,动辄以上千行计,这时,控制代码所占程序的比率就不会超过10%了,管理效率就体现出来了。
而当代码随着业务迅速增长的时候,模式控制代码就像公司的管理层人员,不会像底层人员迅速增长,这时候的模式控制代码数量就在整个系统中的数量比重越来越小,但控制代码起到的作用依然是决定性的,系统的可扩展性,可维护性在一开始创建管理架构的时候就被决定了。
我们创建的模式控制代码(包括几个类和数据表),就是搭建这个系统的管理架构,在程序很简单的时候当然是小题大做,但处理现实中的复杂系统,搭建一个有前瞻性的架构,就是"毕先利其器"的必要过程.
2.获得更高的软件的"代码收益率"
我们用一个新名词,"代码收益率"来解释。像计算像投资收益率一样计算产出收益和投入的资产的比率。如下列公式所示,如果代码也需要计算收益率的话,我们计算一下模式与控制部分的代码收益率如何,列一个简单的公式:
这里,"手工编写的业务代码行数"是随着业务的扩展和改变而不断增加的需要手工加入的新的逻辑(其他共有的逻辑则通过类的复制和继承即可得到),而核心的"模式与控制代码"则增长不会很大,在良好的设计原则和模式下,甚至能做到这部分的零增长。
从成本的角度来看,开发成本在最开始的时候有架构师来定义最初的模式与控制,这时的成本是最高的。但系统扩展时,开发人员只要按照固定的模式添加新的类和配置,照猫画虎即可,由一般的开发人员进行扩展就够了,其开发成本是逐渐降低的。
由此可见,模式和控制代码是用于控制全局的,所有新增的代码都能够在它的控制之下,所以这种模式控制的代码的"代码收益率"是最高的。是值得你投入精力去学习和实现的。
3. 降低软件的开发和维护成本
举一个实际项目中的例子,作者参与一个超过6000行的中型的ABAP后台程序设计和测试,这个程序由基于BAdI的增强触发,负责多种业务类型下的数据处理,一开始,我们用的是ABAP的一般结构化编程。随着业务类型的增加(每几个月都会增加一种类型),我们每次都要在已有的程序中添加新的业务类型,即便采用了功能和模块化设计,但修改的代码量还是重复和复杂,每次都要美国的架构师亲自参与代码设计和指导,然后由印度和中国的ABAPer们开发实现。
测试就更加麻烦,每次的测试都要用一个Excel表格才能记录下所有的回退功能测试(就是对以前的功能的测试,防止新加的功能影响以往的功能)。
还曾经因为一次代码更新了一个公有的模块,而回退测试没有覆盖全,造成了上线后的系统错误。
最后,我们采用了面向对象ABAP和设计原则(开放封闭原则)与模式(主要是增强的简单工厂模式)重构了这个程序,创建了几个控制类和控制数据表,将业务类型封装到一个抽象类中,然后将每个具体的业务类型定义为这个抽象类的子类。控制类的代码和数据表如果用代码行数来核算的话只有200多行。
重构后,目前每次扩展业务时,只需要客户定义好新业务的基本规则(这些规则间很类似,可以重用大部分代码),确定最核心的代码逻辑,然后交给一个一般的开发人员去做两件事情:
先用SE24按照现有的子类为模板,复制出一个新的类型,修改其中的某些方法来实现新的规则(我们视这部分的实现类不是模式与控制代码,而是具体的业务实现代码)。
然后再到数据配置表SM30中添加上这种业务类型就可以了。即便是一般的开发人员,这个工作既不复杂也无风险。
而原来冗长的BAdI的主程序,目前只有100多行,并且自重构后,我们添加过多种业务类型,但这些模式控制代码和以前的业务类代码从来不用修改和增加过。
至于测试,就更高效了,重构后的代码,在前几次添加规则时我们还做回退测试,因为每次添加业务类型的都是创建一个新的类(对扩展开放),从来都不会去修改以前的类和代码(对修改关闭),所以对以前的逻辑不会有任何影响,到后来我们只测试新加的业务功能,连繁琐的回退测试都省略了。而且每次上线没有因为这个新架构而产生任何问题。
架构师采用良好的架构设计后,对于后期的功能扩展来说,价格高的架构师基本再不用参与这类扩展工作,而价格低的开发人员也有章可循没有开发风险,测试则更是可以自动化进行或者减少回退测试,进一步降低了成本。
表6-3对比了作者在实际工作中的参与的这一项代码重构前后的效果和成本对比。
NO. |
项目 |
原有面向过程代码 |
重构后的面向对象代码 |
1 |
手写代码行数 |
6000行,几乎所有代码都需要手工编写。 仅主程序代码行数就近2000行,阅读和修改都比较困难。 |
3000行,(全部代码超过了一万行,但是大部分代码都是系统生成的,或者采用复制类的方法自动生成的,并且代码分散在类方法中,易于理解和阅读。新加功能时,我们复制一个新的类,然后只需要根据业务需要,修改类中的几个方法即可,而控制类的代码基本没有增长过。) |
2 |
重新添加一个业务功能后,回退测试(Regression Test)所耗时间 |
30小时,每次添加一个新业务功能,都要修改现有代码,必须进行全面的回退测试,保证新加功能后的代码没有影响以往的业务逻辑。 |
0小时,因为采用了增强的工厂模式,遵循了开放封闭原则,以前的业务功能代码不用进行任何修改,以前的代码连编译都不需要,不再需要回退测试。 |
3 |
新功能测试所需时间 |
5小时 |
5小时,开放封闭原则允许新加功能,新功能测试和原来用时一致。 |
4 |
上线后发生的错误次数 |
10+次,因为每都需要对原有代码进行频繁调整,出错概率很大。 |
1次,并且是新功能迁移时发生的问题,因为增新功能不会影响以往的业务逻辑 |
5 |
重新添加一个业务功能所需时间 |
80+小时,每次都需要架构师亲自审阅和设计,指导开发代码。 |
30+小时,仅需架构师设计和审阅代码即可。 |
为了验证我的解释,我们来测试一下,当业务上有一个新的类型"半成品物料"需要添加进来时,看该架构是否能做到只添加新代码,不用修改原来的代码。
如图6-28,我们新加"半成品物料"后的系统结构如下所示,需新加一个子类B3,然后新加一条数据库记录即可,对虚线内的原有的系统代码(也包括报表主程序ZREP_CLS_022)不用做任何修改,连重新编译都不需要。
我们加入一个新的物料类"ZCL_MATERIAL_SEMI",代表物料类型 "半成品类型",专门处理SAP物料类型为"HALB"的半成品物料的打印。
如图6-29所示,首先进入SE24,根据现有的类ZCL_MATERIAL_RAW,点击复制按钮,复制一个近似的半成品类ZCL_MATERIAL_SEMI。
如图6-30所示,复制一个半成品类ZCL_MATERIAL_SEMI。
如图6-31所示,半成品类ZCL_MATERIAL_SEMI同样是继承自类ZCL_MATERIAL,仅需要修改对应的Description等信息。
如图6-32所示,为半成品类ZCL_MATERIAL_SEMI重新编写那些需要修改的方法的代码逻辑,不用修改的代码的方法则继续沿用以前的逻辑,不用处理。
如图6-33所示,设定半成品物料的打印信息,然后激活这个半成品类。
如图6-34所示,添加完新的类后,第二步就是进入SM30,为类注册表ZINSP_DESC_T,新添加记录,SAP标准物料类型为HALB(半成品),对应的类型处理类ZCL_MATERIAL_SEMI。
如图6-35所示,前两步的添加代码和添加记录完成后,我们不用修改,也不用编译任何以前的类和主程序代码,直接运行程序ZREP_CLS_022,然后输入物料类型SEMI001,物料其对应的SAP标准物料类型为HALB(半成品)。
如图6-36所示,代码可以根据物料类型,找到相应的处理类,触发相应的半成品物料类的方法来处理新的业务逻辑,而且,程序原来的针对原料和成品的处理逻辑不受任何影响。可见,该架构符合对扩展开放,对修改关闭的开放封闭原则,拥有较为稳定和易于扩展的系统结构。
该架构的核心就是利用了多态的特性,将"物料"抽象为较为稳定的,很少修改的抽象的父类;而具体的物料(成品,半成品,原料)则代表了具体多变的可以随时添加和修改的具体的物料类型。
采用继承让各个物料子类有不同的处理方式。
采用多态根据当前的物料类型创建对应的处理类对象进行处理。
该系统逻辑依然是根据不同的物料类型来处理物料的不同检验方式,但把原来固定的IF / ELSE逻辑改成了工厂类中动态的表记录的读取的方式,获得相应物料类型的处理类的名称,并动态创建上转型多态的父类对象。从而完全符合了开放封闭原则。该"增强的简单工厂"架构是一种很实用并符合很多业务场景(不仅仅SAP业务开发会遇到的场景)的设计方式。
以上的开放封闭原则的示例即能帮助我们来实现一种即插即用的组件式的较为稳定和易于扩展的软件系统架构。
《SAP ABAP面向对象程序设计:原则、模式及实践》
https://book.douban.com/subject/30317853/
http://www.duokan.com/shop/tbt/book/179473
https://item.jd.com/12423999.html
https://e.jd.com/30429611.html