领域驱动设计简介及相关思想

总结现阶段对DDD的认知。缺少实战经验,本篇从各个大佬博客截取组合而成。主要用来领悟其设计思想。

架构演进与复杂性应对

软件架构模式的演进

软件架构模式的演进

第一阶段是单机架构:采用面向过程的设计方法,系统包括客户端 UI 层和数据库两层,采用 C/S 架构模式,整个系统围绕数据库驱动设计和开发,并且总是从设计数据库和字段开始。

第二阶段是集中式架构:采用面向对象的设计方法,系统包括业务接入层、业务逻辑层和数据访问层,采用经典的三层架构。这种架构容易使系统变得臃肿,可扩展性和弹性伸缩性差。

第三阶段是分布式微服务架构:随着微服务架构理念的提出,集中式架构正向分布式微服务架构演进。微服务架构可以很好地实现应用之间的解耦,解决单体应用扩展性和弹性伸缩能力不足的问题。

集中式架构的一个例子

在业务的初期,系统的功能一般都很简单,普通的CRUD就可以满足,大多数的都采用三层架构,这个时候系统还是清晰的。

但随着业务规模的增长,需求逐渐变多,业务逻辑变得越来越复杂,系统变得冗余。模块之间相互依赖,修改一个功能时,往往需要花很多时间在回溯原有的逻辑上,修改带来的影响也充满不确定性。

服务耦合

订单服务中提供了查询、创建订单相关的接口,也提供了订单评价、支付、保险的接口。同时我们的表也是一个订单大表,包含了非常多字段。在我们维护代码时,牵一发而动全身,很可能只是想改下评价相关的功能,却影响到了创单核心路径。

虽然我们可以通过测试保证功能完备性,但当我们在订单领域有大量需求同时并行开发时,改动重叠、恶性循环、疲于奔命修改各种问题。

面对上述问题,敏捷实践中的重构、测试驱动设计(TDD)以及持续集成(CI)可以对付各种问题:

  • 重构在保持行为不变的前提下改善清楚不协调的局部设计。
  • 测试驱动设计确保对系统系统修改不会导致功能丢失破坏。
  • 持续集成则为团队提供了同一代码库。

上述的三种手段中,重构是克服演进式设计中大杂烩问题的主力,一般通过在类以及方法层面做改动来实现。但通过重构之后的类很难给它一个业务上的含义。这就会导致新的开发人员不总是知道对逻辑修改或者相关功能来源于此类。

面向对象

面向对象主要思维特点是逻辑分析思维,认为万物皆有边界,如同世界这个词语一样,通过寻找边界封装定义一个事物,然后再探究这个事物内部的组成部分,通过封装不变性,开放变化性,增强系统的柔韧性和灵活性。面向对象的本质是逻辑分析哲学,其核心就是将业务领域进行抽象、建模。

在面向对象设计的指导下,代码实现了低耦合高内聚,符合SRP(单一职责)、OCP(开闭)、LSP(里氏代换)、DIP(依赖倒转)、CRP(合成复用)、LoD(迪米特法则)的设计原则。

但面向对象并不是银弹,面向对象对象语言写出来的程序并不就是面向对象的。

贫血模式与失忆症

贫血领域对象:指仅用作数据载体,而没有行为和动作的领域对象。

在习惯了J2EE的开发模式后,Controller/Service/Dao这种分层模式(贫血模式),很自然就会写出面向过程的代码,相关非相关的业务逻辑集中在Service中,在Service中拿着数据处理来实现功能,很多面向对象的理论都没法应用,在这种开发模式中,对象仅仅是数据的载体,没有行为。大量的业务逻辑堆积在一个巨型类中的例子屡见不鲜,代码的复用性和扩展性无法得到保证。

简单的业务系统采用贫血模式和过程化的设计是没有问题的,但业务复杂化之后,业务的逻辑、状态会分散在大量但类方法当中,代码意图逐渐不明确直至腐坏,这种情况就是贫血症引起的失忆症。此时业务的核心功能将会受到侵蚀,需求变更、实现将会变得困难。

