阅读 507

领域驱动设计(DDD)深入探究

每一个技术和架构的出现都有其历史背景和演进历程;同样,每一个技术和架构也存在其优缺点和适应的业务场景。因此本文从"贫充血模型”以及“分层架构演进“两个点,对DDD的出现背景和演进历程进行了简单分析。 “你说的我都懂,show me your model,show me you code",对于如何进行DDD设计,以及如何进行开发,本文后面也以一个自身业务实例,拿出来与大家交流讨论。

充血领域模型 VS 贫血模型

Anemic model and bulky services

贫血模型是指,其领域对象贫血的模型。在贫血模型中,领域对象仅用作数据载体,没有行为和业务逻辑,业务逻辑通常放在服务、utils和帮助程序等中。这种设计(图左侧显示的三层)是当前大多开发模式。

当我们实现特定的功能时,我们首先讨论将被持久化的类,我们称之为实体。它们在上图的图表中以字母E表示。这些实体实际上是数据库表的Object-Oriented Representation。我们没有在它们内部实现任何业务逻辑。它们唯一的作用是被一些ORM映射到它们的数据库等价物。

当我们的映射实体准备就绪时,下一步是创建用于读写它们的类。就是DAO(数据访问对象)。通常,我们的每个实体都代表不同的业务用例,所以DAO类的数量与实体的数量相匹配。它们不包含任何业务逻辑。DAO类只不过是用于检索和持久化实体的工具。我们可以使用现有的框架来创建它们,比如Hibernate。

在DAO之上的最后一层是我们实现的精髓——Service。服务利用DAO来实现特定功能的业务逻辑。不管功能的数量如何,典型的服务总是执行以下操作:使用DAO加载实体,根据需求修改它们的状态并持久保存它们。Martin Fowler将这种体系结构描述为一系列事务脚本。功能越复杂,加载和持久化之间的操作数量就越多。通常,一些Servcie会使用其他Servcie,导致了代码量和复杂性都增加。

Rich model and thin services

领域驱动设计在代码分层方面提供了完全不同的方法。领域驱动设计模型的通用架构如图中右图所示。

请注意在DDD中的Service层,它比贫血模型中的Service层要薄得多。原因是大多数业务逻辑包含在聚合、实体和值对象中。

Domain层有更多类型的对象。有值对象、实体和对象,它们组成一个Aggragate。Aggragate只能通过标识符连接。它们之间不能共享任何其他数据。

最后一层也比前一层薄。存储库的数量与DDD中的聚合数量相对应,因此使用贫血模型的应用程序比使用富模型的应用程序拥有更多的存储库。

Easier changes and less bugs?

应用领域驱动的设计体系结构给开发人员带来了一些重要的好处。

贫血模型,其实是一种Procedural programming,它虽然比较直接,但是我们很难知道对象的变更过程(为什么要变,变得条件是什么,怎么变得,变成什么样了)。因为业务逻辑都在Service中,而Service是无状态的(没有持有对象状态),这样我们就很难了解对象状态的变更过程,当Service之间存在互相调用时,就就更难理解对象状态的变更过程了。同时,Service中持有大量对象的状态处理逻辑,也会出现很多重复的代码。

由于在实体和值对象上划分对象,我们可以更精确地管理应用程序中新创建的对象。聚合的存在使我们的API变得更简单,而在聚合内部的更改也更容易实现(我们不需要分析我们的更改对Domain的其他部分的影响)。

同时,较少的Dmoain元素之间的连接可以减少在开发和修改代码时产生错误的风险。比如避免一致性问题等等。

参考:Domain-Driven Design vs. anemic model. How do they differ?

领域驱动架构

最经典的,同时用的也是最多的,就是三层模式。三层模式分为UI层,Service层,和Dao层。在这种架构中,业务逻辑被定义在业务逻辑层的Service对象中,至于反映了领域概念的领域对象则被定义为Java Bean,这些 Java Bean并没有包含任何领域逻辑,因此被放在了数据访问层。三层模式架构图如下:

上面提到,这种传统的三成模式,会导致贫血模型。要避免贫血模型,就需要合理地将操作数据的行为分配给领域模型对象(Domain Model),即战术设计中的 Entity 与 Value Object,而不是放到三层模型中的Service中,那么此时架构应该为:

