模型驱动设计
在平常,我们使用的最多的开发思路就是 CRUD,各种信息管理系统都可以看作是对数据库增删查改的封装,在应对小的项目的时候,CRUD 可真是神器,不管三七二十一,一把梭,第一期就完成了。但是随着客户需求的变更与增多,问题随之而来。为什么会导致这样的问题?作者给出的解释是,开发人员对业务没有深入的理解。就像是原来做一些很难的数学题一样,看到书后的参考答案(用户的最终目标),然后就开始尝试各种拼凑得出答案,虽然最后终于把答案拼装出来了,但是因为完全不懂原理,一旦题目做了一些变动,就会变得束手无策。作者在书中也举了很多类似的例子,例如开发团队一开始不太理解电子电路设计相关的知识,导致做出来的东西让对方感觉很奇怪,而且没法适应业务的变化。
应对这个问题的解决方案就是对业务领域进行建模。在书中,作者反复提到了模型驱动设计(Model Driven Design)这个名词,这里的模型,指的就是领域模型。构建领域模型需要一位对业务领域相当熟悉的专家跟开发团队共同努力。专家将自己的业务领域相关的知识拿出来与开发团队一起共享,这样开发团队才能正确认识到正在开发的东西要如何实现业务规则。
那么如何描述这么一个领域模型呢?作者的建议是使用“统一的语言”,这里的统一的语言主要是指开发团队要跟领域专家使用相同的术语指代同一个东西,这主要是为了避免交流过程中产生的理解上的障碍。就比如,大家在一起讨论地瓜这个东西怎么烹饪才好吃,结果每个人的说法都让对方感到非常奇怪,因为有的人认为地瓜指的是“沙葛”,有的人以为地瓜是说的“红薯”。
将统一的语言跟模型绑定起来,在谈论领域模型的时候,都必须使用“统一的语言”进行描述,除此之外,在团队内部的交流中,更是应该始终坚持使用统一的语言进行交流,如果发现现有统一的语言用来讨论业务需求有些困难,就应该考虑是不是建模上面出现了偏差。作者强调:
要认识到, UBIQUITOUS LANGUAGE 的更改就是对模型的更改。
领域专家应该抵制不合适或无法充分表达领域理解的术语或结构,开发人员应该密切关注那些将会妨碍设计的有歧义和不一致的地方。
领域模型还应该被文档化,作为口头交流和代码的补充。代码能够在很大程度上展现出模型的细节设计,但参与项目的人员还需要看到这个模型的全貌。开发团队内的交流可以帮助其他人理解模型的整体设计,在编码过程中起到指导作用,但是需要了解领域模型并不只是开发团队,所以,文档化的模型也是必要的。作者还建议,文档应该尽可能的少,主要用作口头交流跟代码的补充说明,同时还应该时时保持更新。当然了,最重要的是,文档同样应该使用前面提到的统一的语言。
基于模型驱动设计的架构
领域模型最终还是要通过代码来进行实现,如果之前构建的模型跟代码脱节了,那这个模型驱动设计就变得名存实亡了。基于关注分离的这个原则,我们应该将代码中涉及到领域建模的部分分离出来。例如,一个医疗报销信息管理系统中,业务领域主要包括报销审批的流程控制、各部门预算管理,而像导出 Excel 报表之类的东西,并不是业务领域关注的重点,所以报销审批跟预算管理部分的代码应该与报表导出之类的代码分离。
实现分离领域模型的技术我们非常的熟悉,就是分层架构。作者建议,应该将项目代码按照“用户界面层”、“应用层”、“领域层(模型层)”、基础设施层进行划分。
层次 | 作用 |
---|---|
用户界面层 | 与客户端发生交互的地方,客户端可以是使用系统的人,还可以是其他调用本软件接口的系统 |
应用层 | 软件需要完成的与业务领域无关的工作在这里进行,例如信息管理系统打印报表的功能就属于应用层 |
领域层 | 整个业务软件的核心,负责表达业务相关的概念与规则 |
基础设施层 | 可以看作是整个系统的后勤部门,例如:为应用层提供打印能力,为领域层提供数据持久化的能力 |
各个层次内保持高内聚,各个层次间保持低耦合,而且耦合方向必须是高层依赖低层,例如上层调用下层的公共函数接口。只有做到这样,才能够帮助我们更好的集中注意力开发当前的层次,尤其是专注于领域层的设计,这将是领域驱动设计的精髓所在。
当然,软件架构的技术不止分层一种,这里作者提到的只是最普遍的做法,只要能够达到将领域层分离的目的,使用其他的方式也是可以的。
软件模型
当我们完成了领域建模部分的分离后,我们就应该全身心的专注于使用代码进行领域建模了。这里需要我们自己设计的模型主要分为下面几类:实体(Entity)、值对象(Value Object)、服务(Service)、模块(Module)。
实体与值对象
模型驱动设计把业务对象分为两类:实体与值对象,区分一个对象在业务领域中到底属于实体还是值对象的方法非常简单——能否仅通过标识区别。这里标志类似于业务模型中的主键,可以是对象的某个属性,还可以是几个属性的组合,有时候,还可能是通过序列生成器生成的唯一 ID,一个对象是否拥有这样的标志,主要取决于我们的业务需要。
举个例子,早餐店卖袋装豆浆,店铺不关心袋装豆浆的编号,只要有存货,就可以接着卖下去;而店内的座位则不同,因为店员需要知道订单要送到哪个座位上,这时候,我们的系统就不得不关注座位的编号。所以,在上面的系统中,座位应该被设计成实体,编号不同的座位应该加以区别。但是情况并不往往都是这样,假如上面的店铺店员不够用,店长决定改成让客人自己去餐台领餐的模式,这时候座位在我们的系统也就不需要跟订单关联,那么座位号也就会变得无关紧要,这时候再把座位设计为实体就会显得多余。
那什么样的东西应该设计为值对象呢?考虑一个在学生信息管理系统中的名字类型:
public class Name
{
string FirstName;
string LastName;
}
根据我们的常识,姓氏与名字一样的姓名应该是等价的,不管这个姓名对象我们手动 new 出来的,还是从数据库中读取并反序列化出来的,只要跟另一个姓名对象中的对应字段的值相等,那么这两个对象就是可以相互替换的。像这种情况,姓名对象在我们的领域模型中就应该是一个值对象。同时,值对象应该是不可变的,不包含标识,而且应该足够简单。再让我们回顾一下上面用 C# 代码定义的姓名类,可以发现他距离我们对值对象的定义还有些差距——没有重现比较规则、字段可变。与其去再去定义一堆重写的方法跟属性的访问控制器,不如尝试一下 .Net 家族中 F#,或者 JVM 系的 Kotlin。
// 使用 F# 定义姓名类
type Name = { FirstName: string; LastName: string}
let nameA = { FirstName="a"; LastName="b"}
let nameB = { FirstName="a"; LastName="b"}
printf "%A" (nameA = nameB)
// --> true
服务
在领域建模的过程中,往往需要封装一些操作和过程,这是时候就需要使用服务来表示这些操作。举个例子,在医疗报销信息管理系统中,员工登记好的报销单需要提交给相应的主管人员审批才能生效。假设我们把提交这个操作作为员工实体的职责之一,但是这样做的话,员工对象可能还需要了解需要提交到哪位主任那里,如果我们的系统需要定义多种审核规则的话,那员工实体需要了解的东西就会变得很多。显然,将提交审核的操作封装为一个服务将会是一个更好的做法,员工实体只需要关注本年度自己的余额会被扣除就好了,而具体由哪位主任去审核,均由我们的报销单提交服务负责处理。除了用来封装业务领域的操作,服务还可以用来封装领域层提供给上层(应用层)的接口,作为领域层的边界。
但并不是所有的我们分析出来的服务都属于领域层。还是上面的例子,当审核提交成功的时候,需要给员工发个通知,我们可以在界面上弹个消息通知一下,或者使用短信通知一下,像这样的通知服务,就不属于业务领域的一部分。
服务往往是使用一个动词来命名,而且,这个动词应该出现在“统一的语言
中,服务的调用参数与返回结果也应该是领域对象。服务并不是把属于实体或值对象的职责据为己有的一个对象,而是对业务领域中一个有着重要作用的操作或者过程封装。同时,服务应该做到无状态,任何时候调用服务的任一个实例而不必关注服务实例的历史状态。
模块
原文使用了 Module 这个词语,根据我的理解,这里 Module 对应到 C# 中指的是 NameSpace,在 Java 中指 package,在 Angular 中指 NgModule。模块不仅仅只是对代码的划分,同时还是对领域概念的划分。在对代码进行组织的时候,需要考虑到代码在领域层面的关联性。
聚合
上面提到了,使用模块可以将不同的领域概念划分,这仍然只是一个粗粒度的划分,在模块的内部我们还需要面对实体对象间错综复杂的联系(单向关联、双向关联)。具体的复杂性体现在,对一个对象的一些属性的修改,可能会导致新的对象的生成或者旧的对象的销毁,有时还可能伴随着对一些其他对象的修改。像这种牵一发而动全身的事情是我们在开发过程中最不希望看见的,那么应该如何知道,对一个对象修改的影响范围是从何处开始,又到哪里终止呢?聚合模式给出了一个解决方案。
在聚合模式中,我们把几个关联度较高的对象划分成一个聚合,每个聚合中有一个小组长,我们把小组长叫做聚合根(Root),聚合的内部对象间可以相互引用,但要是想从聚合外引用里面的对象,这是不被允许的,从外部只能引用聚合根。如果要从外界访问聚合内部的实体,只能从聚合根那里获得一个不可变的引用,如果要访问聚合内部的值对象,也只能从聚合根那里获得值对象的一个拷贝。聚合内部的对象也只能由聚合根创建,一旦聚合根销毁,聚合内的全部实体跟值对象也都会一起被销毁。这也就是说,从聚合的外部不能随意的创建、删除或者修改属于某个聚合内部的对象,聚合变成了我们修改数据的最小单元。
规则确实比较多,但是正是这些规则帮助我们简化了对对象生命周期的管理。在没有垃圾回收器的 Rust 语言中,也使用了类似的概念实现了对对象生命周期管理的能力。不过聚合除了管理对象生命周期之外还有贯彻落实业务规则的能力。比如在我们上面的学生信息管理系统中,姓名很可能是属于学生作为聚合根的聚合中的一个实体,所以姓名对象的创建、修改操作由学生来控制,这样的话,我们就可以把“姓名必须使用汉字”、“姓名长度不能超过 30 个汉字”这样的业务规则交由学生来处理。