Menu Close

DDD领域驱动设计

1. 什么是DDD

2004年Eric Evans 发表《领域驱动设计——软件核心复杂性应对之道》(Domain-Driven Design –Tackling Complexity in the Heart of Software),简称Evans DDD,领域驱动设计思想进入软件开发者的视野。领域驱动设计分为两个阶段:

  1. 以一种领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具,在交流的过程中发现领域概念,然后将这些概念设计成一个领域模型;
  2. 由领域模型驱动软件设计,用代码来实现该领域模型;

简单地说,软件开发不是一蹴而就的事情,我们不可能在不了解产品(或行业领域)的前提下进行软件开发,在开发前,通常需要进行大量的业务知识梳理,而后到达软件设计的层面,最后才是开发。而在业务知识梳理的过程中,我们必然会形成某个领域知识,根据领域知识来一步步驱动软件设计,就是领域驱动设计的基本概念。而领域驱动设计的核心就在于建立正确的领域驱动模型。

DDD不是一种架构,而是一种架构方法论,是一种拆解业务、划分业务、确定业务边界的方法, 被认为是一种高度复杂的领域设计思想。简单的来说,是为了实现复杂问题领域简单化,帮助我们设计出清晰的领域和边界,以便于更好推进技术架构的演进。

业务的快速变化驱动着软件系统越变越复杂,DDD是为了解决特别复杂而且快速变化的业务系统。DDD 可以很好实现微服务内部和外部的"高内聚、低耦合"。

DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。

我们需要将面向具体实现编程改造为面向抽象接口编程。DDD作为一种思想来辅助我们设计。

1.1. 领域驱动的思想

软件的本质是对真实世界的模拟,软件设计与真实世界对应的关系如下图所示:

file

真实世界是什么样,软件设计就怎么设计,在每次需求变更的时候将变更还原到真实世界中,看看真实世界是什么样子的,根据真实世界进行变更。这样日后无论怎么变更,经过多少轮变更,都按照这样的方法进行设计就不会迷失方向,设计质量就可以得到保证,这就是领域驱动设计的思想。

  1. 真实世界有什么事物,软件世界就有什么对象。
  2. 真实世界中这些事物都有哪些行为,软件世界中这些对象就有哪些方法。
  3. 真实世界中这些事物间都有哪些关系,软件世界中这些对象间就有什么关联。

在领域驱动设计中就将以上三个对应先做成一个领域模型,然后通过这个领域模型指导程序设计,在每次需求变更时先将需求还原到领域模型中分析,根据领域模型背后的真实世界进行变更,然后根据领域模型的变更指导软件的变更,设计质量就可以得到提高。总之,软件发展的规律就是逐步由简单软件向复杂软件转变,简单软件有简单软件的设计,复杂软件有复杂软件的设计,因此当简单软件向复杂软件转变时,就需要通过“两顶帽子”适时地对程序结构进行调整,再实现新需求,只有这样才能保证软件不退化。

1.2. 领域驱动设计的核心

领域驱动设计的核心是领域模型,这一方法论可以通俗的理解为先找到业务中的领域模型,以领域模型为中心驱动项目的开发。而领域模型的设计精髓在于面向对象分析,在于对事物的抽象能力,一个领域驱动架构师必然是一个面向对象分析的大师。

在面向对象编程中讲究封装,讲究设计低耦合,高内聚的类。而对于一个软件工程来讲,仅仅只靠类的设计是不够的,我们需要把紧密联系在一起的业务设计为一个领域模型,让领域模型内部隐藏一些细节,这样一来领域模型和领域模型之间的关系就会变得简单。这一思想有效的降低了复杂的业务之间千丝万缕的耦合关系。

1.3. DDD中使用的方法论

file

1.4. DDD设计模式

  1. 贫血模型
    • POJO 没有业务
    • 业务交由Service处理
  2. 充血模型
    • DAO 负责和业务映射
    • DO 负责DB映射

2. DDD四层架构规范

2.1. 模型关系图

file

file

  • 基础层(Infrastructure Layer):业务与数据分离。为领域层提供持久化服务,为其它层提供通用能力。
    file

  • 领域层(Domain Layer): 系统的核心,纯粹表达业务能力,不需要任何外部依赖,领域服务之间可以相互调用,但绝对不能把别的领域的业务逻辑写到自身领域中来
    file

  • 应用层(Application Layer):协调领域对象,组织形成业务场景,只依赖于领域层。
    file

  • 用户层(User Interface):负责与用户进行交互。理解用户请求,返回用户响应。只依赖于应用层。(它是不是可以访问领域层?)
    file