上面的模型中,将业务的行为合理的分配给了领域模型对象(Domain Model),这样可以避免贫血,同时又不会造成业务逻辑层太臃肿。

开发完一个系统,不可能一成不变,都是会不断的更新迭代的,因此需求会不断的更新和变化。但是了,仔细观察,我们会发现变化总是有迹可循的。其一,用户体验、操作习惯的变化,往往会导致系统界面展示的多变;其二,部署平台,组件切换的变化,往往会导致系统底层存储的多变。但总体来说,不管系统前台展示以及底层存储如何变化,系统的核心领域逻辑基本上不会大变。

上面的架构中,领域层依赖了基础设施层,这种耦合会导致领域层变得脆弱,因此我们需要想办法使得领域层更纯粹,更稳定。方法很简单,那就是依赖于抽象,不依赖于具体。因此可以重新设计系统的层次,新的设计如下:

在这种分层模式下,领域对象通过基础设施层抽象的接口进行持久化存储。但是,新增加一个“基础设施层抽象”是否合适了?

从业务角度看,管理对象的生命周期是必须的,访问外部资源却并非必须。只是因为计算机资源不足以满足这种稳定性,才不得已引入外部资源罢了。也就是说,访问这些领域对象属于业务要素,而如何访问这些领域对象(如通过外部资源),则属于具体实现的技术要素。

从编码角度看,领域对象实例的容身之处不过就是一种数据结构而已,区别仅在于存储的位置。领域驱动设计将管理这些对象的数据结构抽象为资源库(Repository)。通过这个抽象的资源库访问领域对象,自然就应该看作是一种领域行为。倘若资源库的实现为数据库,并通过数据库持久化的机制来实现领域对象的生命周期管理,则这个持久化行为就是技术因素。

因此,Repositories应该搬迁至领域层,图中的领域层就不再依赖任何其他层次的组件或类,成为一个纯粹的领域模型,新的架构如下:

上面的架构即是DDD的四层架构最终样子。梳理下各层的指责如下:

展现层

它负责向用户显示信息和解释用户命令,完成前端界面逻辑。这里的用户不一定是使用用户界面的人,也可以是另一个计算机系统。

应用层 它是很薄的一层,负责展现层与领域层之间的协调,也是与其它系统应用层进行交互的必要渠道。它主要负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果的拼装,拼装完领域服务后以粗粒度的服务通过 API 网关向前台应用发布。通过这样一种方式,隐藏了领域层的复杂性及其内部实现机制。 应用层除了定义应用服务之外,在这层还可以进行安全认证,权限校验,持久化事务控制或向其他系统发送基于事件的消息通知。

本层代码主要通过调用领域层服务,完成服务组合和编排形成粗粒度的服务,为前台提供API 服务。本层代码可进行业务逻辑数据的校验、权限认证、服务组合和编排、分布式事务管理等工作。

领域层 它是业务软件的核心所在,包含了业务所涉及的领域对象(实体、值对象)、领域服务以及它们之间的关系,负责表达业务概念、业务状态信息以及业务规则,具体表现形式就是领域模型。领域驱动设计提倡富领域模型,即尽量将业务逻辑归属到领域对象上,实在无法归属的部分则以领域服务的形式进行定义。

本层代码主要实现核心的业务领域逻辑,需要做好领域代码的分层以及聚合之间代码的逻辑隔离

基础设施层 一个系统的基础不仅仅限于对数据库的访问,还包括访问诸如网络、文件、消息队列或者其他硬件设施,因此本层更名为“基础设施层”是非常合理的。它向其他层提供通用的技术能力,为应用层传递消息(API 网关等),为领域层提供持久化机制(如数据库资源)等。

根据依赖倒置原则,封装基础资源服务,实现资源层与应用层和领域层的调用依赖反转,为应用层和领域层提供基础资源服务(如数据库、缓存等基础资源),实现各层的解耦,降低外部资源的变化对核心业务逻辑的影响。

本层主要包括两类适配代码:主动适配和被动适配。主动适配代码主要面向前端应用提供 API 网关服务,进行简单的前端数据校验、协议以及格式转换适配等工作。被动适配主要面向后端基础资源(如数据库、缓存等),通过依赖反转为应用层和领域层提供数据持久化和数据访问支持,实现资源层的解耦。

