【读书笔记】【设计】如何实现领域驱动设计(DDD)笔记

前言:领域驱动设计,是一种架构思想,它不是关于技术的,而是关于讨论、聆听、发现和业务价值的,而这些都是为了把知识挖掘并表达出来。敏捷开发:DDD并非充满繁文缛

前言:

  领域驱动设计,是一种架构思想,它不是关于技术的,而是关于讨论、聆听、发现和业务价值的,而这些都是为了把知识挖掘并表达出来。

  敏捷开发:DDD并非充满繁文缛节的笨重的开发过程,相反它可以和敏捷很好的结合。可以采用“测试先行、逐步改进”的设计思路。其中重构是最必要的一步。

领域:

  领域分类:可以划分为核心子域、支撑子域、通用子域。核心域是值得配置最好的开发者的,但是有时候子域在自己眼里也是自己的核心域。所以核心是相对而言的,相对整个BU来说,也会有自己赚钱的核心域。

  • 核心域拥有核心的知识抽象,使用DDD战术回大有裨益。不过DDD战术同样适用在其他域中。
  • 如果你不知道怎么划分界限上下文的时候,不如先描述核心域,然后创建一个核心上下文。

  问题空间:问题空间是核心域和其他子域的组合。问题空间中的子域通常随着项目的不同而不同,他们各自关注于当前的业务。

  解决方案空间:解决方案空间包含一个或者多个界限上下文,所以说一个界限上下文对应的是一个解决方案,解决方案可以是演进的。

  界限上下文:界限上下文是一个显式的边界,领域模型便存在于这个边界当中,领域语言把通用模型(语言)表达为软件模型。每一个模型概念,包括它的属性和操作,在边界之内都具有特殊的含义。但界限上下文不应该只包含通用语言和领域模型,它还包括那些提供交互手段,辅助功能的内容,例如数据库,UI,REST资源等等。但如果数据库不是一个Context专用,而是要求通用多系统共享,或许会淡出一个上下文。

  • 它对应了一组解决方案,一个会议可能会形成一个上下文,产生一个解决方案。
  • 好的设计应该是每一个子领域内应该形成自己的子域界限上下文的,不然的话可能你的子域可能划分的不太好。然后一个大领域下就会存在多个不同的界限上下文。——(P49)
  • 在一个好的界限上下文中,一个术语应该值表达一种领域概念,例如顾客在不同上下文中含义就不同,所以应该在不同上下文中让顾客更佳实体化,例如在会员系统中顾客就是:会员顾客
  • 上下文和物理结构不一定要是一一对应的
  • 界限上下文是可以在重构中产生的。

  通用语言:通常来说,通用语言和界限上下文存在一一对应的关系。

  • 界限上下文的通用语言,向我们提供了设计领域模型的概念术语,用它来指导实体建模、领域服务、值对象设计。
  • 通用语言应该直接反映在代码中,而要保持设计文档的实时更新是非常困难而且不可能的。

  战略重要性:战略设计基本会划分大大小小的各个子域及上下文,但是有些情况下一个新的子域或许不会那么明显,那么在知识沉淀的时候会发现矛盾所在,所以上下文也有可能新增和重整。这些都是会上升到战略层的决定。如果不重构矛盾到子域,可能会导致一些大泥球的设计。

上下文映射图:

  上下文映射图:这些不同的上下文中的模型会有重叠的本质物理实体,但表现出了不同的领域模型。这个时候就需要为他们的映射关系进行管理,最有效的手段就是上下文映射图。

  • 上下文映射图为什么重要,因为它帮我我们在解决方案空间中看待问题

  上下文关系:上下文的关系在映射图中可以表示,一般会有上游和下游之间的关系,其中设计关系的概念如下:

  • 合作关系:一起成功、一起失败。
  • 共享内核:两个上下文共享内核,有利有弊,但要保证内核的持续集成发展;
  • 客户方-供应方关系:上游不依赖下游,下游依赖上游;
  • 遵奉者:上下游中,上游不帮助下游,摆烂的关系;
  • 防腐层:当关联上下文难以正确映射的时候,需要一个单独的层作为代理做这件事,提供模型翻译功能;
  • 开放主机服务:上下文的公开协议;
  • 发布语言:两个界限上下文的翻译语言;
  • 另谋他路(SeparateWay):声明两个上下文不存在关系;
  • 大泥球(Big Ball Of Mud):混在一起的模型,边界不清晰,就把他们整体混成一个大泥球;

  个人实践:物流单是一个上下文(整体物流上下文),作业单分别有自己的上下文(作业上下文),作业单代表的是作业域,与实操交互;物流单是协同域,与协同交互。

