前面细讲了基于CQS的4层架构,其中的领域模型层也就是六边型架构中的内核在整个开发流程中工作占比最大,也是需要工程师最需要关注地方。那么话说回来了,里面到底包含了什么东西需要投入如此高的关注度。答案还用说?必然是领域模型啊,比如实体、值类型、业务服务等,您别忘了咱们讲的是领域驱动设计。具体可参看如下图所示的领域模型层(后续简称BO层)中的元素。这里面东西较多,基乎每一种都可以开一章来讲,也就是可以水好多的文字。
一、业务模型层中的特色元素
1、业务异常
BO层中元素比较多,但这里面最具特色应该是“业务异常”。您说把领域模型、领域服务归结为BO中的元素这本是无可厚非的,因为它们本来面向的就是业务实体、业务逻辑。把异常也归结为BO中的元素是几个意思呢?而且,书上都没这么讲过,我这是不是故意的哗众取宠骗流量?这话不能这么说,书上没讲过的东西多着呢,人家写书的时候都会站在一定的前提之上,比如读者应该会一门开发语言,应该做过实际的项目,应该有具备哪些基本知识等。咱这种博客什么样的受众都有,内容本身也是个人经验的总结,有点特殊的东西很正常,而且我不仅要讲书上没有的东西,还会讲得很细,您就踏实的看吧。
回到业务异常这个事情上来,在软件的分析设计过程中,那些有形的物体(一般是名词)可以建成实体,比如订单、账户、商品等;还有一些是无形的但在需求过程中也隐晦的提到了,最典型的就是“事件”,这是对动词进行建模的经典案例。而异常则是典型的、经常被隐晦提及的需要被建模的实体,由于其隐蔽性所以在建模时很容易被忽略。举个例子,需求中可能会这样描述:下单失败时应该告知用户失败的原因。这个场景中提及了“失败原因”这个名词,那应该用什么东西来描述它呢?这时就需要业务异常来发挥热量了。显性的提及失败的处理逻辑还好一些,还有一种常见的需求形式比如:下单成功后,给用户发送短信通知。这种需求只描述了正向的业务而没有说明如果失败了要如何处理。此时就需要工程师发挥自己的主动性,结合业务的使用形式为失败的场景建立适宜的处理方式,当然也可以和客户或产品经理就此等情况进行协商,实际的参与到需求完善的过程中。
此种工作方式也应对了我在前面所说过的:软件开发并不是无脑的堆代码。实际上,我也见过一些工程师,当面临业务BUG时候很不愿意承认自己的过失,而是将责任推给需求或其它人,常用的口头语就是“你也没说啊?”,对方的常见回答是“你为什么不问”?然后就开始撕了……客观来讲,我个人比较不喜欢这种工程师,缺少必要的积极性和主动性。工作其实首先成全的是自己,能否让自身成长也只能靠自己,毕竟“师傅领进门,修行在个人”;满足公司的需要这本来就是义务,毕竟你从公司拿钱了;另一方面也需要考虑如何进行自我提升,有时候软技能比会什么什么技术更加重要。我在以往的工作中也曾经历了许多的故事,有些是个人的不足,有些是公司的政治,但一直在努力的进行自我提升包括写这些文章这个事情的本身。不顺是眼前的,有些时候并不是您自身的问题,但能否做一些事情为将来开辟出一条适合自己的路,这是可控的。
有关业务异常还有一些补充,您在日常用的时候务必给他一个见名之意的名字。这么说吧,给异常一个好名儿比您费半天劲想词去描述异常原因更有价值,名起得好在抛异常的时候甚至不用写具体原因。另外,实践中最好让业务异常继承于“Exception”而不是“RuntimeException”,也就是使用检查异常以避免异常未被正确捕获。我写了一个业务异常的示例供参考,注意名字啊,我个人觉得起得挺牛掰的,看名就知道什么错误。另外一点,如果把代码再写细一点,做一个业务异常的基类并让所有的具体异常从它继承,就可以使用一些全局异常处理机制,比在每个方法里做try...catch要强得多。
public class DeploymentApprovalFormCreationException extends Exception { public DeploymentApprovalFormCreationException() { super(); } public DeploymentApprovalFormCreationException(String message) { super(message); } public DeploymentApprovalFormCreationException(String message, Throwable cause) { super(message, cause); } public DeploymentApprovalFormCreationException(Throwable cause) { super(cause); } protected DeploymentApprovalFormCreationException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); } }
2、资源仓库接口
除业务异常另外需要着重说明的是“资源仓库接口”。有些工程师会把资源仓库当做DAO来用,这个其实是一种误用。资源仓库要包含哪些接口和您的业务是强关联的,再说直白一点就是由业务来决定资源仓库有哪些能力。两者的目的有着本质的不同:DAO用于操作数据模型,资源仓库用于操作领域模型。系统在实现的时候,领域模型最终还需要变成数据模型才能进行存储和索引,这也是资源仓库该干的事情。
具体来看,领域模型一般会有大量的嵌套对象,各对象间关系复杂,而且也不是所有的对象都需要进行持久化;数据模型相对简单得多,常用的关系型数据库对应的模型也不过是一种二维关系。想要实现两者的转换已经不仅仅是引入一个简单的设计模式比如工厂就能解决的了,还需要再应用一种更为复杂的设计来实现领域模型和数据模型的解耦合,引入“资源仓库”可以达到这个目的;另外,资源仓库还能约束您在设计的时候要以业务模型为驱动以避免陷入面向数据库设计的情况。为了实现洋葱架构的效果,设计时还需要把资源仓库的定义与实现进行分离。由于定义一般是以接口的形式,所以并不会为BO层引入更多的针对基础设施的依赖,不得不感叹DDD的那些先驱还真是聪明。
这里有一个问题,为什么说需要根据业务的需求来设计资源仓库呢?举一个例子,在我们常见的电商购物业务中有“下单”概念,下单的一个重要步骤是对订单模型进行存储,也就说资源仓库应当具备订单持久化的接口;支付后把订单的状态变成已支付,说明还需要有一个从存储中查询订单的能力和变更订单信息的能力,分别对应查询和编辑;订单一般不能删除只能作废说明不需要有删除的需求。针对上述需求,我们发现订单资源仓库应当只有三个接口:1)查询单个订单;2)更新订单;3)存储新订单。具体到查询单个订单的接口,其参数是订单ID还是编号亦或是其它的信息,也是需要根据业务来定的。通过这个案例,相信您应该明白了资源仓库设计的依据是业务而不是数据存储,DAO才是面向数据的。不同的DAO虽然对应的数据模型不一样,但有一些基本的功能是通用的比如:增加、删除、更新等。由于责任单一且不包含业务逻辑,一般都会将DAO作为基础设施层中的组件。
资源仓库接口的实现逻辑上属于基础设施层的内容,系统设计过程中我个人一般会将其与基础设施层分开至不同的包中。此外,真实项目中一般也会设计一个资源仓库的基本接口,毕竟大部分场景中都需要对领域模型进行存储、变更和根据ID查询的能力。下面代码展示了两个不同业务的资源仓库接口的定义,您需要注意两点:1)资源仓库接口所在的包应该是BO;2)资源仓库所定义的接口应该由业务来驱动的。
public interface Repository<TID extends Comparable, TEntity extends EntityModel> { /** * 根据ID返回领域模型 * @param id 领域模型ID * @return 领域模型 */ TEntity findBy(TID id); /** * 删除领域实体 * @param entity 待删除的领域实体 */ void remove(TEntity entity); /** * 删除多个领域实体 * @param entities 待删除的领域实体列表 */ void remove(List<TEntity> entities); /** *将领域实体存储至资源仓库中 * @param entity 待存储的领域实体 */ void add(TEntity entity); /** * 将领域实体存储至资源仓库中 * @param entities 待存储的领域实体列表 */ void add(List<TEntity> entities); /** *更新领域实体 * @param entity 待更新的领域实体 */ void update(TEntity entity); /** *更新领域实体 * @param entities 待更新的领域实体列表 */ void update(List<TEntity> entities); }
Repository接口是所有资源仓库接口的基类,包含了新建、更新、根据ID查询和删除四类基本操作。有人说资源仓库的接口都应该使用业务术语,类似于“update”、“add”已经偏向于技术,应当使用如“save”代替。我个人觉得这么搞其实挺麻烦的,存储的时候还需要区分到底是插入还是修改,代码会很脏。不过使用业务术语表达每一个接口这个倒是个很重要的规范,您应该遵守。另外有争议的是“delete”接口,这接口其实不应该有,可能也是因为设计时脑子抽了才加上的,您在实践时干掉即可。有了基本接口后,下面就可以基于此来定义业务级资源仓库接口。
package xx.workflow.bo.opresourceapply.repository; import xx.common.odd.repository.Repository; import xx.workflow.bo.opresourceapply.OprApplyForm; public interface OprApplyFormRepository extends Repository<Long, OprApplyForm> { }
“OprApplyFormRepository”所面向的实体是“OprApplyForm”,其中没有再定义任何其它接口,说明只需要使用“Repository”中的能力即可。
package xx.servicedeployment.bo.repository; import xx.common.odd.repository.PersistenceException; import xx.common.odd.repository.Repository; import xx.servicedeployment.bo.DeploymentDetail; public interface DeploymentDetailRepository extends Repository<Long, DeploymentDetail> { /** * 根据部署审批单查询部署详情 * @param deploymentApprovalFormId 部署审批单ID * @return 部署详情 */ DeploymentDetail findByDeploymentApprovalFormId(Long deploymentApprovalFormId) throws PersistenceException; }
“DeploymentDetailRepository”中多了一个“根据部署审批单查询部署详情”接口,说明某个命令型业务中有需要根据“部署审批单”查询“DeploymentDetail”这个业务实体的需求。其它有关“DeploymentDetail”的能力仍然从基类中继承。
根据上面的演示,您可以看到资源仓库接口的定义遵循了前面所说的全部规范尤其是其所操作的对象都应是领域模型;您应该也看到了类似查询“XXX信息列表”这种单纯用于查询的方法并没有出现在资源仓库接口中。
二、业务模型层中的代码结构
根据上图所示,您已经明确了BO层所包含的元素的种类。我们前面说BO层很厚,这么多东西都在这个层里,想不厚也不行啊。如果落实到代码中,这些元素一般会统一放到一个包中,包名即为业务名,如下图所示。针对包的组织,我建议这么做:根据业务能力将服务分成几个子BC,以包的形式组织这些BC。比如订单服务中需要包含两项业务:订单管理、发货单生成,那针对这两项分别建立两个包,每个包都按下图所示的结构进行代码组织。不建议建立如BO、DAO、VO、Service四个包,根据这些包对代码进行组织和分类。
上图展示了“审批服务”业务的代码结构,BO这个包中除了事件、业务异常和资源仓库接口,其它的都是实体类型、值类型等组件。根据业务能力组织代码,您会发现即使是一个单体的系统,在遵循了DDD设计规范后仍能具备高内聚的属性,后续如果需要拆分时只需要做一些简单的工作即可。
三、BO层访问限制
既然BO层处于系统的核心位置,根据六边型模型的要求就需要在依赖与访问控制两个方面进行约束。依赖相对简单,只要让其别依赖于其它层就OK,也就是限制这层对其它层元素的引用;特别常见的一个错误就是在领域实体中引入资源仓库接口或DAO,虽然初衷是为了提升性能,但会造成代码结构的混乱,损害了代码的健壮性使系统成为了所谓的大泥球(其实球不球的也不是重点,有了BC的隔离最多是个小泥球,胡乱的引用体现出您的工作没有规则)。访问控制方面,针对每个对象的访问级别包括public、package、private等需要进行充分考虑,做到最大化的隔离。除应用服务层和资源仓库实现层,其它层不可以直接引用业务模型。下面的代码展示了BO层中实现领域模型时的反例,供参考。
问题一:业务模型依赖Spring框架;问题二:访问了其它包的DAO;问题三:反向依赖资源仓库,记住:资源仓库实现与领域模型的依赖是单向的。
总结
本章讲解了BO层中所包含的元素,尤其对于业务异常和资源仓库接口进行了重点说明。通过本章的内容相信您已经对于所谓的六边型架构中的内核及其构成有了一个感性的认识,也为后面的学习打下了一定的基础,后续我们会深入到BO内部对各元素逐一的进行解释。