软件复杂性应对方法

解决复杂和大规模软件的武器可以粗略的归位三类:分治、抽象、知识。

  • 分治:把问题空间分隔为规模更小且易于处理的若干子问题。好的分治自然高内聚低耦合。
  • 抽象:使用抽象能精简问题空间,而且问题越小越好理解。
  • 知识:指导分治和抽象的手段。

而领域驱动设计正是解决软件复杂的知识。

领域驱动设计

提出及核心思想

2004 年埃里克·埃文斯(Eric Evans)发表了《领域驱动设计 软件核心复杂性应对之道》(Domain-Driven Design –Tackling Complexity in the Heart of Software)这本书,从此领域驱动设计(Domain Driven Design,简称 DDD)诞生。

DDD核心思想是通过领域驱动设计方法定义领域模型,从而确定业务和应用边界,保证业务模型与代码模型的一致性。将问题分解,降低业务理解和系统实现的复杂度。

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

核心概念

领域通用语言

领域通用语言,是DDD中一个非常重要的概念。

开发人员的思想中充斥着类、方法、算法、模式、架构等,总是想将实际生活中的概念和程序工件进行对应。他们希望看到要建立哪些对象类、要如何对对象类之间的关系建模。

开发人员习惯按照封装继承多态等面向对象的概念去思考,会使用这些词汇进行沟通,但是领域专家通常对这些一无所知,他们对软件类库、框架、持久化甚至数据库都没有什么概念,他们只了解他们特有的领域专业技能。所以开发人员与领域专家之间很难进行沟通。

领域驱动设计的一个核心原则是使用一种基于模型的语言。模型是软件满足领域的共同点,很适合作为通用语言的构造基础,使用模型作为模型的核心骨架,要求团队在进行所有的交流时都是用一致的语言。

在代码中也是这样。在共享知识以及推敲模型时,团队会使用演讲、文字和图形,这里要确保团队使用的语言在所有的交流行形式中看上去都是一致的,这种语言就是领域通用语言。

领域通用语言在建模的过程中应该广泛尝试来推动软件专家和领域专家之间的沟通,从而发现要在模型中使用的主要的领域概念。

领域模型

领域模型是领域驱动的核心。采用DDD的设计思想,业务逻辑不再集中在几个大型的类中,而是由相对小的领域对象组成,这些类都具备自己的状态和行为,每个类是相对完整的独立体,并实现领域的业务对象映射。

领域通用语言中的所有关键词汇,在领域模型上应该都能找到。各方人员沟通时,都应该以领域模型为基础。通过讨论的不断深入,大家对领域的认识也会不断深入,领域模型也会不断得到完善,统一语言的词汇也会不断丰富和精准。

需要特别强调的是,开发人员应该尽量保证代码实现和领域模型相绑定,时刻保持代码与模型的一致。如果不绑定,那代码就会慢慢和模型相脱节,就会出现像我们以前那样的设计文档和代码相脱节一样的问题,甚至模型还会起到误导作用。

通过这样一种思路,我们确保语言、模型、代码三者紧密绑定,确保最后实现出来的软件可以准确无误的实现业务需求,并且还能让我们的软件可以快速的和业务同时演进。而不像传统的开发方式那样,分析、设计、实现三个阶段完全脱节,最后出来的软件没有很好的满足业务需求,也不能在未来很快的跟业务需求一起演进。

所以,领域模型同时承载了分析的结果和设计的结果,这里的分析是指对领域内业务需求的分析,设计是指对模型的设计以及软件的设计。所以,我们的领域模型,不能只考虑业务需求,还要同时考虑软件设计的原则,是一种综合考虑的、平衡的设计结果。

与业务模型的区别:业务模型是对业务概念及其关系的表达,而领域模型在业务模型的基础上,用OOA/D的思想进行进一步精炼和抽象的对象关系模型,而且领域模型中有聚合、实体、值对象的区分。