2.2. 分层的原则

在《实现领域驱动设计》一书中,DDD 分层架构有一个重要的原则:每层只能与位于其下方的层发生耦合。

  • 严格分层架构
    优化后的 DDD 分层架构模型就属于严格分层架构,任何层只能对位于其直接下方的层产生依赖。即领域服务只能被应用服务调用,而应用服务只能被用户接口层调用,服务是逐层对外封装或组合的,依赖关系清晰。

  • 松散分层架构
    而传统的 DDD 分层架构则属于松散分层架构,它允许某层与其任意下方的层发生依赖。服务的依赖关系比较复杂且难管理,甚至容易使核心业务逻辑外泄。

2.3. 领域模型中的概念

领域的整体概念图

file

领域概念脑图

file

  • 领域
    领域可以拆分为多个子领域。一个领域相当于一个问题域,领域拆分为子域的过程就是大问题拆分为小问题的过程。例如:保险领域被拆分为投保支付保单管理理赔四个子域。

file

  • 限界上下文-Bounded['baʊndɪd] Context[ˈkɑnˌtekst]
    限的意思就是划分、规定;界就是界限、边界;上下文就是业务的整个流程。
    限界上下文是一个语义上的边界,在边界内,通用语言中的所有术语和词组都有特定的含义。在很多情况下,在不同模型中存在名字相同或相近的对象,但是它们的意义却不同。当模型被一个显示的边界包围时,其中每个概念的含义都是明确的。比如用户,可能是C端的产品使用者,可能是B端的商家,可能是平台自己的运营,语义是不明确的。在商家管理的限界上下文,用户就是商家,含义是明确的。
    模块(Module)是DDD中明确提到的一种控制限界上下文的手段,在我们的工程中,一般尽量用一个模块来表示一个领域的限界上下文。

  • 通用语言-Ubiquitous Language(UL)
    可以把限界上下文看成是整个应用程序之内的一个概念性边界。这个边界内还有一个很重要的概念叫通用语言,限界上下文中的每种领域术语、词组、或者句子都叫做通用语言,无论是领域专家和开发人员在对领域问题的沟通、需求的讨论,开发计划的制定、概念、还是代码中出现的类名与方法,都包括其中,而且要注意的一个规则是:只要是相同的意思,就应该使用相同的词汇。可以看出,这种通用语言不是一下子就可以形成,而是在一个各方人员的讨论中,不断发现、明确与提炼出来的。关于限界上下文中的术语一定要准确反映通用语言的问题。

  • 实体-Entity
    一个由它的标识定义的对象叫做实体。通常实体具备唯一ID,能够被持久化,具有业务逻辑,对应现实世界业务对象。

  • 值对象-Value Object
    一个没有概念上标识符描述一个领域方面的对象,这些对象是用来表示临时的事物,或者可以认为值对象是实体的属性,这些属性没有特性标识但同时表达了领域中某类含义的概念。通常值对象不具有唯一ID,由对象的属性描述,可以用来传递参数或对实体进行补充描述。
    当你只关心某个对象的属性时,该对象便可作为一个值对象。为其添加有意义的属性,并赋予它相应的行为。我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性。

  • 聚合-Aggregate
    聚合表示一组领域对象(包括实体和值对象),用来表述一个完整的领域概念。而每个聚合都有一个根实体,这个根实体又叫做聚合根。举个简单的例子,一个电脑包含硬盘、CPU、内存条等,这一个组合就是一个聚合,而电脑就是这个组合的聚合根。在聚合中,根是唯一允许外部对象保持对它的引用的元素,而边界内部的对象之间则可以互相引用。除根以外的其他Entity都有本地表示,但这些标识只有在聚合内部才需要加以区别,因为外部对象除了根Entity之外看不到其他对象。
    聚合表达的是真实世界中整体与部分的关系。当整体不存在时,部分就变得没有了意义。
    file

  • 聚合根-Aggregate Root
    聚合根(Aggreate Root, AR)是一组相关对象的集合,作为一个整体被外界访问,聚合根就是这个聚合的根节点。它通常是软件模型中那些最重要的以名词形式存在的领域对象,对于一个会员管理系统,会员(Member)便是一个聚合根;对于报销系统,报销单(Expense)便是一个聚合根;对于保险系统,保单(Policy)便是一个聚合根。聚合根是主要的业务逻辑载体,DDD中所有的战术实现都围绕着聚合根展开。
    简单的理解:聚合根就是把关联紧密的实体放到一起,对外提供统一的访问,外界不能直接访问内部的实体。

  • 聚合根与聚合根之间的通信
    1)如果是经典的DDD设计,那么应该让领域服务来完成多个聚合根之间的通信,领域服务知道该如何以面向过程的方式先调用第一个聚合根做事情,然后再调用第二个聚合根做事情,以此类推。这种方法实际上是一个面向过程的思维,对象实际上已经沦落为被操纵的数据了;这种方式可以使用业务以编排的方式执行(LiteFlow)。
    2)可以通过领域事件实现(事件监听)。例如:消息、

  • 模块-Modules
    对于一个复杂的应用来说,领域模型将会变的越来越大,以至于很难去描述和理解,更别提模型之间的关系了。模块的出现,就是为了组织统一的模型概念来达到减少复杂性的目的。模块应该包含一组具有高内聚性的概念集合。这样做的好处是可以在不同的模块之间实现松耦合,从而提高代码质量和可维护性。否则,我们应该修改模型以重新划分这些概念。由于模块名是UL的一部分,模块名应该反映出它们在领域中的概念。[Evans]
    模块应当有对外的统一接口供其他模块调用,比如有三个对象在模块a中,那么模块b不应该直接操作这三个对象,而是操作暴露的接口。模块的命名也很有讲究,最好能够深层次反映领域模型。
    模块在技术上可以对应Java中的Package。在DDD中,模块表示了一个命名的容器,用于存放领域中内聚在一起的类。
    模块的设计是基于领域模型的,要符合通用语言的表述。其次,模块的设计要符合高内聚低耦合的设计思想。

  • 模块和限界上下文的关系
    模块与子域和限界上下文并不是一致的概念,模块也是一种独立的建模方法。对于何时应该对领域模型进行分离,何时将领域模型建模成一个整体,应该仔细地思考与对待。有时通用语言可以很好地帮助我们做出正确的选择。但是另外的时候,其中的术语将变得非常含糊。在这种情况下,我们并不清楚如何划分上下文边界。此时,我们可以首先将它们放在一起,使用模块来对模型进行划分,并不是限界上下文。但是,这并不意味着我们就应该限制对限界上下文的创建。我们应该通过通用语言的需求来划分模型边界。但限界上下文不是用来代替模块的。使用摸块的目的在于组织那些内聚在一起的领域对象,对于那些内聚性不强或者没有内聚性的领域对象来说,我们应该将它们划分在不同的模块中。

  • 领域服务-Domain Services
    有时,它不见得是一件东西……当领域中的某个操作过程或转换过程不是实体或值对象的职责时,此时我们便应该将该操作放在一个单独的接口中,即领域服务。请确保该领域服务和通用语言是一致的;并且保证它是无状态的。[Evans, pp. 104,106]
    那么在什么情况下,某个操作不属于实体或者值对象呢?书中罗列了以下几点:
    1)执行一个显著的业务操作过程。
    2)对领域对象进行转换。
    3)以多个领域对象作为输入进行计算,结果产生一个值对象。
    对于最后一点中的计算过程,它应该具有“显著的业务操作过程"的特点。这也是领域服务很常见的应用场景,它可能需要多个聚合作为输人。 当一个方法不便放在实体或值对象上时,使用领域服务便是最佳的解决方法。需要确保领域服务是无状态的,并且能够明确地表达限界上下文中的通用语言。
    不过只要在真正必要是才应该使用领域服务,过度使用领域服务将会导致贫血领域模型,所有业务都位于领域服务中,而不是实体和值对象中了。

  • 领域事件-Domain Events
    是将领域对象从对repository或service的依赖中解脱出来,避免让领域对象对这些设施产生直接依赖。领域事件的触发点在领域模型(domain model)中。就是当领域对象的业务方法需要依赖到这些对象时就发出一个事件,这个事件会被相应的对象监听到并做出处理。

  • 应用服务-Application Service
    在DDD设计思想中,应用层主要职责为组装领域层各个组件及基础设施层的公共组件,完成具体的业务服务。应用层可以理解为粘合各个组件的胶水,使得零散的组件组合在一起提供完整的业务服务。在复杂的业务场景下,一个业务case通常需要多个domain实体参与进来,所以Application的粘合效用正好有了用武之地。
    在应用服务中,我们并不会处理业务逻辑,但是领域服务却拾恰是处理业务逻辑的。简单来讲,应用服务是领域模型很自然的客户方,进而也是领域服务的客户方。
    应用服务作为总体协调者,先通过资源库获取到聚合根,然后调用聚合根中的业务方法,最后再次调用资源库保存聚合根。流程示意图如下:
    file

  • 防腐层-Anti-corruption layer(ACL)
    防腐层就是一个上下文通过一些适配和转换与另一个上下文交互。它的作用主要有:
    1)在架构层面,通过引入防腐层有效隔离限界上下文之间的耦合。
    2)防腐层同时还可以扮演适配器、调停者、外观(Facade)等角色。
    3)防腐层往往属于下游限界上下文,用以隔绝上游限界上下文可能发生的变化。
    file
    防腐层是一种在不同应用间转换的机制。创建一个防腐层,以根据客户端自己的领域模型为客户提供功能。该层通过其现有接口与另一个系统进行通信,几乎不需要对其进行任何修改。在不共享相同领域模型的不同子系统之间实施防腐层(或外观或适配器层),此层转换一个子系统向另一个子系统发出的请求。 使用防腐层(Anti-corruption layer)模式可确保应用程序的设计不受限于对外部子系统的依赖。因此,防腐层隔离不仅是为了保护自身领域模型免受其他领域模型的代码的侵害,还在于分离不同的域并确保它们在将来保持分离。

  • 工厂-Factory
    一个对象的创建可能是它自身的主要操作,但是复杂的组装操作不应该成为被创建对象的职责。组合这样的职责会产生笨拙的设计, 也很难让人理解。因此,有必要引入一个新的概念,这个概念可以帮助封装复杂的对象创建过程,它就是工厂(Factory)。工厂用来封装对象创建所必需的知识,它们对创建聚合特别有用。当聚合的根建立时,所有聚合包含的对象将随之建立。
    工厂用来封装创建一个复杂对象,尤其是聚合时所需的知识,是将创建对象的细节隐藏起来。如客户传递给工厂一些简单的参数,然后工厂可以在内部创建一个复杂的领域对象,然后返回给客户。
    file

  • 资源库-Repository
    是用来管理实体的集合,仓储里面存放的对象一定是聚合,原因是domain是以聚合的概念来划分边界的;聚合作为一个整体概念,要么一起被取出来,要么一起被删除。外部访问不会单独对某个聚合内的子对象进行单独操作。因此,我们只对聚合设计仓储。
    资源库的是封装所有获取对象引用所需的逻辑。领域对象不需处理基础设施,以得到领域中对其他对象的所需的引用。只需从资源库中获取它们,于是模型重获它应有的清晰和焦点。
    资源库会保存对某些对象的引用。当一个对象被创建出来时,它可以被保存到资源库中,然后以后使用时可从资源库中检索到。如果客户程序从资源库中请求一个对象,而资源库中并没有它,就会从存储介质中获取它。换种说法是,资源库作为一个全局的可访问对象的存储点而存在。
    Repository的接口应当采用领域通用语言。作为客户端,不应当知道数据库实现的细节。
    Repository和DAO的作用类似,二者的主要区别:

    1. DAO是比Repository更低的一层,包含了如何从数据库中提取数据的代码。
    2. Repository以“领域”为中心,所描述的是“领域语言”。Repository把ORM框架与领域模型隔离,对外隐藏封装了数据访问机制。
      file
  • Assembler[əˈsemblər]-装配
    实现 DTO 与领域对象之间的相互转换和数据交换。一般来说 Assembler 与 DTO 总是一同出现。

  • Facade [fəˈsɑd]-外观
    提供较粗粒度的调用接口,将用户请求委托给一个或多个应用服务进行处理。

