美文网首页
领域驱动设计之二

领域驱动设计之二

作者: gregoriusxu | 来源:发表于2022-05-01 15:37 被阅读0次

    前言

    在这个时代,国人很少注重理论知识的积累,俗话说理论指导实践,好的理论都是在实践的基础之上积累下来的,是前人经验的总结。一个好的设计开发人员就体现在这些上面了,如果不注重知识积累,那么就只会一些花拳绣腿,技术上是很难有所提升的,我们先来看看常用的架构模式及演进过程,从中我们可以体会出领域驱动设计的由来以及好处。

    架构模式

    三层架构
    • 表现层,领域层,数据源层表现层:提供服务,显示信息
    • 领域层:逻辑,系统中真正的核心
    • 数据源层:与数据库,消息系统,事务管理器及其他软件包通信运行环境
    • 表现层:运行在客户端
    • 领域层:客户端、服务端
    • 数据源层:服务端
    三层演化架构

    用户界面层,应用层,领域层,基础设施层

    改进分层架构

    依赖颠倒原则:高层模块不应该依赖低层模块,两者都应该依赖于抽象抽象不应该依赖于细节,细节应该依赖于抽象。我们应该将关注点放在领域层上,采用依赖颠倒原则,使领域层和基础设施层都只依赖于领域模型所定义的抽象接口。由于应用层是领域层的直接客户,它将依赖于领域层接口,并且间接地访问资源库和由基础设施层提供的实现类。应用层可以采用不同的方式来获取这些实现,包括依赖注入,服务工厂和插件

    六边形架构(端口与适配器)

    不同的客户通过“平等”的方式与系统交互,如果需要新的客户,只需要添加新的适配器将客户输入转为成能被系统API所理解的参数就行了。同时,系统输出,比如图形界面、持久化和消息等都可以通过不同的方式实现。所以我们有充足的理由认为,这将是一种具有持久生命力的架构。很多声称使用分层架构的团队实际上使用的是六边形的架构。这是因为很多项目都使用了某种形式的依赖注入。并不是说依赖注入天生就是六边形架构,而是说使用依赖注入的架构自然地具有了端口与适配器风格。

    面向服务架构
    REST
    • 架构风格:架构风格之于架构就像设计模式之地设计一样。使得我们在谈及架构时不至于陷入技术细节中。

    • REST通常基于使用HTTP,URI,和XML以及HTML这些现有的广泛流行的协议和标准。

    • 资源是由URI来指定。

    • 对资源的操作包括获取、创建、修改和删除资源,这些操作正好对应HTTP协议提供的GET、POST、PUT和DELETE方法。

    • 通过操作资源的表现形式来操作资源。

    • 资源的表现形式则是XML或者HTML,取决于读者是机器还是人,是消费web服务的客户软件还是web浏览器。当然也可以是任何其他的格式。

    • 架构约束有6个:

      1. 客户-服务器(Client-Server),通信只能由客户端单方面发起,表现为请求-响应的形式。
      2. 无状态(Stateless,通信的会话状态(Session State)应该全部由客户端负责维护。
      3. 缓存(Cache),响应内容可以在通信链的某处被缓存,以改善网络效率。
      4. 统一接口(Uniform Interface),通信链的组件之间通过统一的接口相互通信,以提高交互的可见性。
      5. 分层系统(Layered System),通过限制组件的行为(即每个组件只能“看到”与其交互的紧邻层),将架构分解为若干等级的层。
      6. 按需代码(Code-On-Demand,可选)支持通过下载并执行一些代码(例如如ava Applet、Flash或JavaScript),对客户端的功能进行扩展。
    • REST优点:

      • 可更高效利用缓存来提高响应速度
      • 通讯本身的无状态性可以让不同的服务器的处理一系列请求中的不同请求,提高服务器的扩展性
      • 浏览器即可作为客户端,简化软件需求
      • 相对于其他叠加在HTTP协议之上的机制,REST的软件依赖性更小不需要额外的资源发现机制
      • 在软件技术演进中的长期的兼容性更好

    例如,一个简单的网络商店应用,列举所有商品,
    GET http://www.store.com/products 呈现某一件商品,
    GET http://www.store.com/products/12345 下单购买,
    POST http://www.store.com/orders

    <purchase-order>
    <item>...</item>
    </purchase-order>
    
    CQRS(命令与查询责任分离)

    CQRS最早来自于Betrand Meyer(Eiffel语言之父,开-闭原则OCP提出者)在Object-Oriented Software Construction 这本书中提到的一种命令查询分离(Command Query Separation.CQS)的概念。其基本思想在于,任何一个对象的方法可以分为两大类:

    • 命令(Command):不返回任何结果(void),但会改变对象的状态。
    • 查询(Query):返回结果,但是不会改变对象的状态,对系统没有副作用。

    CQRS是对CQS模式的进一步改进成的一种简单模式。它由Greg Young在CQRS,Task Based UIs,Event Sourcing agh!这篇文章中提出。

    “CQRS只是简单的将之前只需要创建一个对象拆分成了两个对象,这种分离是基于方法是执行命令还是执行查询这一原则来定的(这个和CQS的定义一致)"。

    CQRS使用分离的接口将数据查询操作(Queries)和数据修改操作(Commands)分离开来,这也意味着在查询和更新过程中使用的数据模型也是不一样的。这样读和写逻辑就隔离开来了。

    主数据库处理CUD,从库处理R,从库的的结构可以和主库的结构完全一样,也可以不一样,从库主要用来进行只读的查询操作。在数量上从库的个数也可以根据查询的规模进行扩展,在业务逻辑上也可以根据专题从主库中划分出不同的从库,从库地可以实现成RepgrtingDatabase,根据查询的业务需求,从全库中抽取一些必要的数据生成一系列查询报表来存储。

    使用ReportingDatabase的一些优点通常可以使得查询变得更加简单高效:

    • ReportingDatabase的结构和数据表会针对常用的查询请求进行设计。ReportingDatabase数据库通常会去正规化,存储一些冗余而减少必要的Join等联合查询操作,使得查询简化和高效,一些在主数据库中用不到的数据信息,在ReportingDatabase可以不用存储,可以ReportingDatabase重构优化,而不用去改变操作数据库。
    • 对ReportingDatabase数据库的查询不会给操作数据库带来任何压力。可以针对不同的查询请求建立不同的ReportingDatabase库。

    当命令处理器执行结束后,一个聚合实例将被更新,同时命令模型还将发布一个领域事件。对于更新查询模型来说,这样的领域事件是至关重要的。值得注意的是,所发布的领域事件还可能导致另一些受同一命令影响的聚合实例的同步更新,最终,这些聚合实例都将与本次事务所修改的聚合实例保持最终一致性。

    在命令模型更新之后,如果我们希望查询模型也得到相应的更新,那么从命令模型中发布的领域事件也是关键所在。在使用事件源时,领域事件也被用于持久化修改后的聚合。然而,事件源并不一定与CQRS一起使用。除非事件日志包含在业务需求之中。不然命令模型是可以通过ORM等方式进行持久化的。不管如何,我们都需要发布领域事件以更新查询模型。

    用户界面处理:

    1. 用户提交命令时,同时更新页面数据。
    2. 在用户界面上显示出当前查询模型的日期和时间。要达到这样的目的,查询模型的每一条记录需要维护最后更新时的日期和时间,用户自己决定是否更新。
    3. Comet(Ajax Push)
    4. 分布式缓存网格(Coherence,Gemfire)的事件订阅
    5. 直接通知用户需要等一会。

    术语解释

    • 通用语言(Common Language):
      围绕领域模型建立的一种语言,团队所有成员都使用这种语言把团队的所有活动与软件联系起来
    • 上下文(Context):
      一个单词或句子出现的环境,它决定了其含义。
    • 限界上下文(Bound Context):
      特定模型的限界应用。限界上下文使团队所有成员能够明确地知道什么必须保持一致,什么必须独立开发。
    • 资源库(Repository):
      一个安全的存储区域,并且对其中所存放的物品起保护作用。一种对象,它不是由属性来定义的,而是通过一连串的连续事件和标识定义的。
    • 值对象(Value Object):
      一种描述了某种特征或属性但没有概念标识的对象领域事件,用来捕获发生在领域中的一些事件的建模工具,将领域中所发生的活动建模成一系列的离散事件,每个事件都用领域对象来表示。
    • 领域事件(Domain Event):
      领域事件是一个领域模型中极其重要的部分,用来表示领域中发生的事件。忽略不相关的领域活动,同时明确领域专家要跟踪或希望被通知的事情,或与其他模型对象中的状态更改相关联。
    • 事件存储(Event Store)和事件溯源(Event Sourcing):
      事件存储,顾名思义,即事件的持久化。
    • 聚合(Aggregation Root):
      由实体和值对象组件的一致性边界。

    通用语言(Common Language)

    • 软件开发人员使用的设计语言:UML,软件专业术语,开发语言

    • 领域专家:使用各个领域的业务术语问题:由于语言上不同,设计开发人员在与领域专家沟通时沟通不畅,导致知识消化变得困难,所以需要创建一种统一的语言来便于领域专家和设计开发人员交流。

    • 如何创建通用语言:

      1. 与领域专家对话,提炼关键术语
      2. 借鉴UML图,根据关键术语画草图
      3. 反复与领域专家沟通,来精化模型关键技术:
      4. 使用单词和短语
      5. 书面文档对图形进行补充,并永远保持最新

    限界上下文(Bound Context)

    从广义上讲,领域即是一个组织所做的事情以及其中所包含的一切。
    由于“领域模型”包含“领域”这个词,我们会认为应该为整个业务系统创建一个单一的、内聚的、全功能式的模型。然而,这并不是我们使用DDD的目标。正好相反,在DDD中,一个领域被分为若干子域,领域模型在限界上下文中完成开发。事实上,在开发一个领域模型时,我们关注的通常只是这个业务系统的某个方面。试图创建一个全功能的领域模型是非常困难的,并且很容易导致失败。一个限界上下文并不一定只包含在一个子域中。但这是可能的。一个限界上下文不应该包含岐义的领域特定术语。原则上是一个子域包含一个限界上下文。

    • 核心域:对于核心域,它是整个业务领域的一部分,也是业务成功的主要促成因素。从战略层面上讲,企业应在核心域上胜人一筹。我们应该给核心域最高的优先级,最资深的领域专家和最优秀的开发团队。

    • 理解限界上下文:
      限界上下文是一个显示的边界,领域模型便存在于这个边界之内,领域模型把通用语言表达成软件模型,创建边界的原因在于,每一个模型概念,包括它的属性和操作,在边界之内都具有特殊的含义。模型需要准确反应通用语言。
      比如:账户在银行上下文和文学上下文,图书在出版的不同阶段,用户在身份访问上下文和协作上下文

    • 限界上下文的组成部分:领域模型,数据库定义,用户界面,开放主机服务等。

    • 限界上下文的大小:模块,聚合,领域事件和领域服务这些概念在需要时才引入,不在引入一个不属于你的通用语言的概念。

    实体(Entity)

    • 为什么使用实体:
      当我们需要考虑一个对象的个性特征,或者需要区分不同的对象时,我们引入实体这个领域概念。一个实体是一个唯一的东西,并且可以在相当长的一段时间内持续地变化。我们可以对实体做多次修改,帮一个实体对象可能和它先前的状态大不相同。但是,由于它们拥有相同的身份标识,它们依然是同一个实体。并不是所有的数据对象都需要建立成实体,很多时候,一个领域概念应该建模成值对象,而不是实体对象。

    • 贫血模型:业务对象仅仅包含数据而不包含行为,他的作用只是数据的载体或者说是数据的传递介质。系统的业务逻辑全部放到业务逻辑层,会导致业务逻辑层比较庞大。

    • 充血模型:业务对象既包含数据又包含行为,他的作用不再只是数据的载体而是一个真正有行为的对象。此时,领域层作为软件体系的一个层次出现而非贫血模式中的辅助的角色。贫血模型以数据为中心,客户代码必须知道如何正确地将一个实体进行操作来完成正确的行为,这样的模型是不能称为领域模型的。

    实体标识的生成方式:

    1. 用户提供唯一标识
    2. 应用程序生成唯一标识
    3. 持久化机制生成唯一标识
    4. 另一个限界上下文生成唯一标识
    • 标识生成时间:
      考虑到客户端需要向外界发布领域事件的情形。我们应该及早生成实体标识,方式2是比较好的方式。唯一标识的生成放在实体上或者资源库

    • 创建实体:
      通过构造函数来初始化足够多的实体状态,一方面有助于表明该实体的身份,另一方面可以帮助客户端更容易地查找该实体。

    • 验证属性:建议采用自封装方式

    • 验证整体对象:采用Specification(规范)或者Strategy(策略)来进行验证

    值对象(Value Object)

    • 值对象特征:
      1. 它度量或描述了领域中的一件东西
      2. 它可以作为不变量
      3. 它将不同的相关的属性组合成一个概念整体4.当度量和描述改变时,可以用另一个值对象予以替换
      4. 它可以和其它值对象进行相等性比较
      5. 它不会对协作对象造成副作用

    领域事件(Domain Event)

    领域事件作为领域模型的重要部分,是领域建模的工具之一。用来捕获领域中已经发生的事情。并不是领域中所有发生的事情都要建模为领域事件,要忽略无业务价值的事件。领域事件是领域专家所关心的(需要跟踪的、希望被通知的、会引起其他模型对象改变状态的)发生在领域中的一些事情。简而言之,领域事件是用来捕获领域中发生的具有业务价值的一些事情。它的本质就是事件,不要将其复杂化。在DDD中,领域事件作为通用语言的一种,是为了清晰表述领域中产生的事件概念,帮助我们深入理解领域模型。

    • 事务一致性
      事务一致性是是数据库事务的四个特性之一,也就是ACID特性之一:

      • 原子性(Atomicity):事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。
      • 一致性(Consistency):事务应确保数据库的状态从一个一致状态转变为另一个一致状态。
      • 隔离性(Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
      • 持久性(Durability):已被提交的事务对数据库的修改应该永久保存在数据库中。
    • 最终一致性
      “最终一致性”是一种设计方法,可以通过将某些操作的执行延迟到稍后的时间来提高应用程序的可扩展性和性能。

    引入领域事件的目的主要有两个,一是解耦,二是使用领域事件进行事务的拆分,通过引入事件存储,来实现数据的最终一致性。

    事件存储(Event Store)和事件溯源(Event Sourcing)

    为什么要持久化事件?

    • 当事件发布失败时,可用于重新发布。
    • 通过消息中间件去分发事件,提高系统的吞吐量。
    • 用于事件溯源。
      源代码管理工具我们都用过,如Git、TFS、SVN等,通过记录文件每一次的修改记录,以便我们跟踪每一次对源代码的修改,从而我们可以随时回滚到文件的指定修改版本。

    事件溯源的本质亦是如此,不过它存储的并非聚合每次变化的结果,而是存储应用在该聚合上的历史领域事件。当需要恢复某个状态时,需要把应用在聚合的领域事件按序“重放”到要恢复状态对应的领域事件为止。

    聚合(Aggregation Root)

    1. 聚合设计的原则:
      聚合是用来封装真正的不变性,而不是简单的将对象组合在一起;聚合内强一致性,聚合之间最终一致性;聚合应尽量设计的小;聚合之间的关联通过ID,而不是对象引用;聚合内强一致性,聚合之间最终一致性;聚合是用来封装真正的不变性,而不是简单的将对象组合在一起这个原则,就是强调聚合的真正用途除了封装我们本身所关心的信息外,最主要的目的是为了封装业务规则,保证数据的一致性。在我看来,这一点是设计聚合时最重要和最需要考虑的点;当我们在设计聚合时,要多想想当前聚合封装了哪些业务规则,实现了哪些数据一致性。
    2. 聚合应尽量设计的小
      这个原则,更多的是从技术的角度去考虑的。通过一个例子来说明,该例子中,一开始聚合设计的很大,包含了很多实体,但是后来发现因为该聚合包含的东西过多,导致多人操作时并发冲突严重,导致系统可用性变差;后来开发团队将原来的大聚合拆分为多个小聚合,当然,拆分为小聚合后,原来大聚合内维护的业务规则同样在多个小聚合上有所体现。所以实现了既能解决并发冲突的问题,也能保证让聚合来封装业务规则,实现模型级别的数据一致性;聚合设计的小还有一个好处,就是:业务决定聚合,业务改变聚合。聚合设计的小除了可以降低并发冲突的可能性之外,同样减少了业务改变的时候,聚合的拆分个数,降低了聚合大幅重构(拆分)的可能性,从而能让我们的领域模型更能适应业务的变化
    • 迪米特法则:
      迪米特法则又叫最少知道原则,最早是在1987年由美国Northeastern University的lan Holland提出。通俗的来讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。迪米特法则还有一个更简单的定义:只与直接的朋友通信。首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。

    • “告诉而非询问”原则:
      一个对象应该命令其它对象该做什么,而不是去查询其它对象的状态来决定做什么(查询其它对象的状态来决定做什么也被称作"功能嫉妒(Feature Envy)"
      举例

        if(person,getAddress0.getCountry0=="Australia"){
      

      这违反了得墨忒耳定律,因为这个调用者跟Person过于亲密。它知道Person里有一个Address,而Address里还有一个country。它实际上应该写成这样:

        if(person,livesIn("Australia"))
      
    • 乐观并发:在乐观并发控制中,用户读数据时不锁定数据。在执行更新时,系统进行检查,查看另一个用户读过数据后是否更改了数据。如果另一个用户更新了数据,将产生一个错误。一般倩况下,接收错误信息的用户将回滚事务并重新开始。该方法主要用在数据争夺少的环境内,以及偶尔回滚事务的成本超过读数据时锁定数据的成本的环境内,因此称该方法为乐观并发控制。

    • 悲观并发:锁定系统阻止用户以影响其它用户的方式修改数据。如果用户执行的操作导致应用了某个锁,则直到这个锁的所有者释放该锁,其它用户才能执行与该锁冲突的操作。该方法主要用在数据争夺激烈的环境中,以及出现并发冲突时用锁保护数据的成本比回滚事务的成本低的环境中,因此称该方法为悲观并发控制。

    • 相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最太程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。所以DDD一般采用的是乐观并发锁。借助于ORM乐观并发锁的机制,在聚合内部根实体放置乐观并发的版本号是最安全的做法。

    资源库(Repository)

    资源库的是封装所有获取对象引用所需的逻辑。领域对象不需处理基础设施,以得到领域中对其他对象的所需的引用。只需从资源库中获取它们,于是模型重获它应有的清晰和焦点。

    资源库会保存对某些对象的引用。当一个对象被创建出来时,它可以被保存到资源库中,然后以后使用时可从资源库中检索到。如果客户程序从资源库中请求一个对象,而资源库中并没有它,就会从存储介质中获取它。换种说法是,资源库作为一个全局的可访问对象的存储点而存在。

    服务(Services)

    当我们在分析某一领域时,一直在尝试如何将信息转化为领域模型,但并非所有的点我们都能用Model来涵盖。对象应当有属性,状态和行为,但有时领域中有一些行为是无法映射到具体的对象中的,我们也不能强行将其放入在某一个模型对象中,而将其单独作为一个方法又没有地方,此时就需要服务.

    服务是无状态的,对象是有状态的。所谓状态,就是对象的基本属性:高矮胖瘦,年轻漂亮。服务本身也是对象,但它却没有属性(只有行为),因此说是无状态的。

    服务存在的目的就是为领域提供简单的方法。为了提供大量便捷的方法,自然要关联许多领域模型,所以说,行为(Action)天生就应该存在于服务中。

    服务具有以下特点:

    • 服务中体现的行为一定是不属于任何实体和值对象的,但它属于领域模型的范围内
    • 服务的行为一定涉及其他多个对象
    • 服务的操作是无状态的

    模块(Moudles)

    对于一个复杂的应用来说,领域模型将会变的越来越大,以至于很难去描述和理解,更别提模型之间的关系了。模块的出现,就是为了组织统一的模型概念来达到减少复杂性的目的。而另一个原因则是模块可以提高代码质量和可维护性,比如我们常说的高内聚,低耦合就是要提倡将相关的类内聚在一起实现模块化。

    模块应当有对外的统一接口供其他模块调用,比如有三个对象在模块a中,那么模块b不应该直接操作这三个对象,而是操作暴露的接口。模块的命名也很有讲究,最好能够深层次反映领域模型。

    总结

    理论往往比实践全面的多,它只是对实践进行指导,并不是说你套弄了所有理论那么你才是领域驱动设计的,你的架构才是正统的领域驱动设计架构,不能以偏概全。但是你的架构如果想说是符合领域驱动设计的也不是那么简单,就像画画一样,如果没有抓住重点和主要特征,那么就是画猫为虎了。

    参考

    相关文章

      网友评论

          本文标题:领域驱动设计之二

          本文链接:https://www.haomeiwen.com/subject/zztoyctx.html