架构:

  分层:大致的层次有用户接口层、应用层、领域层、基础设施层;一般他们的关系是:用户接口层->应用->领域层->基础设施层;

  • 通过依赖导致,把基础设施层导致依赖领域层,其他不用过多关系,但是领域层应该不被依赖,作为稳定的核心。

  六边形架构:也称之为端口适配器,对于每一种外界类型,都有一个适配器与之相对应。

  面向服务的架构:面向服务架构,以服务为主。可以用六边形架构结合,利用REST和SOAP等适配器提供服务。

  • 服务契约、松耦合、服务抽象、服务重用性、服务自治性、服务无状态性、服务可发现、服务组合性(P114)
  • 不应该被技术服务影响上下文模型的大小和划分,业务价值高于技术策略、战略目标高于项目利益。

  RESTful架构:要区分架构风格和架构,架构风格是将所有架构共有的东西抽象出来。REST本来就应该是属于WEB架构的一种架构风格。

  • 第一是资源,资源是具有展现(representation)和状态(state)的
    • 展现的格式可能不同xml\json\hetm
    • 资源应该有URI进行唯一定义
    • 资源不是独立存在的,是不同资源连在一起的,这也是WEB的本质。
  • 第二是接口,把资源看成对象,它应该有什么暴露的方法接口
  • 与DDD结合:这个问题简单,我们不应向外暴露领域模型,所以推荐创建一个单独的上下文,来访问领域模型,包装为资源向外暴露。

  CQRS:查询与命令职责分离

  • 命令:如果一个方法修改了对象状态,那么它是一个命令;
  • 查询:如果一个方法返回了数据,不改状态,那么它是一个查询;

  有了命令和查询的区分,那么便有了查询处理器和命令处理器,这些处理器操作的对应的模型如下:

  • 查询模型:可以创建多个视图作为查询模型。查询模型应该是监听领域事件来生成;
  • 命令模型:当命令处理器执行后,一个聚合应该会被更新,同时可能会发布领域事件;
    • 其他聚合可能会监听事件,然后相应更改,达到最终一致性;
    • 查询模型会监听事件

  事件驱动架构

  长时处理过程(Saga):Long-Runing Process,将处理过程设计成为一组聚合,这些聚合在一系列的活动中相互协作。一个或者多个聚合实例充当任务执行组件并维护整个处理过程的状态。

  • 执行器和跟踪器:用聚合作为跟踪器处理跟踪长时处理过程的状态。我们不需要一个专门的跟踪器来做这种事。
    • 在实际的领域中,一个长时处理过程的执行器将创建一个新的类似聚合的状态对象来跟踪事件的完成情况。
    • 长时处理过程通常有一个isComplete的方法,返回true的时候,执行器将根据领域需要发布领域事件。
  • 事件源:对于某个聚合上的每次命令操作,都有至少一个领域事件发布出去,该事件描述了操作的执行结果。每一次领域事件都将被保存到Event Store中。
    • 重建:从资源库获取聚合的时候,我们可以通过事件源的历史事件来重建聚合。
    • 有序:事件应该是有顺序的重放。
    • 快照:领域事件太多的时候,重建会有性能问题,所以这个时候可以用“快照”的方法。