2.5. 架构中的思想与方法

名称 具体内容
聚合的管理 聚合根、实体和对象的关系
聚合数据的初始化与持久化 工厂和仓储模式
聚合的解耦 聚合代码的解耦、跨聚合的服务调用和解耦
领域事件管理 领域事件实体结构、持久化和事件发布
DDD分层架构 基础层、领域层、应用层和接口层的协作
服务的分层与协作 实体方法、领域服务、应用服务、接口服务,服务的组合和编排,跨多个聚合的服务管理与协同
对象的分层与转换 DTO、DO与PO等对象在不同层的转换和实现过程
微服务间的访问 登录和认证服务
  • 两顶帽子
    1.在不添加新功能的前提下,重构代码,调整原有程序结构,以适应新功能。
    2.实现新的功能。

2.6. 总结

  • 服务是行为的抽象。
  • 应用服务通过委托领域对象和领域服务来表达用例和用户故事。
  • 领域对象(实体和值对象)负责单一操作。
  • 领域服务用于协调多个领域对象共同完成某个业务操作,它包含处理业务的逻辑。而应用服务不要处理业务逻辑,它关注的是安全、事务等非业务逻辑。
  • 对事务的管理绝对不能放在领域服务层,而应该放在应用服务层。因为和领域模型相关的操作的粒度都很细,无法用于事务管理。通常可以放在应用服务中的逻辑有:参数验证、错误处理、监控日志、事务处理、认证与授权。
  • 应用服务是领域服务的客户方,也就是说应用服务会调用领域服务里的方法,实现对领域服务的编排。

