开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情
诞生背景
领域驱动设计(Domain-driven Design)由Eric Evans在2003年提出,是一种经典的软件设计理论,通过领域专家和软件工程师的专业知识和紧密合作使软件设计得更合理。众所周知,抽象是软件系统成功的第一步,系统越复杂抽象越重要,而DDD最重要的能力就是提供了一系列思想工具帮助我们更好地进行软件抽象,所以本文将之归为抽象模型。
DDD要求我们设计之初应该对需求进行领域建模,而不是转化成数据和行为,也就是优先考虑业务领域模型,而不是数据模型,因为以数据来驱动设计,未来的扩展会被限制在数据库上。一般地,我们会用分层架构与DDD相结合,将领域相关的功能放在一个domain层,domain层里各个服务/模块只负责相对应领域的逻辑,不同领域的整合逻辑上移到一个service层。
DDD有一些核心概念需要理解:
- 领域:领域即问题域,比如银行要解决存、贷等问题,就有存、贷等领域。领域可拆分成一个个子领域。按照重要程度和职能,领域或子领域又会属于核心域、通用域、支撑域
- 限界上下文:同一个东西要在特定的上下文内进行定义才有意义,比如苹果,可以是水果也可以是手机。因此,DDD创建了限界上下文,同一个限界上下文内建立起一套通用概念,包含同一个语境下的所有领域对象和关联行为。领域要依靠限界上下文来划分,有时一个领域或一个子领域就是一个限界上下文。DDD中的限界上下文可以用于指导微服务中的服务划分。
- 聚合:关联密切的领域对象(聚合根/实体/值对象)被划入同一个聚合内。一般地,同一个聚合内数据实现强一致性,不同聚合间的数据实现最终一致性。一到多个聚合组成一个限界上下文。
聚合根(Aggreate Root, AR)-业务的载体
一个聚合内唯一的核心领域对象,软件模型中那些最重要的以名词形式存在的领域对象,比如一个机票搜索系统,航班和报价就是聚合根;电商项目中的订单和商品、保险系统中的保单便是聚合根。"聚合“将领域中高度内聚的概念放在一起组成一个整体,需要开发团队和领域专家一起基于对业务的深刻认识,罗列出领域中发生的所有事件可以让我们全面的了解领域中的业务,进而识别出聚合根。聚合根是主要业务的逻辑载体,DDD中所有战术都围绕聚合根展开。一般地,外部对象要访问聚合内对象,要通过聚合根来间接访问,不能直接访问。
限定上下文
既然要聚合,那么聚合是否有一个边界?既然要内聚,那么让我们把所有相关的东西都聚到一起吧,比如用一个Product类来应付所有的业务场景,包括订单、物流、发票等等。对聚合根的设计需要提防上帝对象(God Object),也即用一个大而全的领域对象来实现所有的业务功能。这种机械的方式看似内聚,实则恰恰是内聚性的反面。要解决这样的问题依然需要求助于限界上下文,不同限界上下文使用各自的通用语言(Ubiquitous Language),通用语言要求一个业务概念不应该有二义性,在这样的原则下,不同的限界上下文可能都有自己的Product类,虽然名字相同,却体现着不同的业务。
实体对象(Entity)和值对象(Value Object)
- 实体:实体对象表示的是具有一定生命周期并且拥有全局唯一标识(ID)的对象,比如
Order和Product,包含一系列属性和行为函数。一个实体与数据库对象并非严格的1对1关系,可能是1对N,或者N对1,有些实体甚至不需要持久化。而值对象表示用于起描述性作用的,没有唯一标识的对象,比如Address对象。聚合根一定是实体对象,但是并不是所有实体对象都是聚合根,同时聚合根还可以拥有其他子实体对象。聚合根的ID在整个软件系统中全局唯一,而其下的子实体对象的ID只需在单个聚合根下唯一即可。 - 值对象:一种特殊的领域对象,由若干个属性和不发生属性修改的行为函数组成。值对象属性一经初始化就不可修改,这是其与实体的显著区别,最常见的值对象例子就是地址,比如一个订单关联了一个地址,如果用户下单后变更了常用地址,其实不会修改原订单地址,订单中地址是不变的,只是把用户常用地址替换成另一个地址。
二者如何区分?
- 区分实体和值对象的一个很重要的原则便是根据相等性来判断,实体对象的相等性是通过ID来完成的,对于两个实体,如果他们的所有属性均相同,但是ID不同,那么他们依然两个不同的实体,就像一对长得一模一样的双胞胎,他们依然是两个不同的自然人。对于值对象来说,相等性的判断是通过属性字段来完成的。如订单下的送货地址
Address对象便是一个典型的值对象,地址位置的属性相同如province,city,detail相同那么地址就是相同的。 - 值对象还有一个特点是不变的(Immutable),也就说一个值对象一旦被创建出来了便不能对其进行变更,如果要变更,必须重新创建一个新的值对象整体替换原有的。值对象的不变性使得程序的逻辑变得更加简单,你不用去维护复杂的状态信息,需要的时候创建,不要的时候直接扔掉即可,使得值对象就像程序中的过客一样。在DDD建模中,一种受推崇的做法便是将业务概念尽量建模为值对象。
- 实体和值对象的划分并不是一成不变的,而应该根据所处的限界上下文来界定,相同一个业务名词,在一个限界上下文中可能是实体,在另外的限界上下文中可能是值对象。比如,订单
Order在采购上下文中应该建模为一个实体,但是在物流上下文中便可建模为一个值对象。
DDD设计
业务场景: 小明在外卖APP点了一份鱼香肉丝,支付成功半小时后,外卖小哥把外卖送到了手中。
注意以上场景中提到的动词“点(外卖单)”、“支付”、“(外卖小哥)送(到了手中)”,里面隐含着没有提到的动作是商家在厨房“制作(外卖)”。我们可以总结出以下领域——
Domain划分
按照业务场景,可以分为商城、厨房、物流、支付,4个Domain
暂时无法在文档外展示此内容
其中支付属于通用域,我们可以用任何第三方支付或者支付网关来替换,这里暂时不展开讨论。
提炼Entity、VO、AR
首先,商城里面把订单作为聚合根一般没什么疑问。主要是理解收货地址是VO:因为订单上的收货地址不受地址管理里面的更新操作影响。大家可以试着改一下淘宝的地址管理,然后看之前下过的订单上面,这个地址是否发生了改变。一般订单会采用打快照的方式,将地址信息保存下来(这里就能体现文档型数据库的好处了)。VO的特点是不可改变,如果要改,那就是整个替换。
到了厨房领域,为什么菜品变成了聚合根?我们可以站在厨师角度来想一下,厨师在做菜的时候,大多数情况下不太会按照订单一张张来做。大家都有这样的生活经验:如果一个饭店先做一张桌子的菜的话,其他所有桌都会等着,体验会很不好。一般厨师都会每桌都做点菜,大家先都吃起来,最后大家的菜都慢慢上齐了。当然,如果你坚持要按照订单来做菜,那也没问题,这种Context中,订单确实是聚合根。但在这里为了说明问题,我们假设采用合并做菜这种策略的话,我们的厨房就应该以菜品来做聚合,每道菜是一个聚合根。厨师在拿到商城传来的订单的时候,先要做一次转换(后面会提到,可以用防腐层来隔离厨师对商城订单的感知),按照菜品来做聚合,然后开始批量做菜,最后再按照商城订单重新拆分打包。这里为了说明问题,我们对厨师做了“万能”的设定,把优化做菜次序和最后的打包动作都做掉了,实际场景中,我们可以再拆分子域出来。
最后是物流领域。大家直观感受,外卖小哥是按照一张张订单来送外卖的。实际场景中,我们可以参考物流公司的做法,会做一些路线优化。这里的话,其实我们在物流领域中,把所有的外卖单按照送货地址做了聚合(比如把一栋楼的所有外卖单放在一起,不区分收货人也不区分卖家),然后对聚合后的地址再重新规划路线。这样看来,似乎送货地址应该是一个聚合根。这里有同学可能会想到一种思路,就是把地址这个VO作为聚合根的唯一ID。大家可以体会下这个思路,虽然这有些怪异,我们无法为这个聚合根找到一个统一语言来命名,但是说明已经对聚合根有些感觉了。实际上,我们这里有一个更加贴合实际的对象,叫做送货单。对于大型的、比较复杂的物流场景,这里还会拆分出来装箱单Entity,对应于仓库的打包出库环节。这里为了说明问题,我们就简化了场景。
领域模型
服务集成
总结
DDD是一种自顶向下的设计方式,跟我们信手拈来的自底向上的设计方式不同大家可以在平时的生活场景中,多多尝试这样的思维训练,先不要关注底层的存储,而是先思考用哪些对象来解决问题。
Domain的划分很重要!很多时候我们发现系统变得难以理解或者不太好建模的时候,我们回过头来再看一下,是不是我们一开始的Domain划分不太合理。我们会很容易地发现,其实很多比较成熟的行业,都已经做好了十分合理的领域划分。例如汽车制造行业、电商行业、大型的制造业工厂等等。我亲身参与过门窗生产行业的ERP软件开发,我惊奇地发现门窗厂里面的部分划分、各部门的职责,都是非常清晰的!我们开发的软件如果能和真实世界映射起来,那会是一件十分美好的事情。
本文包含引用:www.cnblogs.com/davenkin/p/…