本系列目录:
Entity与Service,相爱相杀
好,接上一篇。
既然采用order.cancel()
这种模式,那么一个新的问题来了:
所有的命令操作都要变成这样子吗?那曾经巨大的OrderService的代码,岂不是只是单纯挪了一个位置,放在Order里面了,除了上面所谓的可读性的优势,那还有什么用?
并不是,只是一部分放在实体类,其余的命令操作,依旧会采用一种Service来做。 所以,我们必然需要一个可以清晰量化的规范,来确定这些行为该放在哪里:
如果一个命令操作,只修改了一个聚合对象内部的相关数据,那么,就归属给这个聚合 比如,_订单取消_这个行为,需要做的事情有:
- 订单状态标记为取消
- 订单变更记录插入一条,“订单取消”
根据我们之前的图可以知道,这些修改操作,都在这个订单聚合内,很自然的归属给order
注意,我们反复强调了这里是“修改操作”,也就是说,如果需要我们在此操作期间,查询其他聚合的信息,只要不做修改,那就是允许的!就像下面这样:
@Entity
public class Order{
private OrderStatus status;
private String customerName;
private User orderCreator; //假定这里是下单用户,省略了many-to-one的配置
//...
public void cancel(){
//修改操作1:变更订单自身状态
status = OrderStatus.CANCELLED;
//查询用户信息,要记录到订单变更日志中,
//这里如果是Hibernate,会直接触发sql查找,如果换成其他如mybatis,则用对应的repository操作即可,总之,是一个纯查询
String userName = orderCreator.getUserName();
//修改操作2:增加一条订单状态变更信息,具体实现省略
createOrderTrack(OrderStatus.CANCELLED,userName);
}
}
然后,就要从另外一个角度来说了 如果一个命令操作,并且要求是一个完整的事务,修改了多个聚合的数据,那么,需要为这个行为建立一个 Service 而这个Service,不会是一个{领域名称}+Service,而是一个{具体动作}+Service,比如OrderPayService
,订单支付,假定有如下动作:
订单状态改为支付中
商品库存对应扣减
用户若使用了优惠券,则优惠券标记为使用中 这几个操作,是要在一个完整的事务中的,所以我们写在一个Service中
@Transactional public class OrderPayService{ //-----------(1)
@Autowired OrderRepository orderRepository; //-----------(2) @Autowired CouponRepository couponRepository; public String execute(Long orderId,Long couponId){ //暂不考虑前置状态检查 //订单属性变更 Order order = orderRepository.getById(orderId); order.setStatus(OrderStatus.PAYING); //商品库存扣减,按之前的假定,一个订单只对应一个商品 Prodect product = order.getProduct(); product.minusStock(order.getQuantity); //-----------(3) //变更优惠券状态 Coupon usingCoupon = couponRepository.getById(couponId); usingCoupon.setStatus(CouponStatus.USING); //去交易中心获取支付unikey CreatePayResponse payResponse = payCenterApi.createPay(...各种参数...); return payResponse.getUnikey();
} }
//这时,上层入口(如Controller)就是这样调用了 orderPayService.execute(orderId,couponId);
好,老规矩,深入探讨一下:
- 类被命名为_订单支付服务_,也就是{一个动作}+服务,代码的清晰性上来说不言而喻,但也意味着一个操作就要有一个
service
,其实这是非常符合单一指责原则的,但是肯定会有不少同学觉得这样做是容易产生过多的类,过度设计了。确实,会有这种情况,但我依旧推崇这样做,或者说,如果一定要一个service
里多个行为,那至少表示这个行为是相关的比如都是订单,但是PC端下单
,APP下单
等等,可以放在一起,这样职责不泛滥,也便于代码复用 service
,我们可以给与其足够的权限,只要它需要,它可以无所顾忌地获取所有的上下文组件,不管是jdbc组件,还是外部rpc组件,都是可以的。因为给它的定义,本来就是多聚合的事务处理类,所以,只要它能保证事务的安全性,保证业务的完整,这一切都是没问题的(这里暂时不讨论分布式事务问题,那是另外要一个议题)- 库存扣减,我们这里采用了
product.minusStock(quantity)
,而不是直接对product
进行属性修改。当然,直接进行属性修改也是可行的,但是为何这里却封装成了一个方法呢?很可能的原因是,最早的时候,是直接改属性的,但后来有很多地方都要扣减库存,所以,代码重构了,然后minusStock
应运而生。关于,重构,我们后面还会提到。
一个特别注意的点
再思考一个常见的例子:账户转账
应该是这样 account.transfer(otherAccount)
吗?
这里,会有点争议,但我会提倡用AccountTransferService
来做,因为虽然这个操作从宏观上来说是属于一个领域聚合,但是,这个操作,却是完全不同的对象! 是同时修改了两个对象的数据,而且自然要求完整的事务性。
所以,这种场景,也可以理解为,Service,是处理跨聚合对象。
关于实体的Set方法
如果我们使用很多ORM框架,由于框架的实现策略的缘故,实体类是需要把所有的Get和Set方法都要开放的,而且上面大家也看到当我们用OrderPayService
的时候,也直接使用的对象的setXXX
方法,所以Set自然更加需要开放了。
但对于set,本文这套规范,极力倡导一个原则:在进行业务开发时,Set能调用的地方只有1个,那是就在service
中! 其余的任何场景,任何地方,都不允许(或者没必要)调用set方法,尤其是下面这种场景:
//在一个上层,比如Controller中
@GetMapping("/coupon/disable/{id}") //失效某张优惠券,偷懒就不用Post了
public ActionResponse disableCoupon(@PathVariable("id") Long id){
Coupon coupon = couponRepository.getById(id);
//错误,禁止!!!
coupon.setStatus(CouponStatus.DISABLED);
//正确的应该是
coupon.disable();--------------------------
} |
|
public class Coupon{ |
|
private CouponStatus status; |
|
public void disable(){ <-------------------- |
status = CouponStatus.DISABLED;
}
}
一定会有同学马上提出疑问
才一行代码,为什么不能直接用set?强迫症吗?
不否认,这个规范,的确有点强迫症,但是真的是有好处的。
领域设计的思想里,严格意义上来说,Get和Set都是不能随便暴露的,尤其是Set,是在修改这个系统,是有一定风险与危害的,那么,任何一个set,都一定是有原因的,一定是要归属到一个具体的业务命令操作中的。
其实,我在思考这套规范期间,一度将set
方法直接设置成本包可见的级别,希望通过Java编译报错来杜绝这种情况,但是这样又和上面提到的service
的模式出现了冲突,最终只能作罢。
工厂
到此为止,我们可以认可,所有的命令操作,都将会归类到Entity或者Service中
但有一个特例,这里有必要提出来单独说一下:一个实体的创建,也就是增删改查中的 增 的操作
因为删,改的操作,都是先找到一个实体,然后进行操作。但创建却不同,因为在执行创建操作之前,这个实体都是不存在的,你怎么找?就更加不可能有类似 order.create(params)
这种代码出现了,order
尚且不存于世呢! 所以,这里,自然的想到通过创建OrderCreateService
来处理,但考虑到新增的特殊性,建议直接用工厂模式来做,即OrderFactory
对于OrderFactory
,它的责任并非简简单单new Order()
然后一堆setXX
后完事。详细说明如下:
- 负责创建聚合根对象,比如订单聚合中的
Order
,往往创建会有诸多不同的场景,比如创建一个空对象,或者创建有很多默认组件的对象等等,这个就根据业务场景来了。总之返回值一定是一个新创建的实体类。 - 负责创建聚合中其他对象,但是这种场景仔细想来,并不会太多。因为聚合中其他“附属实体”的创建往往会以聚合根实体的某一个命令操作相关。比如订单变更记录
OrderTrack
的创建往往是伴随Order
的各种各样的操作,比如订单创建,支付,发货,取消等等,而大部分时候不需要单独出现诸如OrderFactory.createOrderTrack
的情况。 Factory
中原则上是允许触发对其他领域聚合的数据变更的,因为它是一个特殊的领域Service
。但从一般的业务场景来说,这种情况并不多见,因为创建后立即变动某个其他领域的数据,往往会直接在应用层加入代码,或者通过事件来处理。
“非常薄的业务”
【以下内容为2020-03-06日补充】
什么是”非常薄的业务“,就是说一个实体虽然客观存在,而且不会和其他领域/聚合产生任何强依赖关系,完全有自己独立的生命周期,但是生命周期太短了,以至于根本没有严格意义上的生命周期,只有create,然后加上简单的一些属性set操作。
少见吗?其实不少见,大家想想,是否我们的数据库里有大量的作为“配置”作用的表:比如“短信模板表”,“消息模板表”,“首页内容配置表”等等。如果一定要掰开来说,这些实体的生命周期就是创建,然后配置参数,也可能被删掉/弃用,但这种没有太多"业务流程深度”的生命周期,再要套用DDD,就是杀鸡用牛刀了,甚至是过犹不及。
所以,这种时候,倒不如一个最简单的 MsgTemplateService 来的痛快。
换句话说,就是一个东西如果怎么想都觉得用DDD特别”难受“的时候,那就说明,他不适合DDD,他不适合充血模型,那就继续用贫血模型就好了。
【以上容为2020-03-06日补充】
事件
领域事件,在最早的《领域驱动设计》一书中,并未提及。在之后的相关书籍,诸如《实现领域驱动设计》中,有将其作为一等公民的身份进行详细讲解。很惭愧,这一块我一直没有GET到其精髓,所以我只是结合更广义上的事件,来做了一些分析,如有不合适的地方,也欢迎各位拍砖。
说到事件,大家一定能联想到事件的广播,事件的处理。没错,事件是一个非常好的解耦和工具,也是一个非常舒服的“梳理工具”,因为在讨论需求的时候,经常能够看到非常“顺理成章”的事件场景描述:
当用户创建了一个订单后,要同时生成一个订单变更记录 ------ (1)
当用户的订单支付成功后,要同时为这个用户生成一个XX奖励券,并且用户活跃积分+10 ------ (2)
这一些,都太符合“事件”了,这些有些是在项目第一版的时候就清楚了,有些则随着版本迭代不断加入。 但是回过头的仔细想想,如果遇到这种需求场景的时候,大家在实际的开发中,都真的用了事件吗?不论是单机应用还是分布式应用,我们在不加入事件机制的前提下,上面这些功能通过直接“调接口”都是完全能满足的。
没错,所以对于事件模式,我们可以倡导一个原则:所有的事件,从重构中得来
从重构中得来,意味着,我们没必要在需求一开始就大量采用事件的做法,即使需求描述中有“**当/如果...就...**”,因为绝大部分时候,我们往往无法得知之后的产品的发展方向是什么,过早的事件设计(尤其是分布式系统)会给代码的阅读流畅性,事务管理等等带来更大难度。
事件的优势在于一处广播,多处接收,所以,当“接收方”越来越多,事件机制的优势也越能体现出来。所以,我认为最佳的实践方式,或者更容易推广的实践方式,还是跟着版本迭代来不断优化代码,在逐渐清晰地产品发展方向和扩展方向上,将原有的“直接调用”转变成“事件处理”。一般来说,当“接收方”出现2-3个的时候,可以开始考虑转变成事件机制了,比如上面的(2)
当然,这里并不否认在第一时间就加入事件机制的做法,只是建议如果确定要在一开始就这样做,希望这种做法的开发负责人务必对业务的扩展方向有足够清楚的认识与了解。(比如一家公司在原有系统上开发新的升级版系统,这个时候,可以在第一时间就做好设计优化,因为有很好的业务背景基础)
至于具体的代码实现方式,在单机应用中,Spring有很好的事件机制,而且能够支持事务的完整性。而分布式系统中,更多的接用消息中间件来实现,具体技术细节就不更多展开了。