DDD的战术模式


DDD(领域驱动设计)是一种软件设计方法的主张,这种方法非常全面,因为它提供了代码级别战术、项目组织级别甚至整个组织的战略级别的设计工具。Eric Evans 2003年的领域驱动设计:解决软件核心的复杂性为DDD奠定了基础。
该本书认为代码应该反映业务模式,技术问题应在专用开发下不会妨碍到业务领域。但要实现这一目标并非易事。实际上,经常会发生业务代码受到与技术方面相关的横向侵入的影响(持久性 ......),意识到这种困难,Eric Evans在他的书中定义了许多模式,这些模式可以帮助我们在代码中表达业务问题:战术模式。
为了熟悉战术模式,我们在XKE期间在Xebia内组织了一个研讨会,以探索和吸收Eric Evans提出的不同模式。研讨会格式的灵感来自于所使用的[url=https://twitter.com/tpierrain/status/926216936316424192]2017年11月的聚会DDD巴黎[/url]。因此,在介绍由爱德华思哈克莱门特Heliou,团队是分离成一个个小团队,每一个研究的特定领域。然后,我们就这些不同的模式分享了我们的理解和意见。

实体
实体代表业务对象,其标识随时间保持不变,不管他们的属性如何。例如,如果必须为人类建模,他可以看到他的属性变化(年龄,头发颜色......)。但他的标识仍然是一样的。
因此,为了实现一个模型实体的代码,需要使用经典对象,其中所述的属性将是恒定的并且将提供一个标识符。因此,要知道两个对象是否代表同一个实体,比较它们的标识符就足够了。在上一个人的例子中,我们可以选择身份证号码为标识,因为它具有恒定和独特的优势。
建模一个对象作为该实体需要翻译寻找业务行业中的最佳概念。但它也有助于理解这种建模的含义。实际上,实体具有精确的生命周期,必须经常持久化,维护更复杂并且不是线程安全的(有必要避免程序的两个组件同时修改同一个对象,因为导致不一致的风险)。
最后,请注意,在实现实体时,找到“自然”标识符并不总是很容易。例如,如果像上面提到的,身份证号码是一个很好的候选,我们想到姓名,但姓名很少是独一无二的。最后,如果我们的应用程序必须生成标识符,我们必须小心确保这些标识符的唯一性,因为它并不总是不重要的:如果我们使用自增量标识符,我们必须检查分布式环境中增量的一致性; 如果我们使用时间戳,我们必须管理几乎同时进行的查询的情况......
我们理解为什么实体的管理很复杂,以及为什么我们在绝对必要时才使用实体,更喜欢值对象来建模业务对象。
例子
为了使这种模式更具体,让我们以汽车领域为例。假设我们必须编写管理汽车租赁公司的应用程序。汽车是一个不断发展的对象:它可以租用,零件可以更换等。因此很明显,该对象将在应用程序中建模为实体。
与实体通常的情况一样,您需要为每辆车找到一个标识符。人们可以想到车牌,但这些车牌的规则因国家而异。幸运的是,车辆识别号码作为自然标识符,否则,有必要找到一个技术标识符,其中包含验证其所带来的唯一性的所有问题。

值对象
值对象是由它们的属性定义的业务对象:他们没有标识。例如,如果我们更改日期的属性(月,年......),我们会更改日期这个对象:但它不再是原来相同的日期对象了。因此,为了模拟这样的对象,人们将使用不可变对象:当且仅当它们的所有属性相等时,两个对象才是相等的。因此,这些对象没有随着时间的推移而变化自己的内部状态。
这些对象是不可变的这一事实使它们使用起来非常方便:它们是线程安全的,可以轻松转移,等等。值对象不是简单 DTO ; 我们可以(并且必须)将业务逻辑放在这些对象中。这甚至是吸收工作复杂性的好方法。
在项目中,区分作为实体的对象和作为值对象的对象是简化代码和增加对业务领域的理解的最佳方法之一。
备注:

  • 值对象是一个很好的起点,可以定位哪些是值对象并将业务规则封装在其中不仅可以更好地表达域,还可以“ 吸收 ”代码的复杂性。
  • 实体 / 值对象的区别是相对于上下文的:实际上,相同的概念可以由值对象或实体根据其中的域来表示。典型的例子是钞票:对于商家而言,两张相同价值的钞票是可以互换的(值对象),而对于打印和销毁这些钞票的法兰西银行而言,特定的钞票有一个标识符和生命周期(实体)。
  • 最近,我们开始讨论值对象而不是值类型,因为后一个术语是矛盾的(值类型是一个被认为具有一个内部状态的对象,值对象是认为值不变的)。

例子:
让我们留在上一个例子的汽车领域,并假设我们需要知道汽车的位置。汽车的位置是价值对象的一个很好的例子:一个位置没有识别,完全由其值定义(用GPS坐标表示)。也就是说,这个对象不一定是一个简单的DTO,它可以包含许多具有商业意义的方法:乌鸦在两个位置之间飞行的距离,两个位置之间的最短路径,这个职位所在的国家......

聚合Aggregate
在复杂的系统中(例如:分布式,多线程......),我们经常需要确保一组对象的一致性。但是,这种一致性很快就会成为维护代码的噩梦。
Aggregate模式提供了解决此问题的方法。聚合是一组业务对象(的值对象或实体连接在一起)。在这些对象,一个(通常是实体)将有一个特殊的角色:聚合根。其余代码对Aggregate所做的任何更改都必须通过聚合根方法。因此,禁止通过直接修改非根对象来修改聚合。
因此,单独的聚合根将负责确保聚合的一致性。一个聚合允许建立围绕一个共同的组对象的周边。因此,聚合 是实现业务不变和管理规则的好方法。为了确保一致性聚集在多线程环境中,其中的所有方法调用聚合内部对象应该是同步的。
一个很好的比喻来理解的原则,在动物园中几个人来分配不同动物到笼子,如果时机不好,狮子和羚羊可能是在同一个笼子。最好问一下聚合根对象(这里是动物园饲养员)来照顾放置笼养动物的一致性。
此外,聚合根是主要的实体,一个聚合是受到共同的约束:他们必须遵循一个生命周期。另一方面,如果程序必须处理多个聚合,则不推荐在多个节点/线程 之间共享此处理过程,每个聚合仅由一个节点修改。这种共享在真实世界中的很好的类比是:一个汽车工厂具有多个流水线,未来的车辆根据其标识符分布在各种组装线,每一车辆只能通过寻址一次对应到只有一条装配线。
注意:因为一个聚合必须保证模型的一致性,它有时很有诱惑力,但确保聚合体尽可能小也很重要。实际上,聚合越大,维护越困难,并且调用只能同步增加的区域越多。

例子
在汽车相关业务领域的示例中,如果您想要详细建模汽车,则必须考虑汽车包含相互通信的部件这一事实。因此,汽车包含发动机,车轮,制动器。所有这些部件相互通信:发动机使车轮加速,而制动器阻止它们,方向盘转动它们。为了确保数据的一致性(避免汽车右转弯,而车轮朝向左侧),可以被建模为一个汽车聚合,外界只有通过汽车聚合根本身才可以访问内部。

工厂
有时创建聚合(甚至特别大的值对象)可能非常复杂,特别是如果对象本身已经很复杂。
在这些情况下,Factory模式通过将创建对象的职责转移到专用对象来提供解决方案。这是域模型的一部分,即使它在该领域确实没有业务责任。因此,这种模式非常接近面向对象编程的经典GoF设计模式
但是,不应该为每个对象创建系统地使用此模式。否则,它将不必要地产生额外的复杂性。
请注意,使用模式Factory越来越谨慎,最常被模式Builder模式替换。后者在很大程度上是等同的,但具有更灵活的优点,并且界面通常更清晰。

例子
假设我们想编写一个管理汽车租赁公司车队的应用程序,那么我们就将Car对象建模。但是,如果新车创建与指定发动机,刹车片和改造车体,不仅是复杂的(如前面的章节),还需要业务代码操纵Aggregates内部的对象。为避免这种情况,您可以赋予特定对象以“创建该对象”的责任,以将对象的创建与其余业务代码隔离开来。

服务
在某些情况下,实体,值对象和聚合不足以包含业务域的所有逻辑。
在这种情况下,我们可以使用服务,这些服务是执行业务流程的类,这些业务流程无法由任何其他业务对象令人满意地执行。这通常是将业务对象转换为其他对象的情况。
另一个例子是两个银行账户之间的货币交易:哪个对象将处理代码级别的交易编排?在两个“ 银行账户 ”的对象之一中实施交易会很尴尬,因为在业务层面,这没有多大意义。因此,在Services类中实现事务是一种更好的解决方案。
但是,请务必仅在任何业务对象(实体,值对象或聚合)无法实现的情况下考虑服务。否则可能导致贫血域模型,也就是说,实体变成简单的DTO对象,所有的业务逻辑放入肥胖的服务类,这种情况可维护很差。
例子
仍然是汽车租赁公司的管理应用程序,假设我们想要创建一种计算汽车租赁价格的方法。对于这一点,就编写一个函数computePrice,根据车辆租金价格、租赁日期,租赁的位置,或者客户本身(因为它可以从一定的降低中受益)计算。很明显这个方法应该是领域层的一部分,那么我们应该在什么类型的对象中放置这个方法?我们应该在Aggregate聚合?Value Object 值对象或Entity中对它进行编码吗?很明显,这些对象都不适合容纳computePrice这种方法。在这种情况下,最佳解决方案是将此方法存储在专用服务类中(例如RentService),该服务类将是模型的组成部分。

领域事件
如果实体,值对象和聚合允许您在一个点上表示模型,那么了解系统如何到达当前这个点通常很有用。
一个非常有用的模式可以解决这个问题:领域事件。这些是在Entity或Aggregate上对业务事务进行建模的对象。这些对象通常用于保存,使得可以获得模型所经历的更改的完整历史记录。领域事件的另一个优点是它们简化了系统间同步。一旦系统被修改,它就可以发出域事件以通知其他系统修改其状态。
在实现级别,域事件是不可变对象,其包含至少一个具有事件日期的时间戳和用于标识它所在事件序列中的的序列号。单个时间戳是无法识别域事件,因为可能会有相同的毫秒中的几个事件,尤其是在分布式系统。
在银行业,域事件的典型例子是实体 “银行账户” 的“应收账款”和“借方账户”事件。
该域事件是变得越来越多地被使用,因为它的概念的基础上的模式事件溯源,这本身就很好:用的模式架构CQRS。
注意:这种模式实际上并未在Eric Evans的原始书中定义,而是在2015年发布的精简版和完整版中:DDD Reference
例子
在车队的例子中,汽车可以租用,退回,送去维修。因此,每次汽车改变状态(例如从“租用”到“渲染”),我们可以通过类型领域事件的对象来模拟这种状态变化。如果这个对象是持久的,我们不仅可以查看汽车的当前状态,还可以跟踪汽车的所有历史记录。

分层架构
到目前为止看到的所有模式都可用于建模业务领域。但是,如果我们不将业务代码与其余代码隔离开来,那么这些模式会失去很多价值。
因此,隔离业务代码的方法之一是使用经典的分层架构。然后,将这些层中的一个保留到业务代码中就足以将业务问题与软件的其他组件(图形界面,数据层......)分开。
但是,如果我们保持“经典”分层架构,“业务”层将依赖于下面的层代码(数据存储,基础架构)。因此,业务代码将取决于项目的技术选择,如果这些选择发生变化,则必须进行修改。但是,这与DDD的目标之一是矛盾的,即业务代码仅在业务发生变化时(或者当业务的理解发生变化时)才会发生变化。为了避免这种陷阱,一个技巧是使用依赖注入技术,这样它就是依赖于业务代码而不是相反的技术层。因此,业务层不依赖于任何其他层,而是依赖于它的其他层。
Eric Evans在其2003年的书中阐述了这种架构。从那时起,这种架构得到了简化和改进,导致六边形架构只剩下两层:域和基础架构。还包括的另一改进分层体系结构:在洋葱架构促进相反,层的数量较多,字段本身被分成几个子层。
例子
在我们的车队示例中,假设我们需要让客户知道他们的车已经准备就绪。然后,我们可以提供的接口ContactAvecLeClient将在基础架构层中实现,并包括向客户端发送电子邮件。如果明天,技术选择发生变化,我们希望通过SMS阻止客户,那么改变接口的实现就足够了,这可以在不触及业务领域层代码的情况下完成。

仓储Repostiory
如果以前的模式用于建模业务,则尚未回答一个问题:如何存储表示业务对象的数据?
如果为了保存/检索 聚合,我们直接在业务代码中调用SQL查询,我们不仅打破了业务代码和技术代码的分离,而且,我们将代码绑定到特定的技术,例如关系数据库.
因此,Repository模式通过抽象Aggregates的存储和恢复来解决此问题。存储库的接口必须独立于技术层,并且必须具有业务意义。通过独立于存储的技术细节,该模式使得可以在项目期间改变存储并且还增加业务代码的可测试性。
注意不要将DDD 的Repository模式与简单的数据访问对象(DAO)混淆。实际上,后者仅用于在数据库中映射具有条目的对象,它们不提供与技术的独立性,并且它们的接口通常没有商业意义。


模块
模块是基于类的应用程序中的逻辑分组。模块是绝对的最流行的编程概念之一。
领域驱动设计还建议使用模块。但是在业务代码中,类必须按功能亲和性而不是按实现细节进行分组(存在代码中重复一点的风险)。
用于理解最后一点的比喻是器具和工具的例子:通常,我们将叉子和刀子排列在一起,因为它们具有相似的特征(它们用作餐具)。但刀具和锯子并没有放在一起,尽管它们都有锋利的金属刀片。
例子
在我们的汽车租赁公司的车队管理应用程序中,我们的模块应用部门可以包含一个管理汽车的模块和另一个管理客户的模块。实际上,这些模块在我们的领域中是有意义的:它们显然是我们业务模式的一部分。

结论
此处介绍的所有模式都旨在促进领域模型在代码中的表达。确实,他们帮助:

  • 分离业务逻辑和技术逻辑(存储库,分层架构),
  • 组织代码(值对象,实体,聚合,模块,服务),
  • 考虑竞争和一致性之间的权衡(聚合,域事件),
  • 明确阐述竞争与业务逻辑(域事件)之间的关系,
  • 强制解释代码中的业务概念(值对象,实体,域事件)。

所以,应用这些模式提供了一个代码更清晰,更有条理,更适合于分布式系统,并在其中的业务逻辑是不清楚。