实体:

  通常因为基于数据库的映射,会有很多getter setter方法,但这不是ddd的做法。

  唯一的身份标识和可变性特征可以区分实体和值对象。

  唯一标识:创建实体唯一标识的策略

  • 用户提供的输入作为唯一标识,程序严重唯一性
  • 程序内部提供唯一标识,如UUID,分布式ID
  • 数据库帮忙生成唯一标识
  • 另一个界限上下文已经决定了可以用的标识作为输入

  标识生成时机:实体唯一标识可以在对象创建的时候,也可以在持久化的时候,这要看这个标识在创建过程是否重要,例如创建需要发送领域事件的场景,则需要提前创建;

  • 委派标识:就是数据库id,但最好别透露出去给Domain,拥有这个标识也是重要的

  发现实体的本质特征:界限上下文的通用语言,向我们提供了设计领域模型的概念术语。

  跟踪变化:跟踪实体变化最有用的方法就是领域事件和事件存储;

值对象:

  我们尽量使用值对象来建模而不是实体对象,因为我们可以非常容易地对值对象进行创建、测试、使用、优化和维护。

  通用语言:通用语言是值对象的首要原则;值对象通常有以下特征

  • 度量、描述:它量度或者描述了领域中的一件东西
  • 不变性:它可以作为不变量
  • 整体概念:它将不同的相关的属性组合成一个概念整体,每一个属性对于概念都不可或缺,它和把这些值组装在一起时不同的
  • 可替换性:它可以和其他值对象进行相等性的比较,当值对象不能表达描述后,应该用另一个值对象替换
  • 无副作用:它不会对协作对象造成副作用,它有函数,只产生输出,不改变谁的状态

  Domain Primitive:我们在讨论值对象完整性的时候,我们还会讨论DP(Domain Primitive)领域基本值对象,它是值对象在领域层的升级版,具有不可破坏性,函数式编程给这种完整性提供了可靠的保障。它要求值对象无任何办法被外界破坏其领域概念,记住完备的Value Object是不可破坏的。

  函数式编程(状态所在的位置修改状态):我们知道值对象可以封装函数方法,这个时候如果值对象的入参是实体的时候,我们不能改变实体的状态,而是返回结果让实体自己去改变自己的状态。

  最小化集成:在集成界限上下文的时候,我们尽量用值对象集成,使得上下文之间只需了解对方最小的信息量,做到最小集成。

  标准类型:可以用值对象表示标准类型,枚举是一个很好的实践。

  • 一个共享的不变值对象可以从持久化存储中获取,此时可以通过标准类型的领域服务或者工厂来获取值对象
  • 我们可以用聚合来表示标准类型,但是如果要被其他上下文交互,那么不变性需要我们权衡是否可以建模为值对象。

领域服务:

  客户方:应用服务是领域实体的客户方,同时也是领域服务的客户方。

  • 如何区分应用服务和领域服务,主要看是否是业务逻辑和计算机逻辑。
  • 通用语音指导:一段逻辑是封装到实体还是封装在领域服务中,主要参考通用语言及复用性,职责性。
  • 抽象不同,场景不同,一个逻辑是属于领域服务还是实体都有可能,可以通过重构感觉他们的权衡利弊。

  可能场景:如下场景可用:

  • 执行一个显著的业务操作过程
  • 对领域对象进行转换
  • 以多个领域对象作为输入进行计算,结果产出一个值对象

  独立接口:我们喜欢写一个业务服务,给这个业务服务定义一个接口,其实没有必要

  • 但不管如何,如果有接口,我们可以把领域服务的接口/实现和实体放在一个模块中。用作提现通用语言
  • 如果有接口,是一个基础设施的实现,那么我们可以把实现倒置出去
  • 当你使用加上impl对一个领域服务接口进行实现的时候(非倒置情况),通常来说,这表明你不需要一个接口,其实不同实现应该有不同命名,impl代表只有一个实现

  通用语言(感悟):领域服务,其实是一种特殊的实体对象。你完全可以把当作一个实体来对待

