DDD-领域驱动设计简谈

看到网上讨论 DDD 的文章越来越多,咱也不能甘于人后啊,以下是我对 DDD 的个人理解,短小精悍,不喜忽喷。


解决什么问题

传统模式,产品评审结束,开发人员就凭经验拆分模块,设计数据结构,然后写业务逻辑实现功能。问题在于,不同人的经验、理念不一样,同样的产品需求,最终的技术实现也会不一样;就算是同一个人,可能不同时候接手同样的需求,也会出来不同的设计。究其原因,很多细节之处都是拍脑袋或按个人喜好,或以无所谓的心态处理了,得出的自然是各式各样的结果。往往这些结果是无法令人满意的,这又触发了重构的冲动,然而由于没有一套标准的原则和方法论,所谓的重构只不过是周而复始,盲人探路。

DDD(领域驱动设计)的出现,犹如黑暗中的灯塔,点燃了希望,指引了方向!

其实在传统模式中,我们已经有领域概念了,因为领域概念是天然的,自然地充斥在需求的各个角落。在设计数据库的时候,开发人员肯定是以自己经验,按领域模型构建表结构的。比如用户表、订单表、订单子表等等,这里的用户、订单、订单子项就是领域模型。但领域驱动到此为止,一旦数据库设计完毕,以数据为驱动的开发模式就粉墨登场并贯穿整个项目周期,所有操作都开始围绕数据库作增删改查。到后期数据量大起来,业务外拓,怎么迭代、怎么重构又是公说公有理婆说婆有理,一团浆糊。可能每个人说的都有道理,各自的方案都是可行的,但问题就出在都有道理上,谁也说服不了谁。于是我们迫切地需要一套方法,统一思路,统一方向,不同的人借助它都能设计出较为合理的架构,且相互之间可以认同,就算有调整也是细节而非大的层面。

DDD 就是这样一套方法,如果高内聚、低耦合是理想的架构,那么 DDD 就是为了实现它形成的一套方法论。它作用于需求分析、产品分解、架构设计、业务编写等项目环节,开发人员至少从产品分解环节就要介入。借助 DDD,你会发现混沌迷蒙的代码世界从来没有如此清晰,一条康庄大道在你眼前铺开,一路延伸到那看不见的远方。

概念

首先来看下领域模型的四个概念:

值对象:不可变,意即改变其状态等于是得到了一个新的值对象。外部不是以引用去引用值对象(好怪),而是直接使用它本身。(题外话:C# 9 引入的record有一点值对象的意思; ef core 中新引入的 Owned Entity Types 即是值对象在 ORM 中的技术实现)

实体:有状态(即有生命周期),有标识符(比如 id)。

聚合:聚合是业务和逻辑紧密关联的实体和值对象组合而成,聚合是数据修改和持久化的基本单元。

聚合根:也是实体,同时是聚合的管理者。在聚合内部,负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑;在聚合之间,它是聚合对外的接口人,以聚合根id的方式接受外部请求和任务,实现上下文中的聚合之间的业务协同。

DDD 中的领域模型是充血模型

举例

订单有待付款、已付款、已完成等状态,是实体;

订单还有订单子项集合(每一项对应一个商品),每个订单子项可以增减数量(也即改了属性,但还是那个订单子项),所以也是实体;

订单有收货地址,收货地址由省、市、区、具体住址组成,各部分可独立设置,当修改了其中一项,自然就是一个新地址了,所以收货地址是值对象;

订单子项和收货地址依赖于订单,不能独立存在,外部自然也不能绕过订单直接操作到它们,因此订单、订单子项、收货地址可作为一个聚合,订单是聚合根。

有同学会说,不对啊,收货地址在我的模块里是可以修改维护的呀,修改之后记录还是原来的记录(同个对象),只是内容不一样呀(状态变化),按你的说法,收货地址应该是实体才对。——这就是不同的限界上下文导致同样的业务概念表现为不同的领域模型,甚至在不同的聚合中也可以不同——假设订单引用了收货地址(用户选择了收货地址列表中的第一条北京王府井),如果此时有个操作更改了该收货地址(用户修改了他的第一条收货地址,从北京王府井改为杭州延安路),那么原来已确定的订单地址会跟着变吗?显然不会。这就是值对象的概念。其实值对象和实体同我们熟悉的值类型引用类型表现行为是差不多的。

对于值对象以及其它所有概念,我们要理解,而不是刻意套用。

我们再来想,购物车是否可以划入订单聚合里,毕竟感觉上购物车就是为订单服务的。这里有个简单的判断准则:领域模型是否可以独立访问,是就是聚合。如果没有订单,购物车是可以存在的;反之,用户可以直接下单,而不需要先把商品加入到购物车;所以购物车和订单分属两个聚合。

当在编码过程中发现单次请求涉及到一个服务中的多个聚合操作,这肯定是有问题的。要么是前端业务操作未按照领域规划拆解,要么是产品将原本属于同一聚合的业务过度拆分了。


DDD 是以高内聚、低耦合为指向的。可以说,这两者是绝大多数架构水平的评判标准,自然也是 DDD 的理论基础。

低耦合:模块之间不依赖对方的具体实现。我们熟悉的面向接口编程IOC等机制就是为了贯彻它而来的,曾经流行一时的几十种面向对象设计模式大多也是为了达到低耦合的目的。

高内聚:模块只负责自己应该负责的职责。高内聚与低耦合关注点不同,它是划分模块职责的原则。

一般来讲,高内聚、低耦合是相辅相成的,高内聚决定了必须低耦合(A不能直接调用B,否则A可以行使B的职责),低耦合要求高内聚(既然A、B互不关联,那必然职责得是分离的)。

许多人对它们区分不清,特别是高内聚(毕竟低耦合一直被强调),这里简单说明:比如 A、B 能否直接相互依赖,这是低耦合的考量;A、B 是否能整合成C,或者A是否需要拆分为 C、D,这是高内聚的考量。领域分析时,高内聚会考虑多一点,构建代码时,就要考虑这些职责不同的模型如何协同工作,也即如何耦合到一起。

DDD 提出了两种模式:

领域服务:当领域中的某个操作过程或转换过程不是实体或值对象的职责时,此时我们便成该将该操作放在一个单独的接口中,即领域服务。 领域服务是无状态的。一种情况是当领域层需要外部依赖(比如根据异步结果决定是否更改领域状态,个人倾向于这部分逻辑置于应用层;如果需要防止领域知识泄漏,并将所有领域逻辑保留在领域模型边界内,那么这部分逻辑可置于领域服务),或一个业务需要多个聚合参与时,参看领域服务与应用服务的区别

领域事件:一般指子域内部事件。当A模块执行完自己的操作后,触发事件,任何监听该事件的模块开始执行自己的操作。

这两种模式又引出了CQRS的概念,如此又可以自然地去考量基础层数据源的划分和同步方案……

上文提到的限界上下文(BC):系统内部按照不同业务目的进行划分的模块。这里等同子域的概念,子域属于业务范畴,而BC就触及到服务的领域了。整个领域可以拆分为多个子域,子域又可再往下细分颗粒度更小的子域,最终层的每个子域,对应一个微服务(最小颗粒度的BC对应的服务)。
子域又分核心域支撑域通用域,这又是 DDD 创造的一些概念,目的仍是为了按一定特征划分业务关系,千万不要觉得这是什么高深术语。

另外提一嘴,微服务之间如果通过 SDK/API 方式互相调用的话,首先要判断是否子域划分得有问题,一个业务不应该强关联多个微服务;对于必须跨服务执行的情况,除直接调用外,也可以考虑事件总线的方案,但是否值得这么做需要考量。一般事件总线用于弱关联服务之间,比如订单服务、消息推送服务,一旦有顾客下单,马上给商家推送消息,它们之间虽然有顺序关系,但上游服务并不关心下游服务是否执行到位;对于强关联服务,要考虑事务的最终一致性

相关资料

如何运用领域驱动设计 - 值对象 (文中说的尽量避免使用基元类型不敢苟同,属性若是基元类型就能表示清楚且满足功能需求的没必要非得封装为值类型,而且最后一层的属性肯定只能是基元类型)
DDD之4聚合和聚合根
MASA Framework - DDD设计(1)
eShopOnContainers 知多少8:Ordering microservice
阿里技术专家详解DDD系列 第三讲 - Repository模式

posted @ 2022-02-21 14:28  莱布尼茨  阅读(1236)  评论(2编辑  收藏  举报