领域模型的作用:

  1. 抽象了领域内的核心概念,并建立概念之间的关系;
  2. 领域模型承担了领域内的状态的维护;
  3. 领域模型维护了领域内的数据之间的业务规则,数据一致性;

重要性

领域模型是对具有某个边界的领域的一个抽象,反映了领域内用户业务需求的本质。

领域模型是有边界的,只反应了我们在领域内所关注的部分。领域模型只反映业务,和任何技术实现无关,领域模型不仅可以反映实体的概念,还可以反映过程的概念。

领域模型确保了我们软件的业务逻辑都在一个模型中,这样提高了软件的可维护性,同时业务的可理解性、可重用性也得到了保证。

领域模型能够帮助开发人员相对平滑地将领域知识转化为软件构造。同时所有人员,领域专家、设计人员、开发人员通过领域模型交流,使用同一个模型防止需求的走样,让作出来的软件真正满足需求。

领域模型是整个软件的核心,是软件中最有价值和最具竞争力的部分,设计精良且符合业务需求的领域模型能够快速的响应需求的变化。

建模时的思考

首先先阐述一个观点:用户需求并不等同于用户,捕捉用户心中的模型也不等同于已用户为核心设计领域模型。建立领域模型时,我们要将用户置于模型之外,才能包容客户的需求。

我们在设计领域模型的时候不能以用户为中心作为出发点去思考问题。不能总想着用户会对系统做什么,而是应该从客观的角度触发,根据用户的需求去挖掘领域本身的属性、行为,思考这些事物本质的关联以及其变化规律。

领域模型是排除了人之外的客观世界模型,但是领域模型中包含人所扮演的参与者角色,一般情况下不要让参与者角色在领域模型中占据主要位置,否则各个系统的领域模型将会变得没有差别,整个系统将会变成人际交互的系统,都是以人为主的活动记录或者跟踪。

因此在谈及领域模型时,已默认把人的因素排除开了,因为领域只有对人来说才有意义,人是在领域范围之外的,如果人也划入领域,那么领域模型将会很难保持客观。领域模型是与谁用和怎样用是无关的客观模型。

归纳起来就是领域模型是建立虚拟模型让我们现实的人使用,而不是建立虚拟的空间去模仿现实。

领域、驱动、设计

DDD对应了领域、驱动、设计三个概念。

什么是领域呢?做一个系统都是有原因的,而这个原因就是我们遇到的问题。任何一个问题都会属于某个特定的领域。一个领域的核心业务是确定的,比如一个电商平台,都有商品、购物车、下单、库存、交易付款等。一个领域的本质就是一个问题域,只要我们确定来系统所属的领域 ,那么这个系统的核心业务,要解决的问题、问题的边界也就确认了。一个领域专家通常要在某个领域深入研究很多年才行。

什么是设计?DDD中的设计主要指领域模型的设计。为什么是领域模型的设计而不是架构设计或其他的什么设计呢?因为DDD是一种基于模型驱动开发的软件开发思想,强调领域模型是整个系统的核心,领域模型也是整个系统的核心价值所在。每一个领域,都有一个对应的领域模型,领域模型能够很好的帮我们解决复杂的业务问题。

什么是驱动?从领域和代码实现的角度来理解,领域模型绑定了领域和代码实现,确保了最终的代码实现就一定是解决了领域中的核心问题的。因为领域驱动领域模型设计,领域模型驱动代码实现。我们只要保证领域模型的设计是正确的,就能确定领域模型可以解决领域中的核心问题;同理,我们只要保证代码实现是严格按照领域模型的意图来落地的,那就能保证最后出来的代码能够解决领域的核心问题的。这个思路,和传统的分析、设计、编码这几个阶段被割裂的软件开发方法学形成鲜明的对比。

