使用IoC/DI模式应对需求变化

时间:2022-09-12 11:05:38

使用IoC/DI模式应对需求变化

IoC/DI(Inverse of Control/Dependency Injection,控制反转/依赖注入)模式是一种企业级架构模式,通过将应用程序控制权反转交移给框架,并以构造器注入、属性设置器注入等方式将类实体注入到特定应用层中,最终实现层与层之间的解耦,使得应用程序获得良好的扩展性和应变能力。

X++是Axapta MorphX的开发语言,它是一种面向对象的、高效的商业软件开发语言,有着完整的编译器和调试器。同时由于X++是解释性语言,通常情况下,比起传统的笨拙的编译、连接、测试周期,它可以更快地进行测试和开发。

下面我们将设置一个简单的案例,并给出一个最常用的解决方法,然后分两次对此解决方案进行改进,从而来说明如何在方案中运用IoC/DI模式。

客户需求如下:公司中有两种类型的员工,工程师(Engineers)和分析员(Analysts)。现需要向Dynamics AX系统中添加两个窗体――Engineers和Analysts,分别显示工程师和分析员的编号(ID)、姓名(Name)和积分(Credit)。在每个窗体右边有一个按钮,该按钮的作用是通过一种计算方式,算出工程师或者分析师的最终积分,并显示在弹出窗体上。对于工程师,最终积分=积分(Credit)× 1.1;对于分析师,最终积分=积分(Credit)×1.4。

劣质的设计

客户需求很简单,稍作分析,我们不难得出,无论从数据表结构还是窗体界面上,Engineers部分和Analysts部分都是非常相近的,只是在最终积分的计算方式上有所不同。很明显,为了具体化这两种算法,我们首先需要将其泛化,然后派生出两个不同算法的具体类。

图1 泛化类派生出的两个具体类

在图1中,CreditCalculator_General类用于计算工程师的最终积分;CreditCalculator_Special用于计算分析师的最终积分;而CreditCalculator是泛化类。CreditCalculator_General和CreditCalculator_Special分别实现抽象类CreditCalculator的calculate方法,以实现具体的计算方式。在这种设计情景中,为了能够在计算积分的逻辑中,根据不同的员工类别正确地计算出最终积分,我们很自然地会用到工厂模式。这样,工厂类可以根据特定的参数创建出合适的具体类实例,并调用实例中的calculate方法,计算出最终积分。

这种设计思想是显而易见的,也是目前很多软件设计师常用的一种“应对需求变更”的设计方法。工厂模式的应用,能在一定程度上降低应用系统开发的复杂度,并且也是在一定程度上,为系统的可扩展性提供了先决条件。我们继续分析上面提出的设计方案,在我们目前的这个案例中,使用工厂模式真的能够应对不断变化的客户需求吗?

图2 劣质设计得UML类图

图2展示的UML类图展示了这种设计思想。在用户点击了Engineers或Analysts窗体右边的“Calculate credit”按钮后,窗体CalculateCredit会自动获得用户所选中的“Engineers”或“Analysts”记录(由Dynamics AX中的MenuItem指定),并使用CreditCalculator泛化类的construct构造方法创建出具体的积分计算类,并传入原始积分以获得最终积分,同时显示在窗体上。在这里,CreditCalculator类既是Concrete factory,又是Abstract product。

现在,参照图2,对这种设计思想做一个分析:

CreditCalculator_General与数据表Engineers产生聚合耦合。同理,CreditCalculator_Special与数据表Analysts产生聚合耦合。在这种情况下,如果客户提出需求更改:Engineers也要采用与Analysts相同的积分计算方式,此时,如果仅仅修改Engineers窗体上按钮的MenuItem,使其采用CreditCalculator_Special的计算方式,那么将会由于Engineers窗体向积分计算窗体传送的数据记录类型(Engineers类型)与Analysts数据记录类型(也就是CreditCalculator_Special类中所必需的数据记录)不匹配而出现异常。

由于两个具体类都分别与其业务相关的数据表产生聚合耦合,这导致CreditCalculator抽象类的construct静态方法也间接的与这两张数据表产生聚合耦合(在上面的UML图中以虚线的聚合关联表示);Engineers数据表和Analysts数据表可以看成是stereotype为table的类,它们是存在于数据表示层的,因此,construct静态方法会与其它两个类产生聚合耦合关联,这违背了面向对象设计中“层与层之间需要解耦”的设计思想。

虽然construct方法采用了switch/case语句提供工厂模式中的工厂方法实现,但是仍然无法应对客户需求变化。例如,如果客户提出另外一个需求:需要增加一种新的计算方式Compound,此时您不得不新建一个继承于CreditCalculator的类:CreditCalculator_Compound,并修改construct方法。

在这种情况下,construct工厂方法根本没有解决设计中存在的问题。事实上,这种采用switch/case语句或者if/else语句实现的工厂模式是一种“伪工厂”模式。系统在发生变更的时候,仍然需要修改大量的代码,当然,您会说X++修改代码很方便,但这并不能作为不使用面向对象思想进行系统分析与设计的借口。