六边形架构

在DDD四层架构中,采用了依赖倒置原则,领域层通过依赖于抽象接口,从而实现了解耦。同样的思想,如果我们将应用层,也采用依赖导致原则,其实也可以做到解耦。其实可以发现,在分层架构中采用依赖倒置原则应用与所有层,事实上就不存在分层的概念了,无论高层还是底层都是指依赖于抽象,好像把整个分层架构推平了,这就演化出了DDD的六边形架构:

六边形架构将系统分为内部和外部两层六边形,内部六边形代表了应用的核心业务逻辑,外部六边形代表外部应用、驱动和基础资源等。内部通过端口和适配器与外部通信,对应用以API主动适配的方式提供服务,对资源通过依赖反转被动适配资源的形式呈现。一个端口可能对应多个外部系统,不同的外部系统使用不同的适配器,适配器负责对协议进行转换。这就使得应用程序能够以一致的方式被用户、程序、自动化测试、批处理脚本所驱动。

领域驱动概念解惑

DDD 设计包括战略设计和战术设计两个部分。在战略设计阶段主要完成领域建模和服务地图。在战术设计阶段,通过聚合、实体、值对象以及不同层级的服务,完成微服务的建设和实施。通过 DDD 可以保证业务模型、系统模型、架构模型以及代码模型的一致。

1、什么是领域服务?

当领域中,某个操作过程或转换过程不是实体或者值对象的指责时,我们便应该将操作放在一个单独的接口中,即领域服务。领域服务跟其他领域模型一样,主要关注于特定某个领域的业务。

领域服务与应用服务区别?

答:从指责来看,应用服务不处理业务逻辑,但是领域服务包含业务逻辑;从相对角色来看,应用服务是领域服务的客户方。

领域服务会操作多个领域对象包括操作多个聚合吗?

答:领域服务有可能在单个原子操作中处理多个领域对象。

2、Repository作用是什么?和DAO的关系?

DAO主要是从数据库表的角度来看待问题的,操作的对象是DO类,并且提供CRUD操作,是一种面向数据处理的风格(事务脚本);

Repository对应的是Entity对象读取储存的抽象,在接口层面做统一,不关注底层实现。比如,通过 save 保存一个Entity对象,但至于具体是 insert 还是 update 并不关心。Repository的具体实现类通过调用DAO来实现各种操作,通过Builder/Factory对象实现AccountDO 到 Account之间的转化。

3、什么是防腐层,作用是什么?

阿里技术专家详解DDD系列 第二弹 - 应用架构 这篇文章中对于防腐层(Anti-Corruption Layer,ACL)的定义很好,如下:

很多时候我们的系统会去依赖其他的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被”腐蚀“。这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。

ACL不仅仅只是多了一层调用,在实际开发中ACL能够提供更多强大的功能:

  • 适配器:很多时候外部依赖的数据、接口和协议并不符合内部规范,通过适配器模式,可以将数据转化逻辑封装到ACL内部,降低对业务代码的侵入。在这个案例里,我们通过封装了ExchangeRate和Currency对象,转化了对方的入参和出参,让入参出参更符合我们的标准。
  • 缓存:对于频繁调用且数据变更不频繁的外部依赖,通过在ACL里嵌入缓存逻辑,能够有效的降低对于外部依赖的请求压力。同时,很多时候缓存逻辑是写在业务代码里的,通过将缓存逻辑嵌入ACL,能够降低业务代码的复杂度。
  • 兜底:如果外部依赖的稳定性较差,一个能够有效提升我们系统稳定性的策略是通过ACL起到兜底的作用,比如当外部依赖出问题后,返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑一般都比较复杂,如果散落在核心业务代码中会很难维护,通过集中在ACL中,更加容易被测试和修改。
  • 易于测试:类似于之前的Repository,ACL的接口类能够很容易的实现Mock或Stub,以便于单元测试。
  • 功能开关:有些时候我们希望能在某些场景下开放或关闭某个接口的功能,或者让某个接口返回一个特定的值,我们可以在ACL配置功能开关来实现,而不会对真实业务代码造成影响。同时,使用功能开关也能让我们容易的实现Monkey测试,而不需要真正物理性的关闭外部依赖。