领域事件:

  领域事件是领域模型的组成部分,表示领域中所发生的事情,是通用语言的正式组成部分。使用领域事件我们可以讲任何企业系统设计成自治服务和系统。

  跟踪实体状态变化:大部分情况我们没必要对整个生命周期变化进行跟踪,如何跟踪实体的对象变化?最实用的方法就是存储领域事件和事件存储

  维护事件的一致性:我们希望实现聚合原则,即单个事务,只允许对一个聚合实例进行修改,由此产生的其他修改必须在单独的事件中进行。

  通用语言:在建模领域事件的时候,我们应该根据界限上下文中的通用语言来命名事件及属性。

  订阅方:可以分轻量级订阅方和重量级,后者会存储事件,前者即时转发,或者直接消费

  具有聚合特征的领域事件

  • 有些领域事件不是聚合产生的,可能是某些请求产生的,这个时候可以让领域事件具有聚合特征(即事件=聚合)。
  • 这种事件可以被资源库存储,也必须要有唯一身份标识。
    • 因为不同上下文的消费方需要知道一个消息是不是被重复发送,相同标识的事件要幂等
  • 这种事件无法被删除

  从领域模式中发布领域事件

  为了避免暴露模型,一种简单有效的方式就是使用观察者模式。另外我们可以配合EventBus(或者叫DomainEventPublisher)使用观察者模式(也可叫发布订阅模式吧,虽然他们有一些差距),下面讨论一下EventBus的两种使用方式。

  • 调度者发布模式:使用应用层ApplicationService来动态设置领域事件的订阅者,并维持在ThreadLocal上,这里会引入调度着的概念。
  • 主题对象发布模式:使用对象来维护订阅者,即对象被谁观察是过程概念,而并非调度概念。所以是用领域对象本身来持有订阅者,组装的过程
  • 调度实例化发布模式:依旧按照调度者发布模式,但是维护关系不在主题对象上,而是用专属的对象管理订阅关系,把调度者实例化。

  向远程的界限上下文发布事件:这里讨论的是远程,所以要借助基础设施中间件,有以下几种方式:

  • 领域模式和消息设施共享持久化存储,优点是性能高;
  • 使用XA事务控制,两阶段提交,具体例如入MetaQ支持事务消息;
  • 把领域事件存储在和聚合一样的数据库,这个和1很像;

  事件存储的使用特征

  • 讲事件存储作为一个消息队列来使用
  • 可以作为所有命令方法的流水记录,用作debug等
  • 使用事件存储中的数据来进行业务预测和分析
  • 使用事件重建聚合
  • 撤销对聚合的操作