3. 领域模型中的实体类介绍

领域模型中的实体类分为四种类型:VO、DTO、DO、PO,各种实体类用于不同业务层次间的交互,并会在层次内实现实体类之间的转化。相应各层间实体的传递如下图:

  • 用户发出请求(可能是填写表单),表单的数据在展示层被匹配为VO。
  • 用户层把VO转换为服务层对应方法所要求的DTO,传送给服务层。
  • 服务层首先根据DTO的数据构造(或重建)一个DO,调用DO的业务方法完成具体业务。
  • 服务层把DO转换为持久层对应的PO,调用持久层的持久化方法,把PO传递给它,完成持久化操作。

3.1. VO-View Object

视图对象,用于用户层,它的作用是把某个指定页面(或组件)的所有数据封装起来。

3.2. DTO-Data Transfer Object

数据传输对象,这个概念来源于J2EE的设计模式,原来的目的是为了EJB的分布式应用提供粗粒度的数据实体,以减少分布式调用的次数,从而提高分布式调用的性能和降低网络负载。但在这里表示数据传输的载体,内部不存在任何业务逻辑,我们可以通过 DTO 把内部的领域对象与外界隔离,通常用于用户层与服务层之间的数据传输对象。

3.3. VO与DTO的区别

既然DTO是用户层与服务层之间传递数据的对象,为什么还需要一个VO呢?对!对于绝大部分的应用场景来说,DTO和VO的属性值基本是一致的,而且他们通常都是POJO,因此没必要多此一举,但不要忘记这是实现层面的思维,对于设计层面来说,概念上还是应该存在VO和DTO,因为两者有着本质的区别,DTO代表服务层需要接收的数据和返回的数据,而VO代表用户层需要显示的数据。
用一个例子来说明可能会比较容易理解:例如服务层有一个getUser的方法返回一个系统用户,其中有一个属性是gender(性别),对于服务层来说,它只从语义上定义:1-男性,2-女性,0-未指定,而对于用户层来说,它可能需要用“帅哥”代表男性,用“美女”代表女性,用“秘密”代表未指定。

