美文网首页
项目终于用上了 DDD 领域驱动,太强了!

项目终于用上了 DDD 领域驱动,太强了!

作者: Java程序员YY | 来源:发表于2023-05-08 10:13 被阅读0次

    在公司对支付业务、结算业务、资金业务使用DDD进行领域建模的两年,得到了许多好评,也面对过不少质疑,总体来说还是能收获不少,这对团队成员理解业务起着很大作用。近半年一直在研究DDD的落地实战,如今已修得阶段性成果,迫不及待与大家分享我的落地经验。

    DDD分为战略设计战术设计。一般来说,领域建模是属于战略层的,而DDD工程落地是属于战术层的,两者是否结合使用,视实际情况而定,比如传统的MVC架构也能使用DDD进行领域建模,DDD架构最好是先做DDD领域建模。

    最新上线的一个微服务——内部交易中心,我们使用了DDD架构来落地,希望看完对大家有启发。

    工程架构分层理论

    在工程落地之前,我们有必要先了解下主流的工程架构或架构思想都有哪些,对这些理论有所了解的,也可以直接跳过看下一个部分。

    1、经典DDD四层架构

    在该架构中,上层模块可以调用下层模块,反之不行。即:

    • Interface ——> application | domain | infrastructure
    • application ——> domain | infrastructure
    • domain ——> infrastructure

    分层作用:

    • 用户界面层/表现层:负责向用户显示解释用户命令
    • 应用层:定义软件要完成的任务,并且指挥协调领域对象进行不同的操作。该层不包含业务领域知识
    • 领域层/模型层:系统的核心,负责表达业务概念,业务状态信息以及业务规则。即包含了该领域(问题域)所有复杂的业务知识抽象和规则定义。该层主要精力要放在领域对象分析上,可以从实体,值对象,聚合(聚合根),领域服务,领域事件,仓储,工厂等方面入手
    • 基础设施层:一是为领域模型提供持久化机制,当软件需要持久化能力时候才需要进行规划;二是对其他层提供通用的技术支持能力,如消息通信,通用工具,配置等的实现;

    2、整洁架构思想

    整洁架构(Clean Architecture)是由Bob大叔在2012年提出的一个架构模型,顾名思义,是为了使架构更简洁。

    依赖规则:用一组同心圆来表示软件的不同领域。一般来说,越深入代表你的软件层次越高。外圆是战术是实现机制,内圆的是核心原则。

    这条规则规定软件模块只能向内依赖,而里面的部分对外面的模块一无所知,也就是内部不依赖外部,而外部依赖内部。同样,在外面圈中使用的数据格式不应被内圈中使用,特别是如果这些数据格式是由外面一圈的框架生成的。

    这样做的最大好处是当系统的外部模块不得不改变时(比如,替换已有的过时的数据库系统),系统的内层模块不需要做任何改变。

    3、六边形架构

    六边形架构(Hexagonal Architecture),又叫做端口适配器模式,是由Alistair Cockburn在2005年提出的。

    六边形架构将系统分为内部(内部六边形)和外部,内部代表了应用的业务逻辑,外部代表应用的驱动逻辑、基础设施或其他应用。内部通过端口和外部系统通信,端口代表了一定协议,以API呈现。

    一个端口可能对应多个外部系统,不同的外部系统需要使用不同的适配器,适配器负责对协议进行转换。这样就使得应用程序能够以一致的方式被用户、程序、自动化测试、批处理脚本所驱动,并且,可以在与实际运行的设备和数据库相隔离的情况下开发和测试。

    4、菱形架构

    作用于限界上下文的菱形对称架构从领域驱动设计分层架构与六边形架构中汲取了营养,通过对它们的融合形成了以领域为轴心的内外分层对称结构。

    内部以领域层的领域模型为主,外部的网关层则根据方向划分为北向网关南向网关。通过该架构,可清晰说明整个限界上下文的组成:

    • 北向网关的远程网关
    • 北向网关的本地网关
    • 领域层的领域模型
    • 南向网关的端口抽象
    • 南向网关的适配器实现

    限界上下文以领域模型为核心向南北方向对称发散,从而在边界内形成清晰的逻辑层次,前端UI并未包含在限界上下文的边界之内。每个组成元素之间的协作关系表现了清晰直观的自北向南的调用关系。

    5、CQRS

    CQRS(Command Query Responsibility Segregation)意为命令查询职责分离,它是一种与领域驱动设计 (DDD) 和事件溯源相关的架构模式。Greg Young在2010年创造了这个术语,CQRS的内容基于Bertrand Meyer的CQS设计模式。

    CQRS架构将写入和读取分开,它提出了单独的 API,一个专用于更改应用程序状态的命令路由,另一个专用于返回有关应用程序状态信息的查询路由。

    工程架构分层设计

    基于各个架构有其自己的优缺点,我们结合公司的现状,取其长避其短,融合一套适合自己的架构。

    • 以经典DDD四层架构为骨架,其他优秀架构思想作指导
    • CQRS命令/查询职责分离,应用到DDD应用层,处理复杂操作/复杂查询
    • 整洁架构应用到DDD领域层与基础设施层,接口与实现拆到不同层,把技术代码与业务代码分离
    • 菱形架构指导我们,内部以领域层的领域模型为主,向南北两个方法发散——北向网关(领域层以上)提供本地网关(如Controller、MQListener)与远程网关(如API包);南向网关(领域层以下)负责端口抽象(如仓库接口)与适配器实现(如外部API封装实现)
    • 公司的Base框架在dal包封装了基础CRUD接口,应用到数据访问层内,作为领域层与基础设施层的粘合剂,简化链接

    当然,任何事物有其两面性,融合各个框架后,也有其优缺点——

    优点:通过分离业务与技术代码,有利于业务迭代升级维护;业务驱动而非技术/数据驱动,通过写代码就能积累一定的业务知识;将领域知识和技术知识分类,从而提高代码的可重用性。

    缺点:对从业人员业务分析能力较高,难以从经典MVC架构转变过来;层级较多,写代码前需考虑清楚逻辑应该写在哪一层;规则较多,没有MVC架构灵活,不适用于简单业务系统;学习成本与转移成本比较高,需要对DDD有更好的理解和更长的设计时间(资金组践行DDD领域建模2年)。

    工程代码构建案例

    看代码之前我们先看下领域建模:

    通过领域模型分析,内部交易中心分为内部调货、规则中心、内部出入库、内部销售、内部采购这五大模块,每一个模块对应DDD就是一个聚合,所有聚合形成一个DDD的限界上下文(内部交易上下文),之前的文章提到,限界上下文就是我们划分微服务的一个重要依据。

    接下来,我们结合DDD架构图与领域建模,看看工程代码应该怎么放。

    基于Maven的DDD工程,顶层结构我们按api、service划分为两个module。

    api包的作用:

    • api包的定位是跨服务的顶层契约,service包所有层都可以依赖api包
    • api包定义了对外透出的枚举/常量、入参、出参、API接口等,为了方便使用api类,feign层不作业务划分
    • api包只定义契约不写业务逻辑,避免因业务逻辑变更引发的api包升级

    service包的作用:

    • service包是工程的顶层实现,DDD四层架构在service包体现
    • Application程序入口与DDD的四层处于同一目录

    此外,针对service包还有另一种主流的module划分方式——直接把service包的api、application、domain、infrastructure作为四个独立的module,优点是能通过pom依赖的方式来限制层与层之间的依赖,开发人员能在编码阶段发现依赖问题及时修正,但缺点也明显——不够灵活,工程也会变得较重。

    1、接入层(api)

    接入层又叫用户接入层,主流用interface或api命名,基于包默认按字母排序的原因,我建议使用“api”来命名接入层,但要注意,service包的api层与api包是不同的作用。

    • 接入层是很薄的一层,负责直接对接前端请求或feign实现(facade里的Controller)、数据转换(assembler),入参/出参等契约类(request/response)统一定义在顶层的api包
    • Controller负责对数据做前置校验,具体业务逻辑则交给应用服务或领域服务实现,可直接调用应用服务方法或领域方法
    • 业务划分在接入层不明显,更多是基于前端模块进行划分Controller,且业务复杂时必然存在领域交叉,故facade下没有再细分业务包
    • assembler数据转换负责处理复杂的数据转换,简单的数据转换可显式调用工具类的转换方法

    2、应用层(application)

    应用层主要作用是业务编排、转发、校验等,处理跨聚合、领域事件逻辑,复杂操作/复杂查询也在此层体现(CQRS)。

    • 应用服务AppService是一种简单逻辑封装,接入层无法直接调用领域层拿到结果的,可在此层编排封装聚合方法
    • 应用层可依赖领域层,但不可依赖接入层,所以传参进应用层要么是基础类型,要么在接入层assembler做一层转换,要么入参出参定义在api包
    • 事件一般情况是跨聚合或跨服务的,所以事件定义在应用层,在应用层处理事件的发布/订阅
    • 接入层可直接调用领域层,不经过应用层

    3、领域层(domain)

    领域层或称为模型层,系统的核心,负责表达业务概念、业务状态信息以及业务规则,包含了该领域所有复杂的业务知识抽象和规则定义。

    • 领域层只表达业务,不写技术代码,在业务上不依赖其他层
    • 领域聚合以业务来命名包,聚合内包含该聚合下所有模型(DO对象)、仓库接口、领域服务
    • 领域模型model是领域聚合下的业务核心模型,以XxxDO命名,依旧采用贫血模型,只包含少量原子性操作,不包含跨模型数据处理、持久化操作等
    • 仓库使用repository命名,领域层只定义仓库接口,不写仓库实现
    • 领域工厂factory与设计模式里的工厂模式不同,领域工厂主要负责领域对象的复杂构建,如领域对象生成、属性填充等,由于存在跨聚合的情况,所以factory包并不在聚合内,与领域聚合同层级
    • 外部API接口、外部框架代码做一层浅封装,放在external聚合包下,以ExXxxService命名接口,实现类还是在基础设施层,起接口防腐作用

    因为领域建模最终体现在领域层内,在我们建模时就要考虑领域层的代码如何写。

    • 领域建模时只表达核心属性与核心行为
    • 聚合内跨多个模型的复杂业务逻辑,写在领域服务内
    • 领域模型的方法只写原子性的操作,但不包括CRUD持久化操作

    一些难点:

    • 无法实现模型的“所建即所得”,复杂代码无法通过领域模型的简单几个方法表达完整
    • 模型只能表达核心的业务行为,所谓的充血模型在落地时可能更多地拆分到领域工厂、领域服务、应用服务中实现

    4、基础设施层(infrastructure)

    基础设施层作为工程的基础设施使用,编写与业务无关的代码,如技术框架、工具类,此外还有一个重要的功能,要写仓库的实现类、外部服务的实现类。

    • 基础设施层的仓库(repository)实现了领域层定义的仓库接口,数据访问层(dao)也定义在仓库下,数据库实体(PO对象)定义在entity,以XxxPO或XxxEntity命名,这里遵循了公司框架的命名方式,使用了XxxEntity
    • param是比较特殊的一层,该类一般定义查询数据库的参数。基于公司的Base框架,repository定义接口时依赖了param对象,按道理领域层不应依赖基础设施层(DIP原则),但Param又跟PO对象息息相关,所以把param对象放在了基础设施层
    • 基于Clean架构原则,其他框架性代码、工具类、配置类都放在基础设施层,业务代码与技术代码分离后,万一升级技术代码,对业务代码做最少改动

    我们再来看一下全貌:

    通过实际案例,总结以下重要几点:

    • 领域层是业务最核心的一层,聚合之间的边界需要划分清晰,而接入层、应用层涉及跨聚合,基础设施层关注仓储实现与技术框架,所以我们只在领域层划分业务包,对应领域建模按聚合划分边界,并定义领域模型的仓储接口
    • 充血模型建模,贫血模型落地,把核心业务行为按需划分到领域模型(原子性、非持久化)、领域工厂(构建模型)、领域服务(跨模型)或应用服务(跨聚合、事件)中
    • 使用接口分离业务代码与技术性代码,当业务迭代时,修改领域层和基础设施层的仓库实现即可;当技术框架升级时,修改基础设施层即可,不至于把业务代码也修改一遍,减少出错成本

    DDD工程落地考虑的是代码的归类划分问题,重点在于业务边界的识别、业务和技术代码的解耦。写代码前需要考虑清楚不同的代码应该写到哪里,结合前人优秀的工程架构思路与公司当前的技术架构,整合一套灵活的、适合我们自己的DDD,不能照搬,更不能为了DDD而DDD。

    疑难分析

    1、用充血模型还是贫血模型?

    其实除了常见的充血模型、贫血模型,还有不常用的失血模型、胀血模型,区别如下:

    • 失血模型:只包含Getter/Setter的纯数据类,一般不会有这种设计
    • 贫血模型:包含模型属性、Getter/Setter与非持久化的原子领域逻辑,持久化逻辑放在业务层(如Service类)
    • 充血模型:比贫血模型多了持久化操作与绝大多数业务逻辑,实例化时会拿到很多不一定需要的关联模型
    • 胀血模型:只有领域对象与DAO两层,在领域逻辑上封装事务

    基于现有的Spring框架,以及个人以往的代码编写经验,在代码落地层面还是以贫血模型进行较恰当。

    2、放应用服务还是领域服务?

    应用服务在应用层,领域服务在领域层,我怎么知道业务代码该放哪里?

    应用服务的作用:

    • 负责展现层与领域层之间的协调,协调业务对象来执行特定的应用程序任务(编排业务)
    • 放相对灵活的代码逻辑,易于编排
    • 操作粒度较大,事务管理在此处理

    领域服务的作用:

    • 负责表达业务概念,业务状态信息以及业务规则,是业务软件的核心
    • 放相对原子性的核心代码,封装性与复用性强
    • 操作粒度较细,不管理事务,领域模型不应该意识到事务的存在

    其实,难点在于识别业务代码,考验我们对业务的理解程度与思考程度,如果可以显然预料到未来会发生明显的变化,则应该在设计之初更灵活地设计好;如果对未来的变化把握并不清晰或不确定,满足当前业务需求即可。

    我们无法避免过度设计还是设计不足,但如果架构合理,代码清晰,改起来成本不会特别大。这里提倡开发者尽量多与领域专家(业务人员或产品经理)沟通,以把握代码未来的走向。

    3、特殊代码如何归类?

    除了常规的简单业务代码,还涉及到复杂业务代码拆分到不同类的问题,最典型的是运用设计模式。

    • 工厂模式:根据不同条件生成相应对象,常见领域工厂、领域服务、应用服务内
    • 策略模式:根据不同条件执行相应逻辑,定义一个策略接口和多个策略实现,常见领域服务、应用服务内
    • 观察者模式:使用发布/订阅模式代替,可运用基础设施层的SpringEvent来解耦代码
    • 责任链模式:拆分复杂业务逻辑到各个责任链类执行,常见领域服务、应用服务内

    原则上,核心逻辑在哪一层拆就放在哪一层,避免代码散落到各处。

    一些经验

    DDD领域建模三大步:划分边界、统一语言、组织模型。

    DDD工程落地四大步:整合框架思想、确定划分思路、模型代码映射、特殊代码归类。

    相关文章

      网友评论

          本文标题:项目终于用上了 DDD 领域驱动,太强了!

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