一、前言

前几篇已经实现了一个最简单的购买过程,这次开始往这个过程中增加一些东西。比如促销、会员价等,在我们的第一篇文章(如何一步一步用 DDD 设计一个电商网站(一)—— 先理解核心概念)中规划的上下文映射图可以看到,这些都属于一个独立的上下文(售价上下文)。

二、如何在一个项目中实现多个上下文的业务

一般情况下,为了更好的分而治之,把不同的上下文作为单独的 service,然后通过 rpc 框架(如 WCF)来对其访问是个比较常见的做法。

但是在一些小型团队中,虽然划分出了不同上下文,但是我们的开发团队还是同一个。在这种情况下,我个人一般的做法是直接在同一个解决方案中建立不同的项目去做,但是这里需要在解决方案中明确的划分好不同上下文之间的边界,通过代码审核等手段管理好这个边界不被破坏。

6 给购物车加点料,集成售价上下文 - 图1

图 1

增加的几个项目如图 1 所示。

三、售价上下文与购买上下文的集成

根据我们第一篇如何一步一步用 DDD 设计一个电商网站(一)—— 先理解核心概念所定义的上下文映射图和 9 种集成模式可以看出,这 2 个上下文在同一个子域中,并且在我们实际业务场景中,这 2 者又是相辅相成,所以售价上下文和购买上下文是一种合作关系。

确立这个关系之后,那么这个促销的计算逻辑到底是放到哪个上下文种做更合适呢?我们先整理一下几种可能的方式:

  1. 购买上下文把购物车中的商品信息丢给售价上下文 –> 售价上下文进行计算 –> 把结果再返回给购买上下文。

  2. 购买上下文从销价上下文获取相关会员价和促销信息 –> 再本地的购物车对象基础上进行运算,并直接可运用结果。

  3. 再抽出一个专门的计算服务(隶属于售价上下文),去做这个计算的动作。购买上下文把购物车中的商品信息丢给计算服务 –> 计算上下文从销价上下文获取到相关会员价和促销信息 –> 计算 –> 返回结果给购买上下文

我相信 1 和 2 是比较主流的 2 个方式。

但是方式 2 是把售价上下文仅作为一种数据的提供方,这就把合作关系变成了一个上下游的关系,并且这种方式使得促销规则和购物车强耦合到了一起,不利于促销规则的变化。在这里售价上下文只起了一个简单的数据维护作用,无法完全控制 “售价” 的定义,没有很好的做到职责分离。

方式 1 和 3 对购买上下文来说其实是没有区别的,只是方式 3 让整个数据交互的链路多了一层,会产生额外的开销,好处是服务的粒度更细了,需要结合实际情况权衡一下得失。

这里我选择 1 方式来实现,因为我们在项目初期,还是尽可能的减少非业务目的的拆分导致的额外成本。

好了,确定了集成方式之后,先把 2 个上下文之间用于数据交互的 DTO 模型定一下,如下图 2(售价上下文的 DTO 模型),图 3(购买上下文中与前者对应的值对象)。

6 给购物车加点料,集成售价上下文 - 图2

图 2

6 给购物车加点料,集成售价上下文 - 图3

图 3

另外在图 3 中可以发现增加了一个 ISellingPriceService,抽象了与售价上下文的交互。那么我们在 Mall.Infrastructure.Translators 项目中增加对这个上下文的防腐层处理,老 3 样 SellingPriceAdapter(发起上下文数据请求的适配器)、SellingPriceService(实现 ISellingPriceService)、SellingPriceTranslator(把远程数据对象转换成本地的值对象),代码很简单大家可以在源码中查看。

需要注意的是,这里的 Mall.Infrastructure.Translators 项目仅增加了对 Mall.Application.SellingPrice 项目的引用,类似于把它当作一个远程资源来对待(按上面所说,如果实际由不同的团队负责可以物理上的分离到 2 个解决方案中)。

最后创建一个 CartService,里面的 GetCart() 方法——获取购物车信息,来作为调用发起方。这其中的实现使用了最简单的方式,本地不做任何的数据冗余,代码如下:

  1. public class CartService
  2. {
  3. private readonly static ConfirmUserCartExistedDomainService _confirmUserCartExistedDomainService = new ConfirmUserCartExistedDomainService();
  4. public CartDTO GetCart(string userId)
  5. {
  6. var cart = _confirmUserCartExistedDomainService.GetUserCart(userId);
  7. if (cart.IsEmpty())
  8. {
  9. return null;
  10. }
  11. var sellingPriceCart = DomainRegistry.SellingPriceService().Calculate(cart);
  12. return ConvertToCart(cart, sellingPriceCart);
  13. }
  14. private CartDTO ConvertToCart(Cart cart, SellingPriceCart sellingPriceCart)
  15. {
  16. return new CartDTO
  17. {
  18. CartItemGroups = sellingPriceCart.CalculatedFullGroups.Select(ent => new CartItemGroupDTO
  19. {
  20. CartItems = ent.CalculatedCartItems.Select(e => ConvertToCartItem(e, cart.GetCartItem(e.ProductId))).ToArray(),
  21. ReducePrice = ent.ReducePrice
  22. }).ToArray(),
  23. CartItems = sellingPriceCart.CalculatedCartItems.Select(ent => ConvertToCartItem(ent, cart.GetCartItem(ent.ProductId))).ToArray()
  24. };
  25. }
  26. private CartItemDTO ConvertToCartItem(SellingPriceCartItem sellingPriceCartItem, CartItem cartItem)
  27. {
  28. var product = DomainRegistry.ProductService().GetProduct(cartItem.ProductId);
  29. return new CartItemDTO
  30. {
  31. ProductId = cartItem.ProductId,
  32. ProductName = product == null ? "商品已失效" : product.SaleName,
  33. ReducePrice = sellingPriceCartItem.ReducePrice,
  34. SalePrice = cartItem.Price
  35. };
  36. }
  37. }

四、结语

这次有个全局改动这里提一下,我在本次编码中把之前所有的 Guid 标识全部改为了 string 类型,弱化了对唯一标识的数据类型约束,提高可扩展性(如自增字段、其它自定义的唯一标识等),另外还把购物项中的 Price 改为了 UnitPrice,让语义更加清晰。本篇内容比较粗,欢迎大家探讨。

本文的源码地址:https://github.com/ZacharyFan/DDDDemo/tree/Demo6


原创文章,转载请注明本文链接: https://zacharyfan.com/archives/147.html

关于作者:张帆(Zachary,个人微信号:Zachary-ZF)。坚持用心打磨每一篇高质量原创。欢迎扫描二维码~

6 给购物车加点料,集成售价上下文 - 图4

定期发表原创内容:架构设计丨分布式系统丨产品丨运营丨一些思考。

如果你是初级程序员,想提升但不知道如何下手。又或者做程序员多年,陷入了一些瓶颈想拓宽一下视野。欢迎关注我的公众号「跨界架构师」,回复「技术」,送你一份我长期收集和整理的思维导图。

如果你是运营,面对不断变化的市场束手无策。又或者想了解主流的运营策略,以丰富自己的 “仓库”。欢迎关注我的公众号「跨界架构师」,回复「运营」,送你一份我长期收集和整理的思维导图。
https://zacharyfan.com/archives/147.html