开发一个系统时,应该尽量先把领域模型想清楚,然后再开始动手编码,这样的系统后期才会很好维护。但是,很多项目都是一开始模型没想清楚,一上来就开始建表写代码,造成代码冗余,完全是过程式的思考方式,最后导致系统非常难以维护。

更糟糕的是,前期的领域模型设计的不好,不够抽象,如果系统会长期需要维护和适应业务变化,那后面一定会遇到各种问题维护上的困难,比如数据结构设计不合理,代码到处冗余,改BUG到处引入新的BUG,新人对这种代码上手困难等。而那时如果再想重构模型,那要付出的代价会比一开始重新开发还要大,因为还要考虑兼容历史的数据,数据迁移,如何平滑发布等各种头疼的问题。

DDD中的经典分层架构

用户界面

负责向用户展现信息以及解释用户命令:

  • 请求应用层以获取用户所需要展现的数据
  • 发送命令给应用层要求其执行某个用户命令

应用层

很薄的一层,定义软件要完成的所有任务,对外为用户界面层提供各种应用功能,对内调用领域层完成各种业务逻辑,应用层不包含业务逻辑。

领域层

负责表达业务概念,业务状态信息以及业务规则,领域模型处于这一层,是业务软件的核心。

基础设施层

基础设施层为其他层提供通用的技术能力,提供了层间的通信,为领域层实现持久化机制等。通过架构和框架来支持其他层的技术需求。

DDD中的核心构造块

领域——Domain

领域即问题域、问题空间,领域是一种边界、范围,一个领域代表了一个问题域的边界,也可以理解为一个业务的边界。

限界上下文——BoundedContext

解决领域问题需要一套解决方案,解决方案可以拆分为独立的小解决方案。

限界上下文,Bounded表示边界,Context即上下文。是指解决方案的上下文边界。通过这个边界才可以定义这个边界内的领域模型中所有对象概念的明确含义。

实体和值对象

实体——Entity

实体就是领域中需要唯一标识的领域概念。是多个属性、操作或行为的载体,对应业务对象,具有业务属性和业务行为。

实体不应该定义太多的属性或者行为,而应该寻找关联,发现其他一些实体或者值对象,将属性或者行为转移到其他关联的实体或者值对象上。

值对象——ValueObjects

在领域中,并不是每一个事物都需要一个唯一标识,也就是说我们不关心对象是哪一个只关心对象是什么。值对象没有唯一标识,这是它和实体最大的不同。

区分两个值对象是否相同是通过判断所有属性是否相同,而区分实体时则是通过比较实体的唯一标识是否相同。

值对象另外一个明显的特征是不可变,所有的属性都是只读的,所有可以被安全的共享。

在设计值对象时,应该尽量简单,不要让它引用很多其他的对象。

聚合、聚合根——Aggregate、AggregateRoot

聚合通过定义对象之间清晰的所属关系和边界来实现领域模型的内聚,避免来错综复杂难以维护的对象关系网的形成。聚合定义了一组具有内举关系的相关对象的集合,聚合作为修改数据的一个单元。

聚合的特点:

  • 聚合拥有一个根和一个边界,边界定义了聚合内部有哪些实体或值对象,根是聚合内的某个实体
  • 聚合内部的对象之间可以相互引用,但是如果聚合外部像访问内部的对象时必须通过聚合根。
  • 聚合除根以外的其他实体的唯一标识都是本地标识,即只要在聚合内部保持唯一即可,因为其总是从属于聚合
  • 数据库查询的单元也是以聚合为单位,我们不能直接查询聚合内部的某个非根的对象
  • 删除一个聚合根是必须同时删除该聚合的所有相关对象

聚合的识别:从业务的角度分析哪些对象关系是内聚的,这些对象可以看成是一个整体来考虑,这些对象就可以放在一个聚合内。所谓的关系内聚,就是这些对象必须遵循一个固定的规则,这些规则是指在数据变化时必须保持不变的一致性规则。
聚合根:如果聚合中只有一个实体,那么聚合根就是这个实体;如果一个聚合内部有多个实体,那么就需要思考哪个对象有独立存在的意义并且可以与外部进行直接交互。