3.4. VO与DTO的应用

在以下才场景中,我们可以考虑把VO与DTO二合为一(注意:是实现层面):
1)需求非常清晰稳定,而且客户端很明确只有一个的时候,没有必要把VO和DTO区分开来,这时候VO可以退隐,用一个DTO即可,为什么是VO退隐而不是DTO?回到设计层面,服务层的职责依然不应该与用户层耦合,所以,对于前面的例子,你很容易理解,DTO对于“性别”来说,依然不能用“帅哥美女”,这个转换应该依赖于页面的脚本(如JavaScript)或其他机制(JSTL、EL、CSS)
2)即使客户端可以进行定制,或者存在多个不同的客户端,如果客户端能够用某种技术(脚本或其他机制)实现转换,同样可以让VO退隐。

3.5. DO

DO(Domain Object):领域对象,就是从现实世界中抽象出来的有形或无形的业务实体。DO不是简单的POJO,它具有领域业务逻辑。

3.6. DTO与DO的区别

首先是概念上的区别,DTO是用户层和服务层之间的数据传输对象(可以认为是两者之间的协议),而DO是对现实世界各种业务角色的抽象,这就引出了两者在数据上的区别,例如UserInfo和User,对于一个getUser方法来说,本质上它永远不应该返回用户的密码,因此UserInfo至少比User少一个password的数据。而在领域驱动设计中,正如第一篇系列文章所说,DO不是简单的POJO,它具有领域业务逻辑,如果直接把DO传递给用户层,用户层的代码就可以绕过服务层直接调用它不应该访问的操作,对于基于AOP拦截服务层来进行访问控制的机制来说,这问题尤为突出,而在用户层调用DO的业务方法也会因为事务的问题,让事务难以控制。