代码需要依赖一个用于指定计算方式的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字段关联。

在添加了这个Staff的Map以后,我们进而修改CreditCalculator、CreditCalculator_General、CreditCalculator_Special三个类,使得其数据操作对象为Staff,而不是上面我们所使用的Common。

这样一来,类中仅对Staff“接口”进行操作,完全不涉及任何具体的数据表类,具体类与数据表完全解耦,在解耦的同时,也解决了数据冲突所造成的异常。至此,我们已经基本解决了上述四个问题中的前两个,也使得我们的设计具备了一定的需求变更应对能力。例如,当客户需求变更为:无论员工类别是哪种,都采用General的计算方法时,我们几乎不需要去修改任何现有代码,只要修改MenuItem的属性即可。这种设计方式可以用图3展示的UML类图进行描述。

图3 第一次修改的UML类图

由图3可以看出,CreditCalculator类已经与Staff表类(stereotype为table的类)产生耦合关联,同时解耦了具体表与其之间的耦合关联。在此,IoC/DI设计模式的应用已经初见端倪:CreditCalculator类在新建的时候,以及General和Special类在使用calculate方法进行积分计算的时候,它们并不知道数据表抽象类Staff(实际是一个Map)具体指代的是Engineers还是Analysts。数据表的具体实例是在CalculateCredit窗体创建General/Special类实例的时候,通过构造函数注入到类中的,这就是依赖注入(DI)的具体体现。

我们再来思考同样一个问题:现在的设计真的可以应对不断变化的客户需求吗?仍然不行!我们忽略了“伪工厂”方法和BaseEnum。换句话说,如果客户需要添加一个新的最终积分计算方法,我们不得不去修改construct方法和这个BaseEnum。

图4 第二次修改后的序列图

图5 第二次修改后的UML类图

第二次改进

我们需要使用控制反转(IoC)及其容器实现来完成设计的第二次改进。所谓控制反转,就是将程序控制权由应用程序反转交给框架。例如在支持插件系统的应用程序中,应用程序是框架,应用程序的具体行为都在插件中体现,程序控制权则在插件手中。

Axapta本身就是一个控制反转的实例。为了解决第一次改进中遗留的问题,我们需要引入一种框架,在此我们简单地引入一个IoC容器,由容器来确定系统使用哪个积分计算类来实现最终的积分计算。

下面是关键的两个模式参与者:

配置数据表

配置数据表是对IoC容器配置的描述,一般情况下是一个键值对集合,用于表述在某个特定的环境中,使用哪个类来实现依赖注入,在Spring和框架中表现为XML文件。

IoC容器

IoC容器用于注册环境特征与类类型的对应关系,并为应用程序提供用于依赖注入的具体实例。在本范例中,我们使用一张数据表来保存配置,因此在系统启动的时候,我们无须进行类型注册。

动态特性表现在以下几个方面:

用户在Engineers(或者Analysts)窗体上按下“Credit calculation”按钮,由此调用CalculateCredit窗体。

CalculateCredit窗体调用IoC容器的GetClassFromContainer方法,以便获得具体的计算实例,以便进行最终积分的计算和输出。

当CalculateCredit窗体调用IoC容器的GetClassFromContainer方法时,应用程序将控制权交给了IoC容器,此时容器会根据调用者的MenuItem名称,通过查询配置数据表来获得对应的类标识(ClassId),进而产生类的实例并返回给调用者。

调用者(CalculateCredit窗体)获得类实例后,调用实例的calculate方法计算出最终积分,并显示在窗体上。

通过第二次改进,我们的系统已经可以应对客户需求的变化了。当客户要求Engineers的最终积分计算方式要与Analysts的最终积分计算方式相同时,我们只要在Engineers窗体的按钮上,将其关联的MenuItem设置为Analysts中对应按钮的MenuItem即可。

当客户要求添加一种新的最终积分计算方式时,我们只需要新添加一个继承于CreditCalculator的类,同时添加一个MenuItem,并在配置数据表Configuration中设置两者的关联即可,完全不需要更改现有代码。当客户需要添加并处理一个与Engineers/Analysts结构相同的数据表记录时,我们只需要创建数据表,并将其添加到Map中即可。

由此可见,IoC/DI模式给我们带来了应用程序的可扩展性,它使得我们的应用程序能够应对不断变化的客户需求,这也使我们了解到,要应对客户需求变更,不仅可以在开发模式上下手,同时也应该在系统设计的过程中多下功夫,只有这样,我们才能够真正的打造出具有良好构架和优秀质量的应用程序。

最后补充一点,在本例中,需要引入一个与架构相关但与业务无关的数据表,如果客户在这方面有较高要求或限制的话,比如,不能随便添加与业务无关的对象时,IoC/DI设计模式的使用会受到阻拦。因此我们需要“随需应变”,尽量不与需求相冲突,虽然Axapta本身是一个IoC/DI的具体实例,但它并没有提供IoC/DI的设计框架,还需要设计人员在项目开发过程中多多权衡。

上一篇:高性能计算源于惠普“动能” 下一篇:商业智能不只是报表