工厂——Factory

有时创建一个领域对象是一件比较复杂的事情,工厂用来封装创建一个复杂对象尤其是聚合时所需要的知识,将创建对象的细节隐藏。

仓储——Repository

仓储用于将领域模型中的对象持久化到数据库中。

仓储里面存放的对象一定是聚合,领域模型中是以聚合的概念去划分边界的,聚合是我们更新一个对象的边界。

仓储还有一个重要的特征就是分为仓储定义与仓储实现部分。在领域模型中定义仓储的接口,而在基础设施层实现具体的仓储。将存储的职责实现放置在仓储层,保证了应用层代码的简洁。

领域服务——DomainService

领域中有一些概念并不适合建模为对象,即归类到实体或者值对象都不合适。他们本质上是一些操作、动作而不是事物。这些操作常常会涉及到多个领域对象,并协调这些领域对象共同完成这个操作。

如果强行将这些操作职责分配给任何一个对象,被分配的对象就承担了一些不属于自己的职责,会导致对象职责的混乱。

基于面向对象语言规定任何属性或者行为都必须放在类中,DDD认为服务是一个很自然的范式来应对这种操作。

领域服务没有状态只有行为,其存在的意义就是协调领域对象共同完成某个操作,所有的状态还是保存在对应的领域服务中。

领域服务另外一个重要的功能就是可以避免领域逻辑泄露到应用层,带有Facade外观模式的思想。

领域事件——DomainEvent

领域模型中的对象之间既然有关系,就肯定需要相互协作共同完成某个更大的业务逻辑;那么如何协作呢?目前最优雅并能确保领域对象处于核心主动地位的方式是通过Domain Event。

在C#,Java这样的语言中,对象天生并不具备发送消息和接收消息的能力,需要依赖于外部框架;而像Scala的Akka那种Actor Model,一个领域对象就是一个Actor,Actor能够通过发送异步消息和其他Actor通讯联系,这种消息发送是异步的,属于“fire-and-forget”方式。

Domain Event是EDA(Event Driven Architecture)思想的一种体现,EDA原本是用于SOA(Service Oriented Architecture)中,服务与服务之间的通信;Domain Event则是将EDA用于领域对象之间的通信;

引入Domain Event主要目的是为解决如何将领域模型和技术架构进行解耦,让领域模型不依赖于特定的技术架构实现,从而可以让领域模型真正反映纯粹的业务模型。

设计领域模型的一般步骤

  1. 根据需求建立初步的领域模型,识别出明显的领域概念以及他们的关联,关联可以没有方向但必须有一对一,一对多这些关系,用文字精确没有歧义的描述领域概念的涵义以及包含的主要信息
  2. 分析主要的软件应用程序功能,识别出主要的应用层的类,有助于及早发现应用层与领域层的职责
  3. 进一步分析领域模型,识别出实体、值对象、领域服务
  4. 分析关联,对业务进行深入分析以及软件设计原则和性能权衡,明确关联的方向或者去掉不需要的关联
  5. 找出聚合边界以及聚合根
  6. 为聚合根配备仓储
  7. 走查场景,确定领域模型可以有效的解决业务需求
  8. 考虑如何创建领域对象实体或值对象(工厂or构造)
  9. 重构模型。完善模型中有疑问的地方,思考对象获取是否正确,聚合是否正确,性能等。

领域建模是一个不断重构,持续完善的过程。通过领域专家、设计人员、开发人员不断的沟通,模型不断的细化并朝正确的方向演进。

扩展

CQRS架构与EventSource事件溯源

CQRS即Command Query Responsibility Seperation命令查询职责分离,在DDD领域中广泛使用。核心思想是将应用程序的查询部分和命令部分完全分离,这两部分可以用完全不同的模型和技术去实现。