3.7. DTO与DO的应用

从上一节的例子中,你可能会发现问题:既然getUser方法返回的UserInfo不应该包含password,那么就不应该存在password这个属性定义,但如果同时有一个createUser的方法,传入的UserInfo需要包含用户的password,怎么办?
在设计层面,用户层向服务层传递的DTO与服务层返回给用户层的DTO在概念上是不同的,但在实现层面,我们通常很少会这样做(定义两个UserInfo,甚至更多),因为这样做并不见得很明智,我们完全可以设计一个完全兼容的DTO,在服务层接收数据的时候,不该由用户层设置的属性(如订单的总价应该由其单价、数量、折扣等决定),无论用户层是否设置,服务层都一概忽略,而在服务层返回数据时,不该返回的数据(如用户密码),就不设置对应的属性。
对于DO来说,还有一点需要说明:为什么不在服务层中直接返回DO呢?这样可以省去DTO的编码和转换工作,原因如下:
1)两者在本质上的区别可能导致彼此并不一一对应,一个DTO可能对应多个DO,反之亦然,甚至两者存在多对多的关系。
2)DO具有一些不应该让用户层知道的数据
3)DO具有业务方法,如果直接把DO传递给用户层,用户层的代码就可以绕过服务层直接调用它不应该访问的操作,对于基于AOP拦截服务层来进行访问控制的机制来说,这问题尤为突出,而在用户层调用DO的业务方法也会因为事务的问题,让事务难以控制。对于某些ORM框架(如Hibernate)来说,通常会使用“延迟加载”技术,如果直接把DO暴露给用户层,对于大部分情况,用户层不在事务范围之内(Open session in view在大部分情况下不是一种值得推崇的设计),如果其尝试在Session关闭的情况下获取一个未加载的关联对象,会出现运行时异常(对于Hibernate来说,就是LazyInitiliaztionException)。
4)从设计层面来说,用户层依赖于服务层,服务层依赖于领域层,如果把DO暴露出去,就会导致用户层直接依赖于领域层,这虽然依然是单向依赖,但这种跨层依赖会导致不必要的耦合。

3.8. PO-Persistent Object

持久化对象,它跟持久层(通常是关系型数据库)的数据结构形成一一对应的映射关系,如果持久层是关系型数据库,那么,数据表中的每个字段(或若干个)就对应PO的一个(或若干个)属性。

3.9. DO与PO的区别

DO和PO在绝大部分情况下是一一对应的,PO是只含有get/set方法的POJO,但某些场景还是能反映出两者在概念上存在本质的区别:
1)DO在某些场景下不需要进行显式的持久化,例如利用策略模式设计的商品折扣策略,会衍生出折扣策略的接口和不同折扣策略实现类,这些折扣策略实现类可以算是DO,但它们只驻留在静态内存,不需要持久化到持久层,因此,这类DO是不存在对应的PO的。
2)同样的道理,某些场景下,PO也没有对应的DO,例如老师Teacher和学生Student存在多对多的关系,在关系数据库中,这种关系需要表现为一个中间表,也就对应有一个TeacherAndStudentPO的PO,但这个PO在业务领域没有任何现实的意义,它完全不能与任何DO对应上。这里要特别声明,并不是所有多对多关系都没有业务含义,这跟具体业务场景有关,例如:两个PO之间的关系会影响具体业务,并且这种关系存在多种类型,那么这种多对多关系也应该表现为一个DO,又如:“角色”与“资源”之间存在多对多关系,而这种关系很明显会表现为一个DO——“权限”。
3)某些情况下,为了某种持久化策略或者性能的考虑,一个PO可能对应多个DO,反之亦然。例如客户Customer有其联系信息Contacts,这里是两个一对一关系的DO,但可能出于性能的考虑(极端情况,权作举例),为了减少数据库的连接查询操作,把Customer和Contacts两个DO数据合并到一张数据表中。反过来,如果一本图书Book,有一个属性是封面cover,但该属性是一副图片的二进制数据,而某些查询操作不希望把cover一并加载,从而减轻磁盘IO开销,同时假设ORM框架不支持属性级别的延迟加载,那么就需要考虑把cover独立到一张数据表中去,这样就形成一个DO对应对多个PO的情况。
4)PO的某些属性值对于DO没有任何意义,这些属性值可能是为了解决某些持久化策略而存在的数据,例如为了实现“乐观锁”,PO存在一个version的属性,这个version对于DO来说是没有任何业务意义的,它不应该在DO中存在。同理,DO中也可能存在不需要持久化的属性。