聚合:

  原则1:在一致性边界之内建模真正的不变条件(推理为何聚合原则是一个事务只修改一个聚合

  我们要建立一个聚合,一定要找到一种不变条件,这个条件表示一个业务规则,这种规则总是保持一致的,存在多种类型的一致性,其中之一是事务一致性。而我们通常用数据库来保持事务的ACID特性,单个事务中,我们只修改一个聚合实例,这样做很难,但是是值得的。同时这也是为什么我们使用聚合的原因。

  原则2:尽量设计小聚合

  考虑系统性能和可伸缩性,大聚合是很痛苦的。同时也有助于事务的冲突减少。另外我们也可以尽可能的在聚合内设计值对象。

  不要相信每一个用例:很容易出现一个问题就是,一个用例中会修改多个聚合,这个情况下,我们要搞清楚,对用户需求的实现是否分散在多个事务中,还是单个事务,如果是后者就注意了,可能这些用例不能真正的反映模型中的真正的聚合(或许这个时候需要重构我们的聚合,产生新聚合出来)。注意很多情况下,我们其实是可以通过最终一致性修改多个聚合的。

  原则3:通用唯一标识引用其他聚合

  通过以上看出,不同聚合根之间不应该直接引用另一个聚合根。所以聚合根之间的引用,应该通过唯一标识进行。

  建模对象的导航性:既然聚合之间没有直接引用,我们需要其他手段建立他们直接的导航性,应用服务是一个好的选择,领域服务也可以。

  可伸缩性和分布式:因为采用了小聚合和标识引用,所以我们可以在一个核心领域下的不同界限上下文上应用分布式服务,并从中得到好处。通过领域事件,标识引用形成了远程聚合之间的合作者关系。

  原则4:在边界之外使用最终一致性

  其实最终一致性的时延,领域专家基本是可以接受的,想想没有计算机的年代是怎么处理的就理解了。

  Eric Evans,问问我们是否应该由执行该用例的用户来保持数据的一致性。如果是,请使用事务一致性,当然此时依然需要遵循其他聚合原则。如果需要其他用户或者系统来保证数据的一致性,请使用最终一致性。

  打破原则的理由

  • 方便用户界面,例如批量操作
  • 缺乏技术机制,就是没有消息中间件
  • 全局事务
  • 查询性能,一个聚合引用其他聚合有利于性能提升。

工厂:

  领域工厂:工厂方法,工厂模式,通常指的是一个具有单一职责的方法,这个方法的唯一职责就是创建对象,而创建聚合对象也是其中一种对象。

  • 因此,我们并不关心一个工厂方法到底存在于哪里,如果单独用一个类包装,那么称之为简单工厂模式
  • 如果用一个接口表示该方法,把方法的创建开放出去,那么称之为抽象工厂模式。

  领域对象,如果模型也有方法是专门用来生产对象的,那么也称之为工厂方法。

资源库:

  内存无差异:资源库是管理中间状态的一种抽象组件,他让用例过程感觉,聚合根似乎一直存在于内存中一样。

  和聚合的关系:通常来说,聚合类型和资源库存在一对一的关系。

  面向集合的资源库:面向集合的资源库,一个例子就是用一个Map来实现资源库

  • 资源库一直持有聚合引用,所以需要随时随地跟着聚合的变化。
  • 不能向资源库添加多次聚合根,而且我们不需要重新保存聚合到资源库。
  • 隐式读时复制/隐式写时复制,是资源库跟踪聚合根的两种实现。

  面向持久化的资源库:其实就是一种实体转移模式

  • 适合用在面向聚合的数据库
  • 每次事务结束,就把聚合实体整个放回资源库,做全量的覆盖,所以没有更新这个说法。

聚合与事件源(A+ES):

  事件源通过事件来表示一个聚合的完整状态。这里的事件源自创建聚合的一系列事件。这些事件流应该按顺序存储起来,并且以聚合根标识隔离开来。

  • A+ES能保证聚合每一次变更的原因都不会丢失
  • 事件追加具有很高的性能,而且支持不同数据复制的方案
  • 以事件为中心使得开发者更加注重通用语言

  缺点

  • A+ES需要我们对业务由很深的了解,并且只有很复杂的实体才值得这样做
  • 缺少工具和一致的知识体系,具有较高的风险
  • DDD本来就不多,有经验的开发者更不多
  • 需要配合CQRS,这个时候会增加学习成本

  一般步骤

  1. 客户端调用应用服务中的某个方法
  2. 获取所需的领域服务以执行业务操作
  3. 根据客户端传入的聚合根id加载事件流
  4. 通过事件流重建聚合
  5. 向聚合传入参数和领域服务执行
  6. 聚合可能双分排领域服务或者其他聚合执行业务,然后产生新的领域事件作为输出
  7. 新的领域事件用作改变聚合状态,以及把聚合变更放入事件流
  8. 将新追加的事件通过消息设施发送给订阅方

  如下图所示,Mutate方法是聚合用来接受事件的,它将会根据事件的类型,选择相应的方法去恢复实体状态,这个对应的是第4步:重建聚合。

 

  业务操作是如何执行的?在聚合重建后,应用服务会把处理逻辑委派给聚合实例的一个命令方法。该聚合实例将使用其当前状态及领域服务来执行业务操作。随着行为的执行,对聚合状态的修改将通过新的事件予以记录,之后每个新的事件都会传给聚合的Apply()方法。下面看下Apply的用法

  对应代码:(其实和状态管理模型很像)

public partial class Customer{
  ...
  void Apply (IEvent event){
  //将事件追加到事件列表中,之后持久化
  Changes.Add(event);
  //传入每个事件以修改当前内存状态
  Mutate(event);
  }
 ... }

  命令处理器