戏说领域驱动设计(廿一)——领域服务
实体对象和值对象都写完了,本想开始写资源仓库顺便把工作单元再搞搞。不过有一点麻烦的是我不太想把工作单元作为单独的一章来写,一是这东西网上相关的内容太多;二是有的时候使用Spirng的事务就解决了,没觉得有多大作用。不过先不纠结这些,还是按本章的主题写领域服务吧,这好讲,谁不喜欢简单的东西。
一、为啥引入领域服务
要说原因吧,其实也挺简单的。在开发过程中你会发现有些方法不知道放哪个对象身上合适,比如说经典的转账业务:放客户对象上不合适,客户对象中没有余额的概念;放账户上面吧,感觉也差了点意思,这里涉及到了两个对象的变化而且都需要进行存储,比如您的代码可能会这样写“a.transfer(b)”,一般在开发时最好别在方法中直接修改参数的值,只你自己用或看也许问题不大,别人要使用的时候谁会想到参数也会被方法变更?JDK里那么多方法,虽然没统计过但我感觉几乎很少出现方法改参数的情况。还有的时候,一些方法所做的事情不是业务逻辑而是转换,比如根据参数构建领域实体,这种糙活谁谁都不愿意干。哪个对象都有自己的矜持与骄傲,你让他干这些活,那是看不起人家。总而言之吧,涉及跨领域对象操作、对象转换以及没人愿意干的活时交给领域服务这个老好人就妥了,这家伙是系统中的中间人、和事老。可您不能小瞧人家,领域服务也有自己的体面的,干得活再杂也是BO层中的大佬,DAO、应用服务都得围着人家转呢。而且,你别看他不起眼儿,关键时间方能显出英雄本色,要不然你的代码就是一坨那个……
二、领域服务的责任
BO层中有些东西虽然看着不重要,可不能简单的轻看人家,干得活绝对不含糊。您看上面这个图,领域服务要干得事儿也不少呢。不过你用的时候可也得注意两点:一是和应用服务的区分,后面那东西虽然重要可他不处理业务,人家管的是事务、安全、流程控制等。我们领域服务虽然没有实体风光,那也是处理业务的。二是在领域服务中,你可不能访问或使用DAO、IoC、Spring甚至是Log,他和这些人都是老死不相往来的。当然了,也有一种编程模式说您可以使用比如Spring的IoC将领域服务注入到应用服务中,其中领域服务的接口在BO层中定义。反正我个人比较反对这么干,与基础设施耦合不说,你能注入就代表定义和实现是分开的,实现中可以访问DAO或其它的应用服务,再度造成了反向依赖的问题。我们有一个资源库这么干就行了,别老是整出太多的四不像来。再说了,资源库的目的主要就是帮助领域模型和数据模型进行解耦,你将领域服务的定义与实现分开,要和谁解耦?
实体、值对象、资源仓库接口及领域服务是领域模型层的四位公子,而作为BO中的四公子之一,领域服务是最为谦和的。不像实体那么张扬,不像资源仓库那么粗劣。然而成也萧何败也萧何:你用得多了,领域模型变成了贫血模型;用少了,代码看着乱七八糟,随随便便。不偏不倚才能让设计出来的东西更加有组织、有原则,所以我们需要重点说说如何使用领域服务。
二、如何使用
首先,领域服务一般是不包含属性的。如果你在设计过程中发现需要在其中增加属性,那此时你就得开始考虑一下是不是应该把方法和属性放到某个实体或值对象中更合适,只有后两者才是包含属性的对象。那位说了:服务一般是单例的,你不加属性是为了避免并发,那可以用ThreadLocal行不?肯定不行啊,能称之为服务的对象就代表其主要是执行某个动作,加属性不仅违反了服务的使用模式也说明你对领域模型的认识度还不够。另外,为了少写点代码,您用的时候把领域服务建模为单例(不需要使用volatile关键字,这东西用多了容易引入总线风暴)更好,不用非得先new再调用实例方法,麻烦!
第二,如果某个业务需要跨领域模型或者说是跨实体,此时也是使用领域服务的最佳时机。这条约束作为开发规范在团队中进行要求是有意义的。您还记得我在前面说过开发过程中会涉及到三类服务吧?分别是:应用服务、数据服务和领域服务。这仨东西有一个共同的责任:“流程组织”,前两者用于组织用例的控制流程和数据的操作流程,而领域服务则用于组织业务流程。他通过协调多个领域对象的交互来完成某个业务,属于指挥官的角色。比如下列的代码:在建立新的优惠策略的时候需要约束系统中不能存在优惠条件相同的优惠,相当于将目标优惠与现存优惠做冲突检测。很明显,这项业务是跨领域模型的,所以我建立了一个应用服务来完成任务。您再注意一下下面代码所使用的单例模式,这种方式可以有效的简化领域服务的使用。
public class DiscountConflictionTestService { final public static DiscountConflictionTestService INSTANCE = new DiscountConflictionTestService(); private DiscountConflictionTestService() { } /** * 优惠条件冲突检测 * @param target 目标优惠 * @param sources 优惠列表 */ public List<Discount> testConflictions(Discount target, List<Discount> sources) { if (target == null || sources == null) { return new ArrayList<Discount>(); } List<Discount> result = this.compareWithExists(target, sources); //比较优惠自身是否也有策略冲突 if (target.hasConflict()) { result.add(target); } Collections.reverse(result); return result; } }
在继续之前我们来说一下微服务架构下的领域服务要怎么玩儿。其实都不用说跨服务这个层级的交互,假如某个业务需要两个或多个包内的对象共同参与时要怎么写代码?本质上跨包与跨服务是一样的处置方式,你都不能直接引用外部包内的领域模型。如果看过前面的文章您应该记得我说过两个包之间的交互只能通过视图模型,这个原则其实是强制性的。我们还是以下单为例,具体需求为“下单前要判断用户是否未被冻结且已经实名认证”,好多人下意识的做法是这样的。
@Service public class OrderService { @Resource private AccountService accountService; public void placeOrder(OrderDetail orderDetail, String accountId) { AccountVO account = this.accountService.queryById(accountId); if (account.getStatus() == AccountStatus.FREEZEN) { …… } if (!account.isAuthenticated()) { …… } Order order = OrderFactoryService.create(orderDetail, account); …… } }
这段代码其实挺不错,遵循了跨包访问的规范,只可惜不太符合OOP的要求,面向过程的味道太重了。在我们使用OOP的时候,如果您想确保代码的纯粹那就遵循这样的规则:应用服务中业务的参与者只能是领域模型。上面的示例中,“AccountVO”是视图模型,“Order”和“OrderFactoryService”是领域模型,这两类对象混着玩差了点意思。虽然视图模型只是参与了验证,并没有实现过多的业务,可此处的验证也是业务规则的重要组成部分。当然你也可以质问我:不是你说的吗?两个包之间不能相互访问领域模型,我已经传过来了视图模型,你又说我的编程四不像,找事儿呗?
这事儿要怎么说呢,我觉得下面这段解释还是很重要的,您最好拿个小本本儿记下来。提前声明一下,我之前写过的本系列的第十九篇“外验”中提到过“把验证抽象成业务规则从应用服务的代码中剥离出去”,下面这段思想与之并不冲突,请务必注意。既然面向对象编程时应用服务中的业务参与者应该都是领域模型,那么你就应该站在领域模型的立场来设计代码。在账户上下文中,我们往往会将账户建立成实体,所关注的大概是这几个方面:用户基本信息、联系方式、状态、级别、是否实名、积分等。大部分电商系统中的账户都差不多会关心上述信息。而到了订单上下文中,我们关心的则是这个账户在购买商品时所需要的一些信息比如联系方式、是否会员以及影响购买的因素信息比如我们在订购业务中所要求的“必须实名化”等。在这两个上下文中都涉及到了账户,技术角度来看,所指向的信息其实是同一张表,但在业务上的关注点却完全不一样,比如您可能不太会在订购BC中关心用户年龄吧?
基于上述原因,所谓的“账户”概念在账户BC中这么称呼是合理的,放到订购BC中再这么叫就差了点意思。因为我们只关心账户的某一部分,所以最好能给这一部分一个新的名字比如“客户详情”。客户详情是在订购BC中所建立的一个实体,其作用范围也仅限于订购BC里。他的创建方式其实很简单,通过使用账户BC传过来的视图模型,根据其中的属性使用构造函数也好或对象工厂也好进行实例化。如果您明白了这段所讲,上面的代码就应该变成如下样式。
public class CustomerDetail extends EntityModel<Long> { private Status status; public CustomerDetail(AccountVO account) { this.status = new Status(account.getStatus()); } public boolean canOrder() { if (!this.status.canOrder()) { return false; } if (!account.isAuthenticated()) { return false; } return true; } } @Service public class OrderService { @Resource private AccountService accountService; public void placeOrder(OrderDetail orderDetail, String accountId) { CustomerDetail customer = this.constructCustomer(accountId);
if (customer.canOrder()) {
……
} Order order = OrderFactory.create(orderDetail, customer); …… } private CustomerDetail constructCustomer(String accountId) { AccountVO account = this.accountService.queryById(accountId); return new CustomerDetail(account); } }
上述的代码相信对您领悟OOP多少有一点帮助。其实单纯的面向过程和面向对象都挺好,是两种不同的编程模型;两种混搭其实也不像某些人说的那么不堪。您也知道,由于对象的存在会使得代码更加内聚,所以即使一部分代码使用了OOP,总比代码都散着强,毕竟学习和认知也是需要一定过程的,最重要的是自己能够主动去悟。我见过一些程序员,其实也没写过多少东西。一见别人的代码就开始各种指责,什么代码不纯粹啊、不够OO啊、不是DDD啊。碰到这种人您都不用理他,不是键盘侠就近亲出来的。您让他写一段让我们学习学习,人家总是能装出一副玄学大师的样:你?不行,我这种水平的代码你怎么可能会懂……
本来是在讲领域服务,不过还是歪到了OOP上。不过我个人觉得思想是技术的前提,后者千变万化但万变不离其宗,所以掌握思想很重要。其实也不是说工程师的水平不行或不够聪明,主要是现在的好多书上没给出如何做OOP的案例,让大部分工程师没吃过猪肉也没见过猪跑。写书的人都有一个前提:您已经知道如何做面向对象编程了,我估计是把面向对象编程与面向对象语言搞混了。反正我是觉得您没有OOP的基础,上述的代码肯定要比现实中的简单。可是这种文章我也不太可能把项目上的代码贴出来,会出事儿的……在有些人的眼里,简单的代码是学不到东西的,这想法其实特别扯,Linux有全部的开源,看完了他也成不了大师。作为演示,我使用了一些简单的案例,只要能说明问题即可。还是那句话:最重要的是思想,不要总是追求所谓的正宗、权威,最早的那本DDD书籍也是以强调思想为主,有了指导就需要您自己发挥自己的智慧,包括本系列文章,能给出的也是参考。学习到了一个程度就需要集百家之长来弥补自身的不足,不能再等着别人的指导了,那东西哪有个头儿 ?我这写写就开始歪楼,主要是这些问题的确是阻碍我们进步的绊脚石,想学习好实践是一方面,思想也需要进行改造的。
三、使用模式
回归正文, 我在前面的章节中也提到了领域服务的使用模式:严格和松散。松散的模式比较简单,您在应用服务中可以直接使用实体也可以直接使用领域服务来完成某个业务,很多人都这么用包括我,所以没什么可说的;而严格的模式则要求应用服务不能直接调用实体的方法,需要执行什么业务逻辑应该交由领域服务进行代理,如下图所示。
这种模式在领域服务与实体间又做了一个隔离,我个人觉得这种隔离并没有产生很大的效果,毕竟你还是需要在应用服务中引用实体的,最起码你得把领域实体查询出来吧?反而是业务组合的效果比较好,在代码的复用度方面效果相对突出,比如上图中的“领域服务B”。这两种使用模式不属于强制规则,和“实体中不能访问DAO”不一样,具体怎么使用还是得看需要。
总结
本章主要讲解了业务服务及OOP的一些思想,在实际项目中务必要注意其使用方式,错误的使用应用服务很容易导致实体变成了贫血模型,面向对象也就变成了变身过程。另外一条需要提醒的是,在使用业务服务的时候还是要尽量在里面少写业务逻辑,协调任务才是他应该要干的事儿。