3.10. DO与PO的应用

由于ORM框架的功能非常强大而大行其道,而且JavaEE也推出了JPA规范,现在的业务应用开发,基本上不需要区分DO与PO,PO完全可以通过JPA,Hibernate Annotations/hbm隐藏在DO之中。虽然如此,但有些问题我们还必须注意:
1)对于DO中不需要持久化的属性,需要通过ORM显式的声明,如:在JPA中,可以利用@Transient声明。
2)对于PO中为了某种持久化策略而存在的属性,例如version,由于DO、PO合并了,必须在DO中声明,但由于这个属性对DO是没有任何业务意义的,需要让该属性对外隐藏起来,最常见的做法是把该属性的get/set方法私有化,甚至不提供get/set方法,但对于Hibernate来说,这需要特别注意,由于Hibernate从数据库读取数据转换为DO时,是利用反射机制先调用DO的空参数构造函数构造DO实例,然后再利用JavaBean的规范反射出set方法来为每个属性设值,如果不显式声明set方法,或把set方法设置为private,都会导致Hibernate无法初始化DO,从而出现运行时异常,可行的做法是把属性的set方法设置为protected。
3)对于一个DO对应多个PO,或者一个PO对应多个DO的场景,以及属性级别的延迟加载,Hibernate都提供了很好的支持,请参考Hibnate的相关资料。

3.11. 总结

到目前为止,相信大家都已经比较清晰的了解VO、DTO、DO、PO的概念、区别和实际应用了。通过上面的详细分析,我们还可以总结出一个原则:分析设计层面和实现层面完全是两个独立的层面,即使实现层面通过某种技术手段可以把两个完全独立的概念合二为一,在分析设计层面,我们仍然(至少在头脑中)需要把概念上独立的东西清晰的区分开来,这个原则对于做好分析设计非常重要(工具越先进,往往会让我们越麻木)。

下图是微服务各层数据对象的职责和转换过程。

file

4. 领域建模的步骤

DDD 领域建模通常采用事件风暴,它通常采用用例分析、场景分析和用户旅程分析等方法,通过头脑风暴列出所有可能的业务行为和事件,然后找出产生这些行为的领域对象,并梳理领域对象之间的关系,找出聚合根,找出与聚合根业务紧密关联的实体和值对象,再将聚合根、实体和值对象组合,构建聚合。

4.1. 统一语言,梳理业务

  • 描绘需求或问题本身
  • 不断梳理业务
  • 提炼出核心的领域模型,而不是表设计

4.2. 划分限界上下文

根据 不同的场景 来划分限界上下文。限界上下文是微服务设计和拆分的主要依据。在领域模型中,如果不考虑技术异构、团队沟通等其它外部因素,一个限界上下文理论上就可以设计为一个微服务。通过这种基本业务理解的拆分方式,可以使系统做到高内聚、单一职责,拆分出来的第一个微服务都是软件变化的一个原因,不会因为某个原因发生了变更去修改每个微服务,牵一发而动全身。

我们的实践是,考虑产品所讲的通用语言,从中提取一些术语称之为概念对象,寻找对象之间的联系;或者从需求里提取一些动词,观察动词和对象之间的关系;我们将紧耦合的各自圈在一起,观察他们内在的联系,从而形成对应的界限上下文。形成之后,我们可以尝试用语言来描述下界限上下文的职责,看它是否清晰、准确、简洁和完整。简言之,限界上下文应该从需求出发,按领域划分。

4.3. 识别聚合、聚合根

一个 Bounded Context(限界上下文)可能包含多个聚合,每个聚合都有一个根实体,叫做聚合根。

  1. 聚合根与相关对象的关联

    • 聚合根到聚合根:通过ID关联
    • 聚合根到其内部的实体:直接对象引用
    • 聚合根到值对象:直接对象引用
  2. 聚合的几个设计原则

    • 聚合是用来封装真正的不变性,而不是简单的将对象组合在一起。
    • 聚合应尽量设计的小,尽可能小的拆分,这样可以避免重构、重新拆分。
    • 聚合内强一致性,聚合之间最终一致性。