传统开发模式的重构

阿里技术专家详解DDD系列 第二弹 - 应用架构 ,这篇文章中通过一个转账的案例很好的讲解了传统的开发模式可能存在的问题,以及重构方案,有兴趣的可以去看看,我这里简单阐述一下它的主要思想和内容。

可能存在的问题

对于一个复杂的业务,按照传统三层开发可能存在的问题如下:

可维护性能差

  • 数据结构的不稳定性:DO类是一个纯数据结构,映射了数据库中的一个表。这里的问题是数据库的表结构和设计是应用的外部依赖,长远来看都有可能会改变,比如数据库要做Sharding,或者换一个表设计,或者改变字段名。
  • 第三方服务依赖的不确定性:第三方服务的变化:轻则API签名变化,重则服务不可用需要寻找其他可替代的服务。在这些情况下改造和迁移成本都是巨大的。同时,外部依赖的兜底、限流、熔断等方案都需要随之改变。
  • 中间件更换:今天我们用Kafka发消息,明天如果要上阿里云用RocketMQ该怎么办?后天如果消息的序列化方式从String改为Binary该怎么办?如果需要消息分片该怎么改?

可拓展性差

可扩展性 = 做新需求或改逻辑时,需要新增/修改多少代码

  • 业务逻辑无法复用:数据格式不兼容的问题会导致核心业务逻辑无法复用。每个用例都是特殊逻辑的后果是最终会造成大量的if-else语句,而这种分支多的逻辑会让分析代码非常困难,容易错过边界情况,造成bug。
  • 逻辑和数据存储的相互依赖:当业务逻辑增加变得越来越复杂时,新加入的逻辑很有可能需要对数据库schema或消息格式做变更。而变更了数据格式后会导致原有的其他逻辑需要一起跟着动。在最极端的场景下,一个新功能的增加会导致所有原有功能的重构,成本巨大。

可测试性能差

可测试性 = 运行每个测试用例所花费的时间 * 每个需求所需要增加的测试用例数量

  • 设施搭建困难:当代码中强依赖了数据库、第三方服务、中间件等外部依赖之后,想要完整跑通一个测试用例需要确保所有依赖都能跑起来,这个在项目早期是及其困难的。在项目后期也会由于各种系统的不稳定性而导致测试无法通过。
  • 运行耗时长:大多数的外部依赖调用都是I/O密集型,如跨网络调用、磁盘调用等,而这种I/O调用在测试时需要耗时很久。另一个经常依赖的是笨重的框架如Spring,启动Spring容器通常需要很久。当一个测试用例需要花超过10秒钟才能跑通时,绝大部分开发都不会很频繁的测试。
  • 耦合度高:假如一段脚本中有A、B、C三个子步骤,而每个步骤有N个可能的状态,当多个子步骤耦合度高时,为了完整覆盖所有用例,最多需要有N N N个测试用例。当耦合的子步骤越多时,需要的测试用例呈指数级增长。

重构原则

软件设计的一般遵循以下原则:

  • 单一性原则(Single Responsibility Principle):单一性原则要求一个对象/类应该只有一个变更的原因。但是在这个案例里,代码可能会因为任意一个外部依赖或计算逻辑的改变而改变。
  • 依赖反转原则(Dependency Inversion Principle):依赖反转原则要求在代码中依赖抽象,而不是具体的实现。在这个案例里外部依赖都是具体的实现,比如YahooForexService虽然是一个接口类,但是它对应的是依赖了Yahoo提供的具体服务,所以也算是依赖了实现。同样的KafkaTemplate、MyBatis的DAO实现都属于具体实现。
  • 开放封闭原则(Open Closed Principle):开放封闭原则指开放扩展,但是封闭修改。在这个案例里的金额计算属于可能会被修改的代码,这个时候该逻辑应该需要被包装成为不可修改的计算类,新功能通过计算类的拓展实现。

重构方案

第一步:抽象数据存储层。

