薪水支付系统领域驱动设计实践
(一)通过领域驱动设计想解决什么问题
一个系统,按道理说针对的行业是固定的,业务也比较接近,可是每次换个用户,总感觉需求变化一点点,系统却要改得天翻地覆.修改后的代码让人不忍直视,why?
后来接触到了面向对象开发,才慢慢的理解了原因,我们以前的系统都是基于数据为中心的系统。以数据为中心的系统最大的弊端我认为是很难从中直观的理清原来的业务问题和解决思路,你要想搞清楚这些,就需要理解表结构,理解sql语句,跟踪具体的数据变化,即使这样可能还是印象模糊,你可能还需要配合UI一步步进行系统操作并跟踪对应的数据变化才可以理出头绪。总之,没有一个清晰的模型让你快速的理解原有的完整思路。
而面向对象,更具体的讲面向领域驱动设计方式,提倡提炼通用语言,并基于通用语言设计领域模型,再以模型为核心,围绕模型进行开发。通过这种模式开发的系统,如果以后其它人要了解就比较容易了,只需集中精力了解通用语言和模型基本上就可以了。而后续的变动,同样首先是对通用语言和模型的变动,然后再将模型的变动反映到代码中,这样的变动是一种有序的变动,而不是像以前追在数据后面疲于应付。这样,我们才能不断的将业务收集、沉淀下来,最终反映在产品的竞争力上。
既然领域驱动设计这么好,为什么大多数公司还是采用以数据为中心的开发模式呢?究其原因:难以掌握。这就好像一个分界线,一旦掌握了领域驱动设计,感觉从哲学的层面升华了系统开发的思维方式,从而不再过多地受制于具体的编程语言和技术框架。
说了这么多,实际上本人也是摸着石头过河,便学边实践。
(二)实例描述
该系统由一个公司雇员数据库以及和雇员相关的数据(比如:工作时间卡)组成。该系统必须为每个雇员支付薪水。系统必须按照规定的方法准时地给雇员支付正确的数目的薪水。
l 有些雇员是钟点工。会按照他们雇员记录中每小时报酬字段的值对他们进行支付。他们每天提交工作时间卡,其中记录了日期以及工作小时数。如果他们每天工作超过8小时,那么超过的部分会按照正常报酬的1.5倍进行支付。每周五对他们进行支付。
l 有些雇员完全以月薪进行支付。每个月的最后一个工作日对他们进行支付。在他们的雇员记录中有个月薪字段。
l 同时,对于一些带薪雇员,会根据他们的销售情况,支付给他们一定数量的酬金(commission)。他们会提交销售凭条,其中记录了销售的日期和数量。在他们的雇员记录中有一个酬金字段。每隔一周的周五对他们进行支付。
l 雇员可以选择支付方式。可以选择把支付支票邮寄到他们指定的邮政地址;也可以把支票保存在出纳人员那里随时支取;或者要求将薪水直接转帐到指定银行卡号。
l 薪水支付应用程序每个工作日运行一次,并在当天为相应的雇员进行支付。系统会被告知雇员的支付日期,这样它会计算从雇员上次支付日期到规定的支付日期间应支付的数额。
如果读过《敏捷软件开发-原则、模式与实践》这本书就会发现,这个例子实际上就是那本书的核心实例,之所以用这个例子来进行领域驱动设计的,一是这个例子大小比较合适(我删掉了支付会费部分);二是许多对面向对象感兴趣的人也基本熟悉这个实例,容易理解沟通;最后原书也有一些开发的代码,但并没有写完,借着这次机会按照领域驱动的一些要素重新开发已有的,再将未开发的补全,也算是补作业。
(三)需求分析、设计
无论什么系统,需求分析都是重中之重,但分析的方法和目标却是截然不同的。以数据为中心的系统在需求分析时更偏重业务的流程和产生的数据,数据在业务流程中的状态变化。而面向对象的分析则侧重于找出业务中涉及的对象(常见的是人或物,但也有很多不是人和物,比如本例中的支付安排,工资计算方式,这个不是一两句能说清的,是面向对象的难点,也能判断出一个人面向对象设计功力深浅),各对象在完成业务场景过程中的交互行为。
1) 系统目标分析
首先,我们需要对整体的系统目标进行定义,说得高大上一些就是系统的愿景。这个对后续的分析很重要,一切都围绕该目标展开。当有一些新的发现或想法时,都问上一句:这些是不是更有利于系统目标的实现?
本例的目标:按照规定的方法准时地给雇员支付正确数目的薪水。
2) 上下文分析
这块是应领域驱动设计中强调的战略设计部分,也许是例子太小,所以我仅仅分析系统的上下文。我认为应该将整个发薪看作一个上下文,一个完整地计薪、发薪过程。也许有人认为应该将系统分为三个上下文:雇员上下文,计薪上下文,发薪上下文。这种看法我觉得也很正常,更多的是基于一种我们常见的情景,比如一般企业会建设OA系统,里面包含了雇员管理模块,计薪模块,薪水发放模块。雇员模块可能记录了雇员的基本信息,工作经历,技能情况等等;计薪模块负责计算薪水;发薪模块负责发放薪水,扣除费用等。对应这三个模块刚好是三个上下文。如果这些假设成立,我也认可这种结果,但就我们目前已知的情况,我们仅仅是建设一个薪水支付系统,不应过分假设。
3) 对象分析
对象分析是一个渐进的过程,通过对需求中高频词语的提取,筛选,抽象。然后可能会在这个过程中不断的根据新的发现画出一些类图草图,换句话说在整个系统的建设过程中,在任何阶段如果发现一个新的对象能够让对业务的描述更清晰,模型更容易表达业务,那么就需要对已完成工作进行相应的持续构建,把新的对象反映进去。
本例中我省去了中间渐进过程,形成最后的对象(核心模型见下图1)包括:
图1 核心模型
雇员:公司需要为之付工资的人。在需求中我们可以发现提到了钟点工,带薪雇员,销售人员(也可以认为是带薪雇员的一种),一般第一印象是创建一个雇员父类,然后三种雇员继承父类。但通过分析,我认为其实单纯从人的信息这个角度来说,需求中并没有体现出太多的不同,只所以分为三类雇员,是因为他们的工资构成不同,而这个正是系统的核心所在:规定的时间正确发放工资。所以我们可以通过在工资部分进行合理的规划来识别清楚雇员的类型。退一步讲,即使不能识别清楚员工类别,只要能够正确地发放每一位雇员的相应薪水,也达到了系统的目标。所以在模型中我并没有对雇员进行抽象。而是直接构建一个雇员对象,相关的属性也是雇员基本的属性,如姓名,雇员编号等。另外一个暗示点我认为是带薪雇员,销售人员。销售人员其实也是带薪雇员的一种,我们如果试图对这两类雇员进行建模时就感觉比较别扭,不易说清楚,我认为这也暗示着不应该在薪水发放的上下文去区分雇员类别。
工资:每一位雇员都有一份工资。每份工资包含多个工资分项。工资对象更像是一个容器类,指明了对应雇员的工资都是由哪些工资分项组成的。
工资分项:公司需要支付给雇员的各类费用。工资分项对象包含了它的计算方式和发放时间安排。
计算方式:工资分项计算方式的抽象对象,它主要负责正确的计算工资分项的工资。
发放时间安排:工资分项发放时间安排抽象对象,它主要负责计算相应工资分项的发放时间。
发放方式:雇员工资发放方式的抽象对象,它主要负责将该发放的工资如何发放到给雇员。
工资分项(图2)的具体继承类:钟点服务费,月薪,酬金,从类名很清楚的表明了该类的职责,就不再一一描述。同样,计算方式(图3)、发放时间安排(图4),发放方式的实现类也不再进一步说明,原因同上。把它们抽象出来就是为了在抓取领域的知识时屏蔽各种复杂的细节,这对于我们理解模型非常有帮助,我们先有一个宏观轮廓:每位雇员都有一份工资,每份工资包含不同的工资分项,每种工资分项有一个计算方式进行工资计算,怎么计算细节可以暂不了解,这也符合人的认知特点,先了解宏观整体,在逐步详细了解各部分。对发放时间安排、发放方式进行抽象也是同样的原因。另外这样也非常有利于系统后期的扩展。
接下来重点讨论下计算方式对象(图4)实现类的一些考虑。这块的实现是工资能够正确进行计算的核心。以工资分项---钟点服务费的计算方式为例来进行进一步讨论。钟点服务费 = ∑(每天正常工作时常 X 每小时报酬 + 每天超出正常工作时长 X 1.5倍每小时报酬),这里面有几个重要信息,每天正常工作时间、每小时报酬,我把它们作为钟点服务费工资分项计算方式实现类的属性,然后把打卡记录也作为该实现类的属性,这样,这个实现类就可以人独立完成钟点服务费的工资计算。但从我们更直观的认识这些作为钟点服务费实现类本身的属性更符合我们的认知和习惯,而服务费计算方式更应该只有计算公式,它所需的信息都由他的拥有者传递给它,它利用这些传递的信息完成它的计算过程即可。这中思路我在刚开始学习面向对象时也是这样认为的,到现在可能也会下意识的这样进行建模。但如果你这样做了,你很快就会发现,这种模型只会增加模型的复杂性。基本类的属性传递给算法,如每天正常工作时间还比较顺畅,但如果是打卡记录呢?你会发现算法类和算法类的拥有者都需要依赖它,而算法的拥有者只是经过个手,什么事也没干。另外,这样也让算法的使用者在使用算法时要准备大量的所需信息,加大了使用者的负担。所以有一个原则,如果一个类即可以放A类中,也可放在B类中时,那就把它放到直接使用它的类中。
有人说,如果后期我需要查看某个时间段所有雇员的打卡记录,这个模型不是很麻烦吗?的却是这样。但我们仔细分析:第一,不管是放在算法中,还是放在工资分项中,都需要通过每个雇员来汇总打卡记录,然后筛选出符合条件的打卡记录,这个和打卡记录放在哪个对象中关系并不大。第二,如果有这样的需求,那我认为也许这样的需求所需的打卡记录就仅仅是计算工资所需的那么一点点,也许还需要当天的工作纪要等等。我这样说的意思是想说,这样的需求如果是一个重要的需求,那么可能需要我们提供打卡登记模型,发布打卡登记事件,而工资计算中的打卡记录通过订阅该事件形成本地的所需的打卡记录。
4) 总结通用语言
形成通用语言的重要性不需多言。何时总结通用语言,首先肯定不是本文中的顺序,先进行对象分析和模型设计再总结。我认为这几个过程是一个不断循环、没有固定顺序的过程。大概对领域的对象有一个初步的认识,就应该使用这些对象进行总结通用语言,然后试着模型设计。也可以试着进行模型设计,觉得差不多再总结通用语言。总之,交替进行,最终通用语言和模型能够一致即可。
具体到本例我们用简洁的语言对业务进行描述:
每类雇员的薪水由不同薪水分项组成,每种薪水分项规定了相应的计算方式和发薪时间。每位雇员指定了薪水支付方式。系统应在在薪水分项的发薪时间正确发放薪水。
5) 领域模型设计
根据通用语言的描述,很容易就可以得出图1的模型。我把Employee和PaymentItem作为了聚合根。也有一种选择是只把Employee作为聚合根。我没有这么做的原因其一觉得这样Employee对象的职责过多,不够稳定;其二对象也嵌套过深,不利于调用;其三从事物一致性角度考虑,比如修改钟点工个人信息和登记时间卡。这两个操作应该是互不相干的,不应该在一个事务中。假设我要登记某天的打卡记录,但如果在此过程中有人修改了雇员姓名,当我们保存打卡记录时这个修改的过程会被覆盖掉,也就是事物的一致性没有得到保证。当然有人说登记打卡记录时你别修改人的信息不就可以了,是可以这样做,但这样做就违反了聚合根的使用初衷:隐藏领域内部细节。
还有一个争议点是工资对象为什么没有作为聚合根?我的考虑是工资如果作为聚合根实际上没有太多职责。类似于给雇员制定工资分项,登记时间卡等等操作,基本上工资都是委托工资分项对象来完成,如果此时工资分项不是聚合根,因为各工资分项的差异,这些操作通过工资对象来完成时难度是比较大的。但如果工资分项此时是聚合根,那工资再作为聚合根就不会承担什么职责。
把工资分项作为聚合根更符合系统的需要。整个系统更多的是每种工资分项的计算和时间安排,这也注定了工资分项更应该作为聚合根对外开放,让领域的直接使用者如应用层更高效的利用领域,也不会过度的暴露领域的细节。把工资分项作为聚合根也会面临事务一致性的问题困扰,比如修改钟点工的每小时报酬和登记时间卡其实也是不相干的事情,只不过较之修改钟点工个人信息和登记时间卡关系紧密些。应该分开,但把工资分项作为聚合根就会忽视这种不相关。这是一个弊端。如果想要更清楚的事务一致性,那就直接把打卡记录作为聚合根,但这会带来打卡记录实体到工资分项中打卡记录的一致性问题。这可能就是设计的难处:权衡利弊。
其它模型细节:
图2工资分项
图3 工资分项计算方式
图 4 工资分项发放时间安排
(四)代码实现(待续...)
代码实现我想主要完成以下使用场景:
1) 维护雇员。
2) 指定雇员工资。
3) 可以计算雇员的每种薪水。
登记钟点工工作卡。
登记销售人员销售卡。
4) 指定每位雇员的薪水支付方式。
5) 每天发放薪水。
具体代码实现待续 。。。