废话
之前七七八八看了些DDD相关概念,充血模型、领域事件、领域服务、应用服务等,大致能理解但从未实践。最近在用ABP做个电商模块,尝试用DDD方式来实现购物车功能,感觉还行,下面做个记录。
- 业务分析和设计说明参考:https://gitee.com/bxjg1987/abp/wikis/购物车?sort_id=3392905。
- 完整购物车源码参考:https://gitee.com/bxjg1987/abp/tree/master/src/Shop
下面这些内容只是个人理解,未必正确。
领域实体充血模型-定义满足业务规则的实体类
购物车模块涉及到两个实体ShoppingCartEntity(购物车)、ShoppingCartItemEntity(购物车明细),按我之前的做法会直接定义成POCO,伪代码如下:
public class ShoppingCartEntity { public long Id { get; set; } //关联的顾客id public long CustomerId{ get; set; } public CustomerEntity Customer { get; set; } //购物车明细集合 public List<ShoppingCartItemEntity> Items { get; set; } //,...略 }
购物车明细定义就省了,意思就是属性全部get; set; ,它只是用来给EF做映射,做数据库操作,做数据传递用。
但这样的领域实体类无法真是表达业务规则,添加商品到购物车、从购物车移除商品等购物车相关操作,我们可能会放到应用层(或者老式的业务逻辑层BLL),这样无法体现购物车实体的功能,代码复用性也很低。
我们思考下:
- 如果我们将购物车关联的顾客id设置为只读的,然后通过构造函数来初始化它,因为不希望别人调用我们的购物车对象时将关联的顾客设置为空
- 如果我们将购物车明细设置为只读的,然后在购物车实体上提供:添加商品到购物车、从购物车移除商品、清空购物车等操作如何?因为购物车明细的变化会影响到购物车金额和积分的统计
- 如果我们在购物车操作的不同点触发一些事件如何?比如:当将商品添加进购物车时触发一个事件,因为希望将来别人使用我们的购物车模块时能加入它们的业务逻辑。
这样一来,购物车相关的操作都封装进购物车实体,将来应用层的代码就会变得很少,代码复用性、可扩展性也高。本属于购物车的功能就定义在购物车实体上也更直观。
很多属性应该是私有的
先说一点,我们定义一个方法、一个属性、一个类、一个软件时,一定要考虑这些功能可能在任何时候、任何地方、被任何一个SB(包括我自己)调用,他们很可能不按你的预期来。
购物车必须是属于某个顾客的,也就是必须有个关联的CustomerId,这是我们的业务规则,也是约束,但按我们上面的定义为get; set; 别人可能给他赋值个0或负数,这就让购物车实体处于不正确的状态,所以应该把CustomerId设置为{ get; private set; },同理在定义购物车明细时关联的ProductId(商品Id)也应该是只读的,因为购物车明细必须与某个商品关联才是正常的。
我们可以在构造函数中定义参数来初始化这些只读属性。
如此这般,当创建一个购物车实体后,这个对象无论被谁访问,CRUD工程师们无法像以前一样破坏它的状态。
至于到底哪些属性该是只读的,哪些是public的应该根据场景,每个属性认真思考再决定。
如果非要在某个阶段形成一个不符合业务要求的实体,可以考虑使用Builder模式
有时候你发现仅仅是通过构造函数才能初始化一个对象,感觉很不方便,因为对象可能需要先new出来,然后在各个步骤对它进行赋值,最后才能形成一个我们满意的对象(有严格约束,且符合业务规则),个人觉得这个时候应该为它创建一个对应的Builder对象,把那些临时的状态属性设置到Builder上,最后Builder.Build();生成一个符合业务规则的对象。这种情况不仅仅适用用域领域实体,整个软件设计中都适用。
在目前的购物车功能好像体现不了这个。
EF查询时可以访问到私有构造函数、设置只读属性
这个很重要,之前一直晓得领域实体属性有些应该是只读的,但考虑用ef无法给只读属性赋值,所以后来放弃了,也不晓得从啥时候开始,我们定义的领域实体的私有构造函数和属性EF是可以直接访问的,这就给我们定义符合业务规则的实体创造了机会。
AutoMapper可以通过构造函数做映射
上面的领域实体如果关键属性为只读的了,咱们做dto到实体的映射呢?印象里AutoMapper是可以通过构造函数做映射的,刚好我们上面说了我们的实体是有对应的构造函数的。这个规则有待证实。
领域实体应该有业务方法
想象下,将商品加入购物车这个功能,按我原来的做法会在应用层查询出购物车,比如这个对象叫shoppingCart,那么我会直接
在应用层中: shoppingCart.Items.Add(item); //计算明细对应的金额(明细数量*关联商品的单价) //其它处理
仔细考虑下,将商品加入购物车这个方法不是应该定义在购物车实体上吗?如果这样,商品进入购物车,后续要从新计算金额、积分之类的逻辑也都会写在购物车实体内部,而不是放在应用层。这样,应用层将来只需要shoppingCart.AddItem(item);是不是更符合业务场景?
领域实体的方法只修改自己的状态属性
以订单支付这个方法为例,订单支付 要修改支付状态为已支付、改变支付金额、将物流状态改为待发货等等,支付状态、物流状态、支付金额 这些属性都是订单实体类的,购物车实体中的方法也只是修改自己实体的状态属性。
别想在领域实体里去做依赖注入、访问数据库或其它服务
领域实体里只是根据业务定义相关方法,这些操作都是跟这个领域实体相关的,状态属性的改变。依赖注入、访问数据库或其它服务可以在应用层或领域服务去做。
通过领域事件实现可扩展性
我们可以在购物车中定义这样的事件:当商品明细加入购物车后触发、当移除购物车明细时触发、当购物车明细数量变更时触发.....等等。这样我们的购物车模块可以做得很干净,将来别人使用这个模块时可以订阅这些事件来扩展购物车模块。
这个事件的功能是abp自带的事件总线,可以去参考官方文档。
并且这个事件还是事务性的,意思说如果将来别人扩展我们的模块,在它们的事件处理代码中若操作数据库,和我们处理购物车逻辑是在一个数据库事务中,他们可以抛出异常来阻止我们的正常提交。
领域服务
DDD的说法是当一个功能无法只归结到一个领域实体上时可以考虑领域服务,协调多个实体或其它领域服务时也行。
目前在购物车模块中没有使用领域服务,还是以订单支付为例
上面说了,订单实体本身定义了个“支付”的方法,它内部改变订单自己的状态(修改订单状态、修改支付状态、修改物流状态),然而订单支付还涉及到其它处理,比如:要先判断顾客会员等级、余额情况、是不是黑名单 等等,这里就涉及到多个实体和服务了,所以在订单领域服务中有个支付方法,它会做各种业务判断处理后再调用订单实体.支付();
领域服务中也可以触发领域事件
领域服务也属于领域层,也可以触发相关事件,以这种方式来预留扩展点。abp也提供了这个功能。
何时使用领域服务?合适使用领域事件?
我比较倾向用事件,上面说支付订单前要做各种业务判断,比如会员等级决定折扣、余额检查等,用领域服务很直观,但是不够灵活,比如将来又变了,要在支付前做更多判断呢?此时如果在支付前触发一个事件,那么将来有新的需求就可以加新的事件处理器,不符合业务规则的情况,在事件处理逻辑中抛异常就可以了。
领域服务中是否访问当前用户(session)?
不建议,当前登陆用户严格来说是应用程序状态,而领域服务是细小的领域逻辑,它与应用程序状态无关。
应用服务
领域层整好了,这个代码会变得很少,
它访问数据库得到领域实体,也可以依赖注入领域服务。按业务流程逐个调用领域实体和领域服务的相关方法,通常感觉对应用户的一个操作,比如点个按钮提交
它访问当前用户
它做权限判断等。
它做基本数据校验
它做dto到实体的映射
开始事务、调用领域服务、实体后提交事务
将商品加入购物车的流程
顾客点击“加入购物车”,前端上传商品(或skuId)
应用层做权限判断、基本数据验证、然后查询当前用户关联的购物车
调用购物车.AddItem(item);
购物车领域实体检测这个商品是否已存在购物车了,若在则累加数量,并触发购物车明细数量改变的事件;若不存在则添加商品到购物车并触发 购物车明细增加成功的事件
事件处理程序预留给模块使用方进行扩展的
如果业务流程复杂,在应用层可能还有好几个步骤要做,但如何完成通常是交给领域服务和实体
应用层最后保存数据到数据库(事务)