IoC/DI(Inverse of Control/Dependency Injection,控制反转/依赖注入)模式是一种企业级架构模式,通过将应用程序控制权反转交移给框架,并以构造器注入、属性设置器注入等方式将类实体注入到特定应用层中,最终实现层与层之间的解耦,使得应用程序获得良好的扩展性和应变能力。
客户需求如下:需要向系统中添加两个窗体,Engineers和Analysts,分别显示工程师和分析师的ID、Name和Credit(积分)。在每个窗体右边有一个按钮,该按钮的作用是通过一种计算方式,算出工程师或者分析师的最终积分并显示在弹出窗体上。对于工程师,最终积分=积分(Credit)* 1.1;对于分析师,最终积分=积分(Credit)* 1.4。
劣质的设计
客户需求很简单,稍作分析,我们不难得出,无论从数据表结构还是窗体界面上,Engineers部分和Analysts部分都是非常相近的,只是在最终积分的计算方式上有所不同。很明显,为了具体化这两种算法,我们首先需要将其泛化,然后派生出两个不同算法的具体类:
在上图中,CreditCalculator_General类用于计算工程师的最终积分;CreditCalculator_Special用于计算分析师的最终积分;而CreditCalculator是泛化类。CreditCalculator_General和CreditCalculator_Special分别实现抽象类CreditCalculator的calculate方法,以实现具体的计算方式:
// CreditCalculator_General实现 public class CreditCalculator_General extends CreditCalculator { Engineers engineers; } public void new (Common _common) { engineers = _common; } public Amount calculate() { return engineers.Credit * 1.1; } // CreditCalculator_Special实现 public class CreditCalculator_Special extends CreditCalculator { Analysts analysts; } public void new (Common _common) { analysts = _common; } public Amount calculate() { return analysts.Credit * 1.4; }
在计算最终积分的窗体的init方法中,使用下面的代码获得具体的算法类,然后在RealEdit中显示计算结果:
public void init() { CreditCalculator calculator; ; super(); if (!element.args().caller()) { throw error("@SYS75311"); } // 使用CreditCalculator的construct方法创建实例 calculator = CreditCalculator::construct( element.args().parmEnum(), // 采用何种计算方式 element.args().record()); // 具体记录 // 计算并显示结果 result.realValue(calculator.calculate()); }
其中的construct方法代码如下,参数_method是一个BaseEnum,它有两个元素,用于指定具体的计算方式,该参数在与按钮关联的Display MenuItem上指定,以便于显示结果的窗体在init的时候可以获得具体的参数值:
public static CreditCalculator construct(CalculationMethod _method, Common _common = null) { CreditCalculator calculator; switch (_method) { case CalculationMethod::General: calculator = new CreditCalculator_General(_common); break; case CalculationMethod::Special: calculator = new CreditCalculator_Special(_common); break; default: throw error(strFmt("@SYS22828", funcname())); } return calculator; }
很明显,上面的construct方法其实就是一个工厂方法。那么这种设计方式是不是就可以应对不断变化的需求呢?现在我们来分析一下这种设计方式。
上面的设计思想可以使用下面的UML类图进行简要描述:
1、 CreditCalculator_General与数据表Engineers产生聚合(Aggregation)耦合;同理,CreditCalculator_Special与数据表Analysts产生聚合(Aggregation)耦合;在这种情况下,如果客户提出需求更改:Engineers也要采用与Analysts相同的积分计算方式,此时,如果仅仅修改Engineers窗体上按钮的MenuItem,使其采用CreditCalculator_Special的计算方式,那么将会由于Engineers窗体向积分计算窗体传送的数据记录类型(Engineers类型)与Analysts数据记录类型(也就是CreditCalculator_Special类中所必需的数据记录)不匹配而出现异常
2、 由于两个具体类都分别与其业务相关的数据表产生聚合耦合,这导致CreditCalculator抽象类的construct静态方法也间接的与这两张数据表产生聚合耦合(在上面的UML图中以虚线的聚合关联表示);Engineers数据表和Analysts数据表可以看成是stereotype为table的类,它们是存在于数据表示层的,因此,construct静态方法会与其它两个类产生聚合耦合关联,这违背了面向对象设计中“层与层之间需要解耦”的设计思想
3、 虽然construct方法采用了switch/case语句提供工厂模式中的工厂方法实现,但是仍然无法应对客户需求变化,例如,如果客户提出另外一个需求:需要增加一种新的计算方式Compound,此时您不得不新建一个继承于CreditCalculator的类:CreditCalculator_Compound,并修改construct方法,添加下面代码:
case CalculationMethod::Compound:
calculator = new CreditCalculator_Compound (_common);
break;
在这种情况下,construct工厂方法根本没有解决设计中存在的问题,事实上,这种采用switch/case语句或者if/else语句实现的工厂模式是一种“伪工厂”模式。系统在发生变更的时候,仍然需要修改大量的代码,当然,您会说X++修改代码很方便,但这并不能作为不使用面向对象思想进行系统分析与设计的借口
4、 代码需要依赖一个用于指定计算方式的BaseEnum:CalculationMethod,在添加新的计算方式的同时,还需要在BaseEnum中增加元素,代码应需求而变的情况没有得到任何改观
综上所述,这种设计方式是劣质的,根本无法应对客户需求的变更,因此,我们需要重构,以改进现有设计
第一次改进
针对上面设计的四个问题,我们对设计进行改进
首先需要解耦具体计算类与数据表实体的耦合关联,也就是让CreditCalculator_General以及CreditCalculator_Special类的具体实现不依赖于Engineers与Analysts数据表,当然,我们可以使用Common来表示一个数据记录,但是它不具备Engineers与Analysts数据表的抽象特性,就好像在.NET Framework中,object类并不具备TextReader与TextWriter的特性一样,这是一种过度泛化。
在常规设计模式中,我们需要定义一个接口,使得Engineers与Analysts数据表都继承于该接口,而在CreditCalculator_General以及CreditCalculator_Special类的具体实现中只对接口进行操作,此时,具体计算类已经与数据表实体实现解耦。然而不幸的是,X++中的数据表实现的是Active Record模式,从表面上看,我们无从定义这个接口。
不幸中的万幸,Data Dictionary下的Map为我们提供了解决方案。在此,我们需要新建一个Map,姑且命名为Staff,该Map只有一个数据字段:Credit(因为在我们的积分计算类中,只需要用到这个字段),在Map中添加两个数据表:Engineers和Analysts,并建立字段关联,使得这两个数据表的Credit字段分别与Map的Credit字段关联,如下图所示:
由此我们可以修改CreditCalculator、CreditCalculator_General、CreditCalculator_Special的代码如下(其它部分的代码暂时不需要变化):
// CreditCalculator的代码 public abstract class CreditCalculator { Staff staff; } public void new(Common _common) { staff = _common; } // CreditCalculator_General的代码 public class CreditCalculator_General extends CreditCalculator { // 我们已经不需要定义具体的Engineers数据表实体了 // Engineers engineers; } public Amount calculate() { return staff.Credit * 1.1; } // CreditCalculator_Special的代码 public class CreditCalculator_Special extends CreditCalculator { // 我们已经不需要定义具体的Analysts数据表实体了 // Analysts analysts; } public Amount calculate() { return staff.Credit * 1.4; }
至此,无论传递给CreditCalculator_Special类的数据记录是Engineers类型的,还是Analysts类型的,类的calculate方法都可以很好地计算出最终积分。具体的积分计算类已经与数据表实体解耦。这种设计方式可以用下面的UML类图进行描述(点击图片查看全图):
由UML图可以看出,CreditCalculator类已经与Staff表类(stereotype为table的类)产生耦合关联,同时解耦了具体表与其之间的耦合关联。
在此,IoC/DI设计模式的应用已经初见端倪:CreditCalculator类在new的时候,以及General和Special类在使用calculate方法进行积分计算的时候,它们并不知道数据表抽象类Staff(实际是一个Map)具体指代的是Engineers还是Analysts;数据表的具体实例是在CalculateCredit窗体创建General/Special类实例的时候,通过构造函数注入到类中的,这就是依赖注入(DI)的具体体现。
我们再来思考同样一个问题:现在的设计真的可以应对不断变化的客户需求吗?仍然不行!我们忽略了那个“伪工厂”方法和那个Base Enum。换句话说,如果客户需要添加一个新的最终积分计算方法,我们不得不去修改construct方法和这个Base Enum。
第二次改进
我们需要使用控制反转(IoC)及其容器实现来完成设计的第二次改进。所谓控制反转,就是将程序控制权由应用程序反转交给框架,例如支持插件系统的应用程序,应用程序是框架,应用程序的具体行为都在插件中体现,程序控制权在插件手中,Axapta本身就是一个控制反转的实例。为了解决第一次改进中遗留的问题,我们需要引入一种框架,在此我们简单地引入一个IoC容器,由容器来确定系统使用哪个积分计算类来实现积分计算。
1、 模式参与者
a) 配置数据表
配置数据表是对IoC容器配置的描述,一般情况下是一个键值对集合,用于表述在某个特定的环境中,使用哪个类来实现依赖注入,在Spring和Spring.NET框架中表现为XML文件
b) IoC容器
IoC容器用于注册环境特征与类类型的对应关系,并为应用程序提供用于依赖注入的具体实例。在本范例中,我们使用一张数据表来保存配置,因此在系统启动的时候,我们无须进行类型注册
注:上文中提到的其它参与者在此不一一列举
2、 动态特性
a) 用户在Engineers(或者Analysts)窗体上按下“Credit calculation”按钮,由此调用CalculateCredit窗体
b) CalculateCredit窗体调用IoC容器的GetClassFromContainer方法,以便获得具体的计算实例,以便进行最终积分的计算和输出
c) 当CalculateCredit窗体调用IoC容器的GetClassFromContainer方法时,应用程序控制权交给了IoC容器,此时容器会根据调用者的MenuItem名称,通过查询配置数据表来获得对应的类标识(ClassId),进而产生类的实例并返回给调用者
d) 调用者(CalculateCredit窗体)获得类实例后,调用实例的calculate方法计算出最终积分,并显示在窗体上
3、 序列图
序列图如下所示(点击查看全图):
4、 UML类图(点击查看全图)
在采用了这种设计模式以后,我们的代码需要做如下修改:
1、 添加数据表Configuration,其中有两个字段ClassId和MenuItemName
2、 添加IoCContainer类,类中方法定义如下:
public static CreditCalculator GetClassFromContainer(str 30 _menuItemName) { str menuItemStr; int classId; CreditCalculator calculator; ; classId = Configuration::find(_menuItemName).ClassId; calculator = classFactory.createClass(classId, true); return calculator; }
3、 在抽象类CreditCalculator中添加parmStaff方法如下,同时删去原有的new方法(或者使用默认的new方法):
public Staff parmStaff(Staff _staff = staff) { ; staff = _staff; return staff; }
之所以要添加这个方法,是因为我们在使用classFactory创建类实例的时候,没有办法制定构造函数的参数,因此无法使用构造器注入方式来实现依赖注入,我们只能够使用属性设置器注入方式
4、 修改CalculateCredit窗体的init方法如下:
public void init() { CreditCalculator calculator; ; super(); if (!element.args().caller()) { throw error("@SYS75311"); } calculator = IoCContainer::GetClassFromContainer( element.args().menuItemName()); calculator.parmStaff(element.args().record()); result.realValue(calculator.calculate()); }
通过第二次改进,我们的系统已经可以应对变化的客户需求了。当客户要求Engineers的最终积分计算方式要与Analysts的相同时,我们只要在Engineers窗体的按钮上,将其关联的MenuItem设置为Analysts中对应按钮的MenuItem即可;当客户要求添加一种新的最终积分计算方式时,我们只需要新添加一个继承于CreditCalculator的类,同时添加一个MenuItem,并在配置数据表Configuration中设置两者的关联即可,完全不需要更改现有代码;当客户需要添加并处理一个与Engineers/Analysts结构相同的数据表记录时,我们只需要创建数据表,并将其添加到Map中即可。
由此可见,IoC/DI模式给我们带来了应用程序的可扩展性,它使得我们的应用程序能够应对不断变化的客户需求,这也使我们了解到,要应对客户需求变更,不仅可以在开发模式上下手,同时也应该在系统设计的过程中多下功夫,只有这样,我们才能够真正的打造出具有良好构架和优秀质量的应用程序。
最后补充一点,在本例中,需要引入一个与架构相关但与业务无关的数据表,如果客户在这方面有较高要求或限制的话,比如,不能随便添加与业务无关的对象时,IoC/DI设计模式的使用会受到阻拦,因此我们需要“随需应变”,尽量不与需求相冲突,虽然Axapta本身是一个IoC/DI的具体实例,但它并没有提供IoC/DI的设计框架,还需要设计人员在项目开发过程中多多权衡。