4.4. 设计小聚合

如果聚合设计过大,聚合会因为包含过多实体,导致实体间管理复杂,高频操作时会出现并发冲突或数据库锁,即便我们可以保证事务的成功执行,它依然有可能限制系统的性能和可伸缩性。
小聚合设计则可降低由于业务过大导致聚合重构的可能性,让领域模型更能适应业务变化。

那么,这里的“小”是什么意思呢?最极端的情况是,一个聚合只拥有全局标识和单个属性,当然,这并不是推荐做法(除非这正是需求所在)。好的做法是使用根实体(Root Entity)来表示聚合,其中只包含最小数量的属性或值类型属性。这里的“最小数量”表示所需的最小属性集合,不多也不少。

哪些属性是所需的呢?简单的答案是:那些必须与其他属性保持一致。比如,一个Product拥有name和 description属性,它们需要保持一致,将它们放在两个不同的聚合中显然无意义。当我们修改name,很可能也会同时修改 description,如果你只修改其一,很可能是在修改语法上的错误或使description能够更匹配name。

在聚合中,若认为有些被包含的部分应该建模成实体,怎么办?首先思考该部分是否会随着时间而改变或该部分是否能被全部替换。若可被全部替换,请将其建模成值对象,而非实体。很多情况下建模成实体的概念都可重构成值对象。优先选用值对象并非意味着聚合就是不变的,因为当值对象属性被替换成其他值时,根实体也就随之改变。

将聚合的内部建模成值对象有很多好处。据所选用持久化机制,值 对象可随根实体而序列化,而实体则需单独存储区域予以跟踪。
实体还会带来某些不必要操作,比如,在使用Hibernate时,需对多表联合查询。对单表读取快得多,而使用值对象也更方便安全。由于值对象不变,测试也相对简单。

小聚合不仅有性能和可伸缩性上的好处,它还有助于事务成功执行,即可减少事务提交冲突。系统的可用性也得到了增强。在你的领域中,迫使你设计大聚合的不变条件约束并不多。当你遇到这样的情况时,可以考虑添加实 体或者是集合,但无论如何,我们都应该将聚合设计得尽量小。

4.5. 通过唯一标识引用其它聚合

聚合之间是通过关联外部聚合根ID的方式引用,而不是直接对象引用的方式。外部聚合的对象放在聚合边界内管理,容易导致聚合的边界不清晰,也会增加聚合之间的耦合度。

4.6. 在边界之外使用最终一致性

聚合内数据强一致性,而聚合间数据最终一致性。
在一次事务中,最多只能更改一个聚合的状态。如果一次业务操作涉及多个聚合状态的更改,应采用领域事件的方式异步修改相关的聚合,实现聚合间解耦。
在不持有对象引用的情况下,不能修改其他聚合,因此我们可以避免在同一个事务中修改多个聚合。但这种方式的缺点在于限制性太强,因为在领域模型中我们总需要对象之间的关联关系来完成一些任务。
那么,此时我们应该怎么办呢?

4.7. 通过应用层实现跨聚合的服务调用

为实现微服务内聚合之间的解耦,以及未来以聚合为单位的微服务组合和拆分,应避免跨聚合的领域服务调用和跨聚合的数据库表关联。

5. 架构设计

5.1. 服务调用

file

5.2. 跨库关联查询解决方案

  1. 方案一、数据冗余
    以空间换时间。

  2. 方案二、数据补填
    结合Wrapper设计模式,一般在Dao层实现数据聚合。

5.3. CQRS

CQRS(Command Query Responsibility Segregation),即命令查询职责分离,这里的命令可以理解为写操作,而查询可以理解为读操作。与“基于数据模型的读操作”不同的是,在CQRS中写操作和读操作使用了不同的数据库,数据从写模型数据库同步到读模型数据库,通常通过领域事件的形式同步变更信息。

file****

5.4. Event Sourcing 事件溯源

通过事件来管理领域对象的生命周期,事件即领域对象已发生的事实,只增不改。

5.5. 整洁架构

通过适配器层解耦业务层与技术框架层的代码。

file

5.6. 六边形架构

file

5.7. 清晰架构

6. 微服务架构5.0

整体中台架构(电商架构中台)
file

7. 代码模型

附录

附录A. 相关联的文章

附录B. 其他参考