领域建模——架构设计的第一步(下)
正如上一篇所述,在领域驱动设计中策略设计侧重于子域的拆分和集成,其结果是合理划分的子域以及它们之间的交互关系。当系统已经被拆分成子域之后,领域驱动设计中的技术维度则关注更小的粒度,主要解决的问题是处理在某一个子域之内各个对象之间的交互关系。为此,本篇引出实体(Entity)和值对象(Value Object)的概念并介绍它们背后的聚合(Aggregation)设计思想。
实体
通常,我们在设计一个系统时不知不觉会使用数据驱动的设计思想,即先设计数据库模型,然后再根据数据设计服务层和表现层。数据驱动设计思想非常常见,开发者趋向于关注数据而不是领域,但我们认为面向领域的实体对象才是能够表达业务逻辑的有效载体。究其原因,在于很多对象不是通过它们的数据属性来定义,而是应该具有一系列的标识和行为定义。在领域驱动设计中,我们关注的是实体(Entity)对象而并非数据本身。当然,实体对象本身也包含数据属性,我们可以采用一定的手段把数据对象转换成实体对象。通过标识区分对象,并为对象引入状态改变和生命周期就能达到这种转换目的。
领域驱动设计中,实体应该具有两个基本特征:唯一标识和可变性。
1.唯一标识
唯一标识(Identity)的创建有几种通用的策略,如用户提供初始唯一值、系统内部自动生成唯一标识、系统依赖持久化存储生成唯一标识等。用户提供初始唯一值的处理方式依赖于用户通过界面输入,系统根据用户输入判断是否重复,如果重复则不允许创建实体。系统内部自动生成唯一标识被广泛应用于各种需要生成唯一标识的场景,策略上可以简单使用 JDK 自带的 UUID,也可以借助于第三方框架如 Apache Commons Id,但更为常见的是根据时间、IP、对象标识、随机数、加密等多种手段混合生成。
2.可变性
涉及到实体的可变性,我们不得不面对一个老生常谈的话题,也就是到底应该采用贫血模型还是充血模型。贫血模型的系统结构如下图所示,它的优点在于层次结构清楚,各层之间单向依赖,领域对象几乎只作传输介质之用,不会影响到层次的划分。但缺点也很明显,即领域对象只是作为保存状态或者传递状态使用并不包含任何业务逻辑,只有数据没有行为的对象不是真正的领域对象,在应用层里面处理所有的业务逻辑,对于细粒度的逻辑处理,通过增加一层 Facade 达到门面包装的效果,应用层比较庞大,边界不易控制,内部的各个模块之间的依赖关系不易管理。这些都与面向领域驱动设计中子域和界限上下文的划分以及集成思想相违背,所以我们推荐的是充血模型。
充血模型优点是面向对象,应用层符合单一职责,不像在贫血模型里面那样包含所有的业务逻辑太过沉重。同时,每一个领域模型对象一般都会具备自己的基础业务方法,满足充血模型的特征。充血模型更加适合较复杂业务逻辑的设计开发(见下图)。但如何划分业务逻辑,也就是说要做到把业务逻辑正确放在领域层和应用层中比较困难。
值对象
当只关心对象的属性时,该对象应归为值对象(Value Object),从这点上讲值对象有点类型贫血模型对象。但值对象具备根据明确的约束条件,包括值对象是不变对象、值对象没有唯一标识、值对象具有较低的复杂性。
一般在对系统的实体和值对象进行提取时,关注点首先在于实体,当我们把实体提取完毕,就需要进一步梳理实体中是否包含了潜在的值对象。值对象的特征决定了如何分离值对象的方法。如果一个对象满足自身是度量或描述领域中的一个部分、可以作为不变量、将不同的相关属性组合成一个概念整体、当度量或描述改变时可以用另一个值对象予以替换、可以和其他值对象进行相等性比较、不会对协作对象造成副作用等条件时,我们就认为该对象很大程度上就可能是一个值对象。下图就是从实体中分离值对象的示例,我们发现 Customer 对象中包含了客户的 Address 信息,而 Address 就是一个值对象,因为 Address 将 Street、City、State 等相关属性组合成一个概念整体,Address 也可以作为不变量,当该 Address 改变时,可以用另一个 Address 值对象予以替换。
值对象可以用来表示标准类型,也可以在上下文集成中充当对外的数据媒介。值对象在实现上需要严格保持其的不变性,通过只用构造函数、不用 setter 方法等手段可以构建一个合适的值对象。
识别实体和值对象
识别实体和值对象是面向领域技术设计的第一步,基本的思路还是充分利用业务逻辑中的信息,并采用以下四个步骤。在本节中我们将通过一般系统中常见的账户中心(AccountCenter)为例对这些步骤进行具体展开。
1.识别实体
AccountCenter 对账户(Account)的描述包括:必须对系统中的 Account 进行认证;Account可以处理自己的个人信息,包含姓名、联系方式等;Account 的安全密码等个人信息能被本人修改。请大家注意“认证、“修改”等关键词,从这些词中我们可以判断出 Account 应该是一个实体对象而不是值对象,所以 Account 应该包含一个唯一标识以及其他相关属性。考虑到 Account 实体的唯一标识 UserId 可能只是一个数据库主键值,也可能是一个复杂的数据结构,所以我们 AccountId 提取成一个值对象。这样基本的 Account 实体就识别出来了(见下图)。
2.挖掘实体的关键行为
对 Account 而言,我们再进一步细化。考虑到关联 Account 的用户可能离职等原因,Account 可以处于激活或锁定状态,对应的 Account 实体应用具备激活或锁定相关的行为。包含行为的 Account 实体见下图。
3.识别值对象
考虑到激活状态的 Account 可以修改安全密码、姓名、联系方式等个人信息,我们势必需要从 Account 实体中提取姓名、联系方式等信息,这些信息实际上构成了一个完整的人(User)的概念,但显然 User 不等于 Account,而是 Account 的一部分。Account 作为一个抽象的概念,包含 User 相关信息,也包含用户名、密码等账户相关的信息,所以这个时候我们发现需要从 Account 中进一步分离 User 对象。User 同样也是一个实体,但与 User 紧密相关的联系方式等信息倾向于分离成值对象。对 Account 实体进行进一步分离之后我们可以得到下图的细化结果。
4.构建概念整体
通过以上分析,我们发现从通用语言出发,围绕 Account 概念所提取出来的实体和值对象有多个,其中 Account 和 User 代表两个实体,Account 中包含 User 实现和 AccountId 值对象,而 User 中包含 UserId、Name 和 ContactInfo 值对象。
最后,我们再来总结一下实体和值对象的区别。从标识的角度,实体有唯一标识,值对象没有唯一标识,不存在这个值对象或那个值对象的说法;从是否只读的角度,实体是可变的,而值对象是只读的;从生命周期的角度,实体具有生命周期,而值对象无生命周期可言,因为值对象代表的只是一个值,需要依附于某个具体实体。
聚合的概念
一个子域中包含若干聚合(Aggregate)。聚合的核心思想在于将关联减至最少有助于简化对象之间的遍历,使用一个抽象来封装模型中的引用。聚合的组成有两部分,一部分被称为根(Root)实体,是聚合中的某一个特定实体;另一部分描述一个边界,定义聚合内部都有什么。聚合代表一组相关对象的组合,是数据修改的最小单元,也就是意味着对对象组合的修改只能通过聚合中的根实体进行,而不是对于组合中的所有实体都能进行直接修改。
聚合具备如下固定规则。聚合中的根实体具有全局标识、外部系统只能看到根实体,只有根实体才能直接通过数据进行查询获取,其他对象必须通过聚合内部关联的遍历才能找到,而且删除操作必须一次删除聚合之内的所有对象。这些固定规则都是为了减少复杂关系下对象遍历的次数,明确系统边界。
参考下图中的聚合示意图,左半部分代表的是没有采用聚合概念的对象遍历图,我们可以看到任何对象都能两两进行交互,所以对象都处于同一个边界中;而右半部分显然有所不同,通过聚合思想把系统划分成三个边界,每个边界里面包含一个聚合,图中与外部边界直接关联的就是聚合中的根对象,我们可以看到只有根对象之间才能进行直接交互,其他对象只能与该聚合中的根对象进行直接交互。以图中的8个对象为例,通过聚合可以把最多 28-1 次对象直接交互减少到 23-1 次。
使用聚合将领域模型分散和参与到每个聚合中,这使得领域模型更容易理解。聚合也能帮助我们明确查询操作和删除操作的范围。同时,因为对象的访问只能通过聚合中的根实体进行,考虑到性能和伸缩性,我们一般建议聚合的大小不应该过大。
聚合建模
我们假想一个场景,在项目管理中一个项目可以创建很多任务,一个项目可以评估出一个项目计划。通过分析业务逻辑,可以识别 Project、Task、Plan 这三个主要实体或值对象。关于如何设计这些对象之间的关联,我们有几种思路。
下图是一种基于聚合的建模思路,我们把 Project、Task、Plan 归为实体对象,并把 Project 上升到聚合的根对象。这样外部系统只能通过 Project 对象访问 Plan 和 Task 对象,而 Project 中包含着对 Plan 和 Task 的直接引用。
思路二(见下图)走的是另一条路,我们把 Plan 和 Task 同样上升到聚合级别,意味着重新划分了系统边界,三个对象构成了三个不同的聚合。显然,这种情况下,Project 对象中包含着对 Plan 和 Task 的直接引用是不合适的,在不破环现在有的实体关系,我们可以通过引入值对象来缓解这种现象,通过把唯一标识提取成一个值对象 ProjectId,Project 通过 ProjectId 与 Plan 和 Task 对象进行关联。
思路一和思路二代表着各有利弊的两种极端,有三条聚合建模的原则可以帮忙我们找到其中存在的问题。
1.聚合内部真正的不变条件
第一条建模原则在于关注聚合内部建模真正的不变条件,什么样的业务规则应该总是保持一致,即在一个事务中只修改一个聚合实例,如果一个事务内需要修改的所有内容处于不同聚合中,我们就要重新考虑聚合划分的有效性。另一方面,聚合内部保持强一致性的同时,聚合之间需要保持最终一致性。
考虑思路二中的 Plan 对象,因为系统的目的就是获取 Project 的 Plan,而不是把 Project 和 Plan 分别进行管理,也就是说更新 Project 的同时也应该同时更新 Plan,Project 和 Plan 的更新处于同一个事务中,所以把 Plan 放到以 Project 为根实体的聚合中更加符合聚合建模的这一条原则。
2.设计小聚合
聚合可大可小,设计聚合大小的通用原则是考虑性能和扩展性,我们倾向于使用小聚合。大的聚合可以降低边界对象遍历的数量,但聚合内部包含更多实体和值对象。从性能角度讲,聚合内部复杂的对象管理和深层次的对象遍历会降低系统的性能,因为很多边界处理实际上并不需要涉及过多的聚合内部对象。而对于扩展性,系统的变化对于大粒度聚合的影响显然大于小聚合。同时,考虑到实体具备生命周期和状态变化,聚合建模也推荐优先使用值对象以降低聚合内部复杂性。
3.通过唯一标识引用其他聚合
通过唯一标识引用其他聚合对聚合设计的提示就是通过标识而非对象引用使多个聚合协同工作。聚合中的根实体应该具备唯一标识,思路二中引入值对象 ProjectId 作为 Project 的唯一标识,并通过该值对象与其它聚合中的根实体进行交互就是这条原则的具体体现。如果 Task 业务作为一个根实体的话,一般也会提取一个值对象 TaskId 作为其唯一标识。
综合运用三条聚合建模的原则之后,我们可以得到入下图的聚合建模思路三,这是我们对上述场景进行聚合建模的最终结果。
聚合与子域
子域、聚合和界限上下文三者之间的关系示意图如下所示。该图根据业务功能的特性把整个系统拆分成四个主要的子域,分别包含一个核心子域、两个支撑子域以及一个通用子域,每个子域都有其界限上下文,各个界限上下文之间可以根据需要有效整合从而构成完整的领域。
从上图中我们也可以进一步看到,聚合位于子域中,与子域一样代表的也是一种拆分的层次和粒度。通过在子域的基础上再添加一层聚合的抽象,使得系统拆分不仅仅在大的业务体系级别有所体现,也精确到了系统内部的对象级别。聚合概念的提出与软件复杂度有直接关联。软件设计中的一大问题就在于大多数业务系统中的对象都具有十分复杂的联系,现实世界很少有清晰的边界。复杂的关系需要通过关联数量庞大的对象才能建立,复杂关系的开发和维护需要投入巨大成本,这也是我们为什么要推行微服务架构的一个根源,关于微服务架构的更多内容请参考本课程的第八篇《微服务——最热门的架构》。
对从事应用软件开发而又立志往架构师方向转型的广大程序员而言,面对所谓的架构设计,内心可能会萦绕这样一个问题:架构设计到底是面向技术还是面向业务?诚然在很多技术人员眼中,架构设计几乎等同于技术架构设计,对架构师的理解也主要关注于对各种技术体系和框架的掌握程度。然而,很多团队并不缺少出色的技术人员,但是产品开发最终还是会以失败而告终。究其原因,在于技术人员往往只关注于技术架构,而对系统设计的其他方面,尤其是对业务的理解缺少足够的重视。
当然,针对不同性质的系统开发,架构设计的工作重点显然也会有所区别。在对“架构设计到底是面向技术还是面向业务”这个问题作出判断之前,我们首先需要明确两点,第一点是我们是不是在做业务?除了专门从事中间件或底层框架开发的少数场景之外,绝大多数的软件开发工作实际上都是围绕着现实中的业务问题而展开。如果面对的是业务导向的开发场景,那么我们就要考虑第二点,即面对复杂的业务逻辑架构师应该怎么办?采用主流的架构设计理念和先进的技术实现体系,对业务的充分理解,并且能够对业务与技术进行整合的能力同样是成为一名合格架构师的必要条件。
面向领域思想阐述的架构设计方法既面向技术也面向业务。在上一篇和本篇的内容中,我们探讨了如何整合技术和业务,并引入领域驱动设计思想。领域建模是架构设计的第一步,也是成为架构师首先应该具备的重要知识体系。