这种命令与查询分离的方式,可以更好的控制请求者的操作。比如命令部分可以通过领域驱动设计来实现;查询操作不会造成数据的修改,它是一种幂等操作,可以直接用最快的非面向对象的方式去实现,比如用SQL,我们还可以为其提供缓存改进查询的性能。这样的思想有很多好处:

  • 实现命令部分的领域模型不用经常为了领域对象可能会被如何查询而做一些折中处理;
  • 由于命令和查询是完全分离的,所以这两部分可以用不同的技术架构实现,包括数据库设计都可以分开设计,每一部分可以充分发挥其长处;
  • 高性能,命令端因为没有返回值,可以像消息队列一样接受命令,放在队列中,慢慢处理;处理完后,可以通过异步的方式通知查询端,这样查询端可以做数据同步的处理;

AxonFramework CQRS架构图

CRQS模式思维的源头是基于事件的异步状态机模型。在CQRS的思维下,将领域模型尤其是业务流程看作是一种领域对象状态迁移的过程。这一点与REST将HTTP应用协议看作是应用状态迁移的引擎有着异曲同工之妙。

CQRS引出了Command与Event的概念:

  • Command是系统中引起状态变化的活动,通常是一种命令语气。
  • Event则描述了某种事情的发生,通常是命令的结果。

Command和Event都有对应的Handler来处理,他们具有一个共同的特征,支持异步处理。这也是CRQS架构引入Bus的原因。Command Bus负责对Command分发,并没有对Command提供异步处理,仅将其路由到对应的CommandHandler。Event的处理方式与其类似,Event Bus将Event分发到对应的Handler,Event Handler负责更新数据源,保证查询端可以得到最新的数据。

CQRS中的事件更接近于一种事实,即某次数据改变的结果,是一种确定无疑已经发生的事实。这一思想引入了EventSource,并带来Audit审计的好处。

那么什么是EventSource事件溯源?事件溯源是MartinFowler提出的一种架构模式

  • 整个系统以事件为驱动,所有的业务都由事件驱动来完成。
  • 事件是一等公民,系统的数据以事件为基础,事件要保存在某种存储上
  • 业务数据只是一些由事件产生的视图。

EventSource与CRQS有着天然的联系,但是目前并没有成熟的框架。

服务拆分与架构演进

那么如何从单体结构拆分为服务化的架构?

识别业务的领域、边界

  • Inception -> User Journey|Scenarios(场景),用于梳理业务流程,由粗粒度到细粒度逐一场景分析。
  • 四色建模,用于提取核心概念、关键数据项和业务约束。
  • DDD用于划分领域及边界、进行技术验证。
  • EventStorming事件风暴,提取领域中的业务事件用于正确建模。

拆分方法与策略

  • 绞杀者模式:在遗留系统外围,将新功能用新的方式构建为新的服务。随着时间的推移,新的服务逐渐“绞杀”老的一流系统。对于那些老旧庞大难以更改的遗留系统,推荐采用绞杀者模式。
  • 修缮者模式:将老旧待修缮的部分进行隔离,用新的方式对其进行单独修复。修复的同时,需保证与其他部分仍能协同功能。

修缮者模式

拆分步骤

对于模块的拆分包括两部分数据库与业务代码,可以先数据库后业务代码,亦可先业务代码后数据库。如果代码中出现了跨模块的数据库连表查询,会导致后期服务的拆分非常困难,所以更推荐数据库先行。

数据库拆分

通过重复schema 同步数据,对数据库的读写操作分别进行迁移。

推荐阅读

《领域驱动设计》
《UML和模式应用》

参考资料

https://tech.meituan.com/2017/12/22/ddd-in-practice.html
https://www.cnblogs.com/netfocus/p/DDD.html
https://www.cnblogs.com/netfocus/archive/2011/10/10/2204949.html
https://www.infoq.cn/article/service-split-and-architecture-evolution/
http://agiledon.github.io/blog/2012/12/31/basic-understanding-on-cqrs/

Author: nopainanymore
Link: http://nopainanymore.me/DDD-understanding/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
wechat