CodeSnippet.Cn
代码片段
Csharp
架构设计
.NetCore
西班牙语
kubernetes
MySql
Redis
Algorithm
Ubuntu
Linux
Other
.NetMvc
VisualStudio
Git
pm
Python
WPF
java
Plug-In
分布式
CSS
微服务架构
JavaScript
DataStructure
Shared
这是篇关于DDD的文章,我也不知道叫啥好(三)
0
架构设计
小笨蛋
发布于:2022年05月29日
更新于:2022年05月31日
152
#custom-toc-container
书接上回:[这是篇关于DDD的文章,我也不知道叫啥好(一)](https://www.codesnippet.cn/home/list/149 "这是篇关于DDD的文章,我也不知道叫啥好(一)") 书接上回:[这是篇关于DDD的文章,我也不知道叫啥好(二)](https://www.codesnippet.cn/home/list/150 "这是篇关于DDD的文章,我也不知道叫啥好(二)") ### 9.聚合 #### 1.引言 聚合,最初是UML类图中的概念,表示一种强的关联关系,是一种整体与部分的关系,且部分能够离开整体而独立存在,如车和轮胎。 在DDD中,聚合也可以用来表示整体与部分的关系,但不再强调部分与整体的独立性。聚合是将相关联的领域对象进行显示分组,来表达整体的概念(也可以是单一的领域对象)。比如将表示订单与订单项的领域对象进行组合,来表达领域中订单这个整体概念。 我们知道,领域模型是由一系列反映问题域概念的领域对象(实体和值对像)组成,聚合正是应用在领域对象之上。如果要正确应用聚合,我们首先得理清领域对象间的关联关系。 #### 2. 梳理关联关系 在设计领域模型的初期,我们习惯专注于领域中的实体和值对象,而忽略领域对象之间的关联关系,以至于我们会基于现实业务场景或数据模型来建立关联关系。这样就会引入大量不必要的关联,比如下图: ![图片alt](/uploads/images/20220529/102453-f383745639ff474aa20d282b75d557c6.png '代码片段:Www.CodeSnippet.Cn') 存在大量关联关系的复杂领域模型 然而图中的关联关系都是必要的吗?我想未必。这样的关联关系,加大了实现领域模型的技术难度。 当我们建立对象的关联关系时,思考以下问题: 1. 这个关联关系的作用时什么? 2. 谁需要这个关联关系去发挥作用? 而如何简化关联呢? 1. 基于**业务用例**而非现实生活建立必要的关联 2. 减少不必要的关联 3. 将双向的关联转换为单向关联 如果遵从这个原则,那我们的领域模型将会是这样的: ![图片alt](/uploads/images/20220529/102739-0a7aef05d1b04533a9cfd69f05793d8c.png '代码片段:Www.CodeSnippet.Cn') 基于必要的关联关系的领域模型 领域对象间清晰的关联关系,能够清晰反映领域概念,便于我们设计出比较理想的领域模型。理清了领域对象间的关联关系,我们下面来应用聚合。 #### 3. 应用聚合 领域对象不是孤立存在的,往往几个对象的组合才能表示一个完整的概念,如上文所说的订单和订单项。那如何组合对象呢?也就是我们本文的主题。 **聚合是领域对象的显示分组,旨在支持领域模型的行为和不变性,同时充当一致性和事务性边界。** 这句话涉及到几个概念,我们来拆解一下: 1. 领域对象的显示分组 2. 领域行为和不变性 3. 一致性和事务性边界 其中我们需要澄清下**领域不变性**: > 领域不变性指的是必须遵守的陈述或规则。换句话说,就是领域内我们关注的业务规则。比如,订单必须具有唯一订单编号、订单日期;订单必须冗余商品的基本信息(名称、价格、折扣);订单至少有一个商品,删除商品时,订单项需要一并删除;等等。 前两句话综合来说,就是聚合通过对领域对象的封装来体现领域中的业务规则。 而边界的目的是分离聚合内外,聚合内通过事物来保证强一致性。 总而言之,**聚合不仅仅是简单的对象组合,其主要的目的是用来封装业务和保证聚合内领域对象的数据一致性。** 一致性和事务性边界,又如何理解呢? 一致性是指数据一致性,事务性指的数据库的ACID原则。下面我们来着重介绍下。 #### 4.一致性边界 为了确保系统的可用性和可靠性,我们必须保证数据的一致性。 > 订单支付成功后,订单状态要更新为已支付状态,且现有库存要根据订单中商品实际销售数量进行扣减。 下面我们就以这个案例,来分析说明。 ##### 4.1.事务一致性 针对这个用例,传统的做法就是,在一个事务中,去更新订单状态和扣减库存。这样似乎满足了业务场景需求,但是我们不得不考虑另外一个问题——并发冲突。比如,在更新订单的同时,商城来了一批货,要进行库存更新,这个时候就存在潜在的冲突,而问题可能表现为数据库级别的阻塞或更新失败(由于悲观并发),如下图: ![图片alt](/uploads/images/20220529/102935-b22375fc61d24ac4950176370684ec39.png '代码片段:Www.CodeSnippet.Cn') 大的事务边界导致并发冲突 这个并发问题我们该如何解决呢? 首先我们要分析问题的原因,这个用例陈述了具体的业务规则。我们错误的将业务涉及到的所有领域对象都放到了一个事务性边界中去了。其实这个用例涉及到三个子域,销售、商品、库存子域。从领域不变性的角度来看,我们应该维护各自子域内业务规则的不变性,而不是为了业务场景实现一概而论。按照这个思想,我们把订单、商品、库存拆分成三个独立的聚合,如下图所示。 ![图片alt](/uploads/images/20220529/103013-83f5eef8f5694ca88548d0d6851acd8f.png '代码片段:Www.CodeSnippet.Cn') 根据领域不变性设置事务边界 从图中我们可以看出,每个聚合都有自己的事务一致性边界。也就是说这三个聚合分别在不同的事务中维持自己的不变性,也就是说聚合是用来维护**内部事务一致性**。那针对以上用例,明显需要跨域多个聚合,我们又该如何保证一致性呢?因为我们不能在一个事务中更新多个聚合,所以我们只能实现最终一致性。 ##### 4.2. 最终一致性 最终一致性的实现原理是借助领域事件来完成事务的拆分,如下图所示。 ![图片alt](/uploads/images/20220529/103059-6db570d97b6f45a2ae2d9867709ba9b0.png '代码片段:Www.CodeSnippet.Cn') 通过领域事件实现最终一致性 而针对我们的用例,在更新订单支付状态时,发布一个订单已支付的领域事件,库存聚合订阅处理这个事件,即可完成库存的更新。事务拆分如下图: ![图片alt](/uploads/images/20220529/103118-5c01170d44a849bc838e3ac866808130.png '代码片段:Www.CodeSnippet.Cn') 库存聚合最终一致性 ##### 4.3. 特殊情况 凡事没有绝对,在一个聚合中仅修改一个聚合是最佳方法。但有时候,在一个事务中更新多个聚合也是可行的,这需要结合具体场景区别对待。另外还有一点需要澄清,以上使用一致性的目的,主要是针对聚合的修改。在一个事务中加载和创建多个聚合是没有问题的,因为并不会导致并发冲突。 #### 5. 聚合的设计 根据上面的阐述:**聚合不仅仅是简单的对象组合,其主要的目的是用来封装业务和保证聚合内领域对象的数据一致性。** 那聚合设计时要遵循怎样的原则呢? 1. 遵循领域不变性 2. 聚合内实现事务一致性,聚合外实现最终一致性 一个事物一次仅更新一个聚合。当业务用例要跨域多个聚合时,使用领域事件进行事务拆分,实现最终一致性。 3. 基于业务用例而非现实生活场景 4. 避免成为集合或容器 对聚合的一大误解就是,把聚合当作领域对象的集合或容器。当发现这个征兆时,你要考虑你聚合是否需要改造。 5. 不仅仅是HAS-A关系 聚合不是简单的包含关系,要确定包含的领域对象是否为了满足某个行为或不变性。 6. 不要基于用户界面设计聚合 聚合不应该根据UI界面的需求进行设计。而应该通过加载多个聚合数据映射到UI展示需要的视图模型中。 7. 创建具有唯一标识的聚合根 聚合根作为聚合的网关,通过聚合根完成聚合中领域对象的持久化和检索。 8. 优先使用值对象 聚合根内的其他领域对象优先设计成值对象 9. 使用ID关联,而非对象引用 对象引用不仅会导致聚合边界的模糊,而且会导致延迟加载的问题。 10. 通过唯一标识引用其他聚合 聚合边界之外的对象不能持有聚合内部对象的引用;聚合内部的领域对象可以持有其他聚合根的引用。 11. 避免在聚合内使用依赖注入 对于依赖的对象,我们应该在调用聚合方法之前查找获取并通过参数传递。可以在应用服务中通过依赖注入资源库或领域服务获取聚合依赖的对象,然后传入聚合。 12. 使用小聚合 通常,较小的聚合使系统更快且更可靠,因为更少的数据传输以及更少的并发冲突。 **大聚合会影响性能**:聚合的每一个成员都增加了从数据库加载和保存到数据库的数据量,直接影响到性能。 **大聚合容易导致并发冲突**:大的聚合可能有多个职责,意味着它涉及到多个业务用例。我们可以量化一个聚合涉及到的业务用例数,数量越大,设计的聚合边界越应该被质疑,尝试将其细化拆解成小聚合。 **大聚合扩展性差**:聚合的设计要关注可扩展性。大聚合可能会跨越多个数据库表或文档,这就在数据库级别形成了耦合,它将阻碍你对数据子集进行数据迁移。同时,在业务改变时,大聚合不能很好的适应变化。 #### 6.最后 聚合是一个复杂的概念,其正确应用的关键是领域对象间关联关系的把握和领域不变性的理解。其实现的难点在于一致性的维护上:聚合内实现事务一致性,聚合外实现最终一致性。聚合的设计是一个持续性的活动,不可能在初始阶段就能设计出完美的聚合,我们应该根据对领域知识的深入和经验的积累持续改进聚合的设计。 ------------ ### 10.工厂 #### 1.引言 在针对大型的复杂领域进行建模时,聚合、实体和值对象之间的依赖关系可能会变得十分复杂。在某个对象中为了确保其依赖对象的有效实例被创建,需要深入了解对象实例化逻辑,我们可能需要加载其他相关对象,且可能为了保持其他对象的领域不变性增加了额外的业务逻辑,这样即打破了领域的单一责任原则(SRP),又增加了领域的复杂性。 那如何去创建复杂的领域对象呢?因为复杂的领域对象的生命周期可能需要协调才能进行创建。 这个时候,我们就可以引入创建类模式——工厂模式来帮忙,将对象的使用与创建分开,将对象的创建逻辑明确地封装到工厂对象中去。 #### 2. DDD中的工厂 我们有必要先理清工厂和工厂模式。 DDD中工厂的主要目标是隐藏对象的复杂创建逻辑;次要目标就是要清楚的表达对象实例化的意图。 而工厂模式是计模式中的创建类模式之一。借助工厂模式我们可以很好实现DDD中领域对象的创建。 而针对工厂模式的实现主要有四种方式: - 简单工厂:简单实用,但违反开放封闭; - 工厂方法:开放封闭,单一产品; - 抽象工厂:开放封闭,多个产品; - 反射工厂:可以最大限度的解耦。 #### 3.封装内部结构 当需要为聚合添加元素时,我们不能暴露聚合的结构。我们以添加商品到购物车为例,来讲解如何一步一步的使用工厂模式。 一般来说,添加到购物车需要几个步骤: 1. 加载用户购物车 2. 获取商品税率 3. 创建新的购物车子项 相关的应用层代码如下: ```csharp namespace Application { public class AddProductToBasket { // ...... public void Add (Product product, Guid basketId) { var basket = _basketRepository.FindBy (basketId); var rate = TaxRateService.ObtainTaxRateFor (product.Id, country.Id); var item = new BasketItem (rate, product.Id, product.price); basket.Add (item); // ... } } } ``` 在以上代码中,应用服务需要了解如何创建BasketItem(购物车子项)的详细逻辑。而这不应该时应用服务的职责,应用服务的职责在于协调。我们尝试做以下改变来避免暴露聚合的内部结构。 ```csharp namespace Application { public class AddProductToBasket { // ...... public void Add (Product product, Guid basketId) { var basket = _basketRepository.FindBy (basketId); basket.Add (product); // ... } } } namespace DomainModel { public class Basket { // ...... public void Add (Product product) { if (Contains (product)) GetItemFor (product).IncreaseItemQuantitBy (1); else { var rate = TaxRateService.ObtainTaxRateFor (product.Id, country.Id); var item = new BasketItem (rate, product.Id, product.price); _items.Add (item); } } } } ``` 以上代码展示了 `Basket`(购物车)对象提供一个 `Add` 方法,用来完成添加商品到购物车的业务逻辑,对应用服务隐藏了购物车如何存储商品的细节。另外购物车聚合能够确保其内部集合的完整性,因为它可以确保领域的不变性。通过这种方式,完成了职责的切换,现在的应用服务要简单的多。 然而,却引入了一个新的问题。为了根据商品创建有效的购物车子项,购物车需要提供一个有效的税率。为了创建这个税率,它要依赖一个 `TaxRateService`(税率服务)。获取创建购物车子项依赖的税率,这并不属于购物车的职责。而按照上面的实现,购物车承担了第二责任,因为它必须始终了解如何创建有效的购物车子项以及在哪里去获取有效的税率。 为了避免购物车承担额外的职责和隐藏购物车子项的内部结构。下面我们引入一个工厂对象来封装购物车子项的创建,包括获取正确的税率。 ```csharp namespace DomainModel { public class Basket { // ...... public void Add (Product product) { if (Contains (product)) GetItemFor (product).IncreaseItemQuantitBy (1); else _items.Add (BasketItemFactory.CreateItemFor (product, deliveryAddress)); } } public class BasketItemFactory { public static void CreateBasketFrom (Product product, Country country) { var rate = TaxRateService.ObtainTaxRateFor (product.Id, country.Id); return new BasketItem (rate, product.Id, product.price); } } } ``` 引入工厂模式后,购物车的职责单一了,且隔离了来自购物车子项的变化,比如当税率变化时,或购物车子项需要其他信息创建时,都不会影响到购物车的相关逻辑。 #### 4.隐藏创建逻辑 > 考虑这样的需求:订单创建成功后,进行发货处理时,要求根据订单的商品和收件人信息选择合适的快递方式。比如默认发顺丰,顺丰无法送达的选择中国邮政。 根据这个需求,我们可以抽象出一个 `Kuaidi`(快递)对象用来封装快递信息,和一个 `Delivery`(发货)对象用来封装发货信息(货物、收件人信息、快递等)。创建 `Delivery` 的职责我们可以放到 `Order` 中去,但针对 `Order` 来说它并不知道要创建(选择)哪一种 `Kuaidi`(快递)。所以,我们可以创建一个 `KuaidiFactory` 工厂负责 `Kuaidi` 对象的创建。 ```csharp namespace DomainModel { public class Order { // ... public Delivery CreateFor (IEnumerable
items, destination) { var kuaidi = KuaidiFactory.GetKuaidiFor (items, destination.Country); var delivery = new Delivery (items, destination, kuaidi); SetAsDispatched (items, delivery); return delivery; } } public class KuaidiFactory { public static Kuaidi GetKuaidiFor (IEnumerable
deliveryItems, DeliveryAddress destination) { if (Shunfeng.CanDeliver (deliveryItems, destination)) { return new Shunfeng (deliveryItems, destination); } else { return new EMS (deliveryItems, destination); } } } } ``` 如上代码所示,工厂类中我们封装了快递的选择逻辑。 当要创建的对象类型有多个选择,且客户端并不关心创建类型的选择时,我们可以在领域层使用工厂中去定义逻辑去决定要创建对象的类型。 #### 5.聚合中的工厂方法 提到工厂,并不是都需要需要创建独立的工厂类来负责对象的创建。一个工厂方法也可以存在于一个聚合中。 > 比如这样一项需求,顾客可以将购物车中的商品移到愿望清单中去。 第一,这个动作是发生在购物车上的,所以我们可以毫不犹豫的在购物车中定义该行为。第二,将商品添加到愿望清单中去,就需要创建一个愿望清单子项。 ```csharp namespace DomainModel { public class Basket { // ..... public WishListItem MoveToWishList (Product product) { //首先检查购物车中是否包含此商品 if (BasketContainsAnItemFor (product)) { //从购物车中获取该商品对应的子项 var basketItem = GetItemFor (product); //调用工厂方法根据购物车子项创建愿望清单子项 var wishListItem = WishListItemFactory.CreateFrom (basketItem); //从购物车中移除购物车子项 RemoveItemFor (basketItem); return wishListItem; } } } } ``` 从上面可以看出Basket暴露一个方法用于将BasketItem转换为WishListItem。返回的WishListItem是WishList聚合根的实体。另外一点我们之所以在Basket中调用工厂去创建WishListItem对象,是因为Basket包含了创建愿望清单子项所需的全部信息。在创建了WishListItem之后,对于Basket对象来说它的任务就完成了。 #### 6.使用工厂重建对象 在项目中,如果没有借助ORM进行数据模型与领域模型之间的映射,或者通过Web服务从一个老旧系统中获取领域对象,都需要我们对领域对象进行**重建**以满足领域的不变性。使用工厂来重建领域对象相对来说要比直接创建要复杂。 > 考虑这样的场景:顾客可以在已购订单中点击再次购买按钮,所有订单项全部重新添加到购物车中去。 这个场景就属于购物车对象的**重建**,跟直接创建购物车对象就不同了。因为将订单中的所有子项恢复到购物车中去,我们就需要额外确保领域的不变性。比如订单子项对应的商品现在是否下架,如果下架我们是直接抛出异常,还是仍旧创建一个锁定的购物车子项,标记其为已下架状态? ```csharp namespace DomainModel { public class Order { // ...... public Basket AddToCartFromOrder (Guid id) { OrderDTO rawData = ExternalService.ObtainOrder (id.ToString ()); var basket = BasketFactory.ReconstituteBasketFrom (rawData); return basket; } } namespace DomainModel { public class BasketFactory { // ... public static Basket ReconstituteBasketFrom (OrderDTO rawData) { Basket basket; // ... foreach (var orderItem in rawData.Items) { //是否下架 if (!ProductServie.IsOffTheShelf (orderItem.ProductId)) { var newBasketItem = newBasketItem (orderItem.ProductId, orderItem.Qty); basket.Add (newBasketItem); } else { throw new Exception ("订单中该商品已下架,无法重新购买!"); } } // ..... return basket; } } } } ``` #### 7.总结 对象创建不是一个领域的关注点,但它确实存在于应用程序的领域层中。通过使用工厂可以有效的保证领域模型的干净整洁,以确保领域模型的对现实的准确表达。使用工厂具有以下好处: 1. 工厂将领域对象的使用和创建分离。 2. 通过使用工厂类,可以隐藏创建复杂领域对象的业务逻辑。 3. 工厂类可以根据调用者的需要,创建相应的领域对象。 4. 工厂方法可以封装聚合的内部状态。 然而,并不是任何需要实例化对象的地方都要使用工厂。只有当用工厂比使用构造函数更有表现力时,或存在多个构造函数容易造成混淆时,或者对要创建对象所依赖的对象不关心时,才选用工厂进行对象的创建。
这里⇓感觉得写点什么,要不显得有点空,但还没想好写什么...
返回顶部
About
京ICP备13038605号
© 代码片段 2024