将Data Access层做抽象,降低系统对数据库的直接依赖。一般方法如下: 1)新建实体对象:一个实体(Entity)是拥有ID的域对象,除了拥有数据之外。Entity和数据库储存格式无关,在设计中要以该领域的通用严谨语言(Ubiquitous Language)为依据。 2)新建对象储存接口类AccountRepository:Repository只负责Entity对象的存储和读取,而Repository的实现类完成数据库存储的细节。通过加入Repository接口,底层的数据库连接可以通过不同的实现类而替换。

第二步:抽象第三方服务

类似对于数据库的抽象,所有第三方服务也需要通过抽象解决第三方服务不可控,入参出参强耦合的问题。这一步一般就是说建立一个防腐层ACL。

第三步:封装业务逻辑

将业务逻辑由散落在各个Servie中改为封装在Domain Entity和Domain Service中。

领域驱动落地实现

本文选取一个相对简单的业务场景来说明,因为业务太复杂一时间很难说清楚,同时大家理解起来也费劲。需求场景为:建一个集商户管理,需求管理为一体的数据流服务,帮助我们管理跟踪生产情况,具体项为:

1)每一个商户下可以建多个需求; 2)需求确认后,会生成多个订单; 3)每个订单,会进行多次交付; 4)每一次交付都需要验收是通过还是不通过。 5)需求,交付以及验收,如果需要都可以提供附件进行说明(附件信息字段不存在修改,如果修改就是上传新的);

过程分解+对象建模

过程分解,就是对需求进行拆分,拆分成一个个Step;对象建模,就是对拆分后的需求,提取对象和操作,进行对象建模。

过程分解一般是自上而下,从一个大的需求,逐步拆分;而对象建模,则是一般从下而上,对于每一个小的Step进行建模和对象分析。因此整个过程是是一个上下结合的过程,相辅相成的,上面的分析可以帮助我们更好的理清模型之间的关系,而下面的模型表达可以提升我们代码的复用度和业务语义表达能力。

参考:一文教会你如何写复杂业务代码

怎们确定领域对象,聚合,实体和值对象

多个对象是否应该作为一个聚合,判断标准:

  • 名字相关性
  • 生命周期性
  • 强一致相关性
  • 内存开销和复杂度

一般确定一个聚合就是通过上面的标准确定。首先,对需求进行拆分,最后分为多个操作,如果多个操作存在名字相关性,即都跟一个领域对象相关,那么这个领域对象可能就是一个待选聚合。复杂业务逻辑这么做是很有好处的,简单的业务逻辑倒是不需要这个步骤,因为很容易就能知道领域对象。

至于其中一个聚合中包含哪些实体,以及其中的实体是否应该单独出去作为一个新的聚合的标准,这个首先我们需要关注涉及到的对象的生命周期性,两者是否生命周期息息相关(离开依赖的对象 ,就失去存在的意义了),如果是那么建议放在一起,不是就参考其他标准判断。

对于聚合,有一个很好的特性,就是聚合是一个整体,操作都是以一个聚合为单位,那么这样就可以做到聚合内保证强一致性,因此如果需要保证领域对象强一致性,那么建议作为一个聚合,否则参考其他标准判断。

聚合是一个整体,可以保证强一致性,那么是不是大聚合就好了?并不是,一个大的聚合,内存开销就大;同时它需要保证最终一致性的同时,因此并发能力下降,性能会有影响。

说到内存开销了,其实内存开销主要是实体带来的,因为实体是有可能修改的,因此每次加载聚合,我们那都需要把实体加载出来,如果有很多实体,那么内存开销肯定很大;但是值对象则不一样,值对象不会被修改,因此加载时,我们不应把值对象完整加载出来。因此,建议优先使用值对象。

那么这里,我们也讨论下,在一个聚合里,聚合内的对戏那个哪些应该成为实体,哪些应该是值对象?我觉得最重要的一条就是这个对象是否可变,如果可变那么就需要是一个实体,如果不可变,那么就设计成一个值对象吧。

基于以上标准,我的需求管理最终的设计如下:

代码开发

系统采用的DDD架构为上面提到的四层架构:

代码目录结构:

GO语言怎么进行EventSourcing开发,后续文章再展开讲!

后记

本文是基于自身实际DDD开发的一些感悟,同时阅读了网上大量相关文章后做的一个总结整理,希望对大家有帮助!