身处产业互联网掀起数字化转型浪潮的时代,日益复杂的业务不断扩展迭代,对软件系统不断地提出新的挑战,因此领域驱动重新出现在人们的视野中。现有的系统在长期迭代过程中难免会出现各种各样的边界混乱、架构冗余、开发效率低下问题,而面向业务高效迭代的落地架构是开发人员持续追求的目标。本文旨在介绍DDD领域驱动如何在复杂业务进行设计落地以及技术代码落地,提供领域驱动的设计思路与实施经验干货。
一、系统背景
1.1 系统简介
每日优鲜定位是一个面向零售云的社区零售线上综合超市,公司主要将业务场景分成 人、货、仓、配、店,而目前门店就是核心的五大场景之一,每日优鲜在生鲜行业领先优势就是靠着前置仓的业务模式突出重围的,因此门店承担着履约到用户中间的关键链路,以及一线作业人员的效率支撑,门店技术面临的挑战是,面对C端实时单量的业务场景下,如何为用户提供优质安全的商品,为一线作业人员提供高效且稳定的系统。门店作业系统,是前置仓门店的店内商品管理、作业管理,核心能力是门店货物的发货、收货、店内流转,在每日优鲜供应链和履约体系中承上启下,如下图所示,是订单履约,采购补货等业务场景的核心流程。 门店系统 = 门店作业(人员作业+机器作业) + 门店策略(店内策略+店间策略)
1.2 门店1.0
2015年,随着每日优鲜的正式创立,前置仓商业模式的试跑成功,线下的人工作业没有任何的系统化记录,导致门店作业效率低下无法被监管,优鲜采购了一套集合大仓和门店一体的仓储作业系统。因为大仓和门店是每日优鲜供应链体系下的重要的两个节点,作业有对半的功能相似度,为了使得业务高效快速运转,大仓和门店共用同一套系统架构。当时整个每日优鲜的业务架构是全国划分多个大仓,每个大仓辐射周边的门店,因此大仓和门店之间是固定的一对多的业务关系。门店1.0的系统使命是从0到1,支持每日优鲜的起步,作为一个初步的仓储系统,支撑着每日优鲜早期的业务扩张。核心系统特征是大仓和门店耦合,线下为主,系统为辅,体现在更多的业务功能呈现在PC端,对业务来说是系统更多做为已经线下作业完成后的系统操作记账功能使用。最初期门店系统在整个每日优鲜的内部业务侧重点在于履约的经营流转过程,用户在平台下单,门店收到订单后线下打印出来,拣货人员按照打印的面单去线下实际的拣货,并且将打包完成的货物交付给骑手,完成订单的作业周转,这时候系统发挥的功能相对单薄,门店1.0的系统上只有门店当前商品的总库存数量,具体陈列排布拣货流程完全靠线下把控,因此门店系统1.0的核心价值更多的是对全门店进销存的线上记账系统化。门店1.0的技术架构是一套js搭建的后端框架体系,犀牛js框架将js语言转化成java字节码从而运行在JVM虚拟机上,采用mysql主从分离,redis作为通用存储、分布式锁和队列缓存,通用存储为了提升交互性能,分布式锁用来防止数据动作并发,队列缓存则是为了缓和上游C端订单的大量冲击B端的系统并发处理量。由于系统的设计限制,每一个大仓和其辐射的几百个门店同用一套独立的js系统,这样做的优点是能够快速迭代开发,维护便捷,并且减缓了数据性能的压力。因为门店1.0系统的技术架构是一个大型的单体服务,其虽然能带来迅速迭代开发的优势,同时它的缺点十分突出,单个系统容量有限,接口性能受限制,并且由于门店和大仓在同一套系统中,大仓的业务人员操作会直接影响到门店对应的人员作业性能,二者互相干扰耦合严重。门店1.0系统的另一个重点问题就是扩展成本极高,由于历史系统架构原因,系统使用到了很多非标化的组件和系统功能,重新部署搭建一套系统的成本一周左右。随着门店数量的不断增加,大仓辐射的范围和补货运力有限,业务上会扩张新开大仓,在现有的体系下大仓和门店需要申请资源新建部署,耗费运维和技术人力成本较高。随着每日优鲜的扩张,在不同地区线下新业务扩展,尤其是每日一淘和便利购业务的扩张,门店1.0系统从最初的1套快速扩展到8套主商城系统,12套便利购系统。除此之外,一旦门店由于业务原因调整补货大仓,就意味着这个门店从账号到数据需要从一套系统彻底迁移到另一套系统,需要对每日优鲜C端B端整体全链路刷数据调整。因此门店1.0系统的运营维护成本很高,利用效率却很低。1.3 门店2.0
门店2.0系统起步于2018年下半年,当时随着每日优鲜业务的快速扩展,大仓和门店的数量在不断扩张迭代,原有的1.0系统不足以支撑业务发展,急需进行突破改善,尤其是公司将核心业务重心放在了前置仓的经营,因此作为前置仓对应的系统需要跟随着业务的迭代扩展启动进化,在物流负责人的带领下开展积极调研分析,重新对门店系统进行规划,从而门店2.0系统开展设计落地,与大仓系统进行切割,彻底的独立解耦,并且根据业务的升级,从大的单体服务变成更适合团队协作的微服务架构。门店2.0的系统使命是支撑门店扩张和大猩猩战略,门店扩张是因为业务经营范围在不断壮大,大猩猩战略则是对门店精细化运营,对门店的库存作业进行统一规范化的线上运营。其系统特征是门店系统独立解耦,核心业务功能线上数字化,体现在系统流程上是门店全量推广手持PDA设备,为即时性作业服务,脱离线下纸质作业,使用PDA设备进行线上化作业指引,通过线上系统去驱动线下作业,而不再是线下作业驱动线上系统的模式。门店2.0系统针对核心业务链路,进行了全流程线上化的功能设计,使得系统充分发挥线上数字化的转型功效。门店内的库区库位库存进行线上化映射,通过系统就可以清晰的知道门店的线下排列和当前真实库存数量情况。升级后的订单作业流程,门店2.0系统从交易侧接收订单后,按照订单配送时间在合理的时间自动进行订单的库存分配,自动推送订单给门店内的拣货人员,拣货人员通过手持PDA领取订单开始作业,并且不再依靠线下的记忆去翻找库存,而是通过PDA手持的库位指引去相应的物理位置去拿取对应的商品,从而大大提高了门店内作业人员的拣货效率,并且如果当前系统库存不足也能提前告知提醒拣货人员进行缺品处理。随着门店的业务复杂度升级,对应的系统复杂度随即而来,门店技术的团队规模也在扩大,单体服务不再适用于团队间的合作维护,因此门店2.0中进行微服务架构摸索。当时微服务架构主流的思想是量级考虑,就近原则。比如对门店系统来说,订单和补货是核心业务流程,所以订单和补货独立拆分了微服务;其次考虑业务相似度,包材和订单流程相似度较高,就划入订单微服务;剩下的部分,店内其他作业相关,用户相关,报表相关,库存查询相关,业务上不关联独立成微服务;对优鲜系统间交互逻辑独立出来划分成对外微服务,仅承担转发功能;所有业务的定时任务拆分成统一的任务微服务;因此,整个门店2.0体系划分下来,拆成了16个存在耦合的微服务架构。门店2.0系统的技术架构是基于Spring Cloud的后端框架体系,数据库依然使用mysql主从分离,但是因为数据量和性能原因根据仓组做了mysql分库分表,同时引入了mysql的分区功能进一步提高性能,redis更多的作为缓存和分布式锁应用,引入rocket MQ作为异步驱动解耦系统间交互,Elastic Job作为定时任务控制流转和闭环。虽然上层应用做了微服务拆分,由于库存和业务单据需要事务保障,并且各个服务都直接依赖基础数据,因此通用数据基础数据和库存相关数据库表在同一个jar包内被各个服务引用,从而能够使得基础数据的查询直接走数据库表调用,并且每个服务都能与库存保持事务。门店2.0系统的代码架构层级采用的经典的MVC,拆分成三层controller,service,dao,虽然是三层的结构,但其实controller和dao层非常轻薄,绝大部分功能全部集中在service层,导致service层臃肿,内部存在多层级依赖,模块边界模糊相互耦合,如下图所示:
门店2.0系统在机器上按照业务应用拆成了多个独立的微服务,然而基于门店的业务特性,门店业务大多数都基于库存基础上,导致业务系统和库存有着事务一致性要求,因此在技术架构上各个微服务之间提取出了一个公用jar包,内容包含库存和基础数据相关的dao层;另一个问题是只有机器拆分开了,各个业务还是公用数据库,仅仅是按照门店所属区域进行分库分表,每个库中包含所有的业务库表,这就造成了微服务独立的不够彻底,仅仅在机器层面达成隔离,数据层耦合严重,并不符合真正的微服务思想。另一个问题是系统过度拆分,领域划分不合理,导致门店系统冗余,机器负载不均衡,分库不合理,数据负载不均,高负载压力大,低负载资源浪费,整体机器和数据库成本高。由于门店2.0系统按照区域维度进行分库分表,一些热点区域由于单量较高,最大单表3000W数据,线上经常会出现慢查询;而且当前大部分业务单据的流转强依赖异步任务调度,这就导致定时任务在线上数据库持续轮询执行,会导致大量慢查询。另一方面任务串行吞吐量有限,过度并行mysql性能压力受限,这就导致一些量级较大的流转效率成为门店的系统的性能瓶颈,在现有体系下难以提升。同时强依赖任务带来的风险极高,一旦任务出了问题业务流程就会严重阻塞。随着每日优鲜业务的扩张,门店2.0系统持续在原基础做加法,不断增加迭代功能,当时的技术和业务侧更多关注的是业务功能的实现度,而没有长远规划未考虑过复用性,这就造成了在业务逻辑功能迭代中复杂度不断累积,功能分散平铺造成迭代开发以及维护成本成倍上升。比如门店的店间调出、包材出库、报损出库的业务功能相似度在70%以上,但是由于系统划分在不同的业务微服务中,需要分开迭代维护,这就会导致业务功能迭代演进的时候工作量和风险需要翻三倍。业务复杂,功能分散,维护迭代成本高,产生的业务影响就是变更需求的迭代效率变慢。对于新增类需求,由于未做好抽象复用,新增业务形态需要从0开发,整体的需求交付效率成为瓶颈。1.4 门店3.0
2020年在多个互联网巨头投入生鲜电商赛道的背景下,新兴的一批生鲜公司也融资势头正猛,每日优鲜独创的前置仓模式开始被模仿,在这一系列大环境因素影响下,公司战略上考虑三个方向,第一是用户体验,在强敌环伺的背景下提高用户留存,核心是完成门店对于批次效期的精细化管理,从而保障用户的食品效期安全体验;第二个是单量增长,提升生鲜赛道的市场占有率,对应的系统需要平稳承载更多单量;第三个是布局平台化转型,考虑能将优鲜在生鲜电商赛道上的前置探索的业务和技术功能开放服务不同商家租户,达成公司的第二增长曲线。这些公司战略对技术上的要求是具备快速迭代、横向扩展的能力,所以系统的服务化是必经之路,由此门店3.0系统正式开启帷幕。门店3.0的系统使命是面向精细化运营,保障用户体验,提高系统效率降低扩展成本,同时面向未来长期趋势做长远布局,为自动化和智能化的远景预留扩展接口。系统特征是完成服务化的演进,为平台化奠定坚实基础,为未来的高效迭代做好充分准备。在门店3.0系统中,对应的业务功能上变化不大,更多的是为未来业务做好排布扩展。当前门店更多的是服务于一线作业人员,其本质出发点是为了让系统更适应业务作业。而门店3.0系统,更多的站在门店的第一性原理基础上,本质目标是保障体验,提高效率,降低成本。不再以业务人员系统可用性为终局目标,意味着这条路依然很长需要不断迭代扩展。业务布局中新增业务功能需要在当前系统体系下快速扩展支撑,未来整体业务趋势是智能化代替人去决策,门店3.0的高效扩展性是主要目标。基于长期架构考虑,门店3.0的技术架构采用了领域驱动进行落地,核心在于业务和技术的统一结合,技术架构服务于业务,才能更好的支撑业务的扩展,自身的技术架构才能走的更加长远。根据团队规模和业务架构,划分合理的微服务领域。技术架构上使用Spring boot框架,在一个较为完善企业内部技术部门,组件、网关、用户等都由公司基础团队统一维护,不需要每个系统自身构建完善生态体系,业务系统其实更专业聚焦在自己的业务领域的时候,因此dubbo比起Spring boot更适合当前环境技术选型,统一使用dubbo长链接对公司内部系统进行交互提高性能,使用http对接网关服务从而灵活对接前端应用。数据库采用mysql的sharding-sphere分库分表,并且引入了基于ES的crate DB进行数据复杂联合查询, 使用redis作为缓存和分布式锁,应用Rocket MQ作为事件驱动从而达到服务异步解耦的效果,采用Elastic Job作为定时任务和容灾兜底服务应用。代码架构上使用DDD的经典四层框架,接口层、应用层、领域层、基础设施层,以业务逻辑所在的领域层微项目核心,基础设施层依赖领域层实现具体仓储的功能,并且包装了对外的交互功能,应用层是方法、任务、消息等的服务入口,核心完成转化校验功能,整体结构如下:当前门店3.0系统已经在2021年3月份完整落地上线,已经在线上平稳运行一个季度,历经过大促的考验和后续业务的需求迭代。笔者总结了DDD架构落地中的设计和实现方法,供给业务系统探索DDD架构作为经验参考。二、领域设计
领域驱动的核心思想是,技术架构与业务架构相互匹配结合。过去技术开发更多的是站在技术的角度上去设计,只关注数据模型,面向数据模型编程,这个会导致技术的思维离业务原本的需求甚远,并且随着业务的不断演进迭代,数据模型越来越复杂,技术软件无法跟随业务的发展快速迭代。技术架构不能脱离业务理解,在互联网发展过程中,产品经理的角色必不可少,在DDD中产品经理相当于领域专家,技术架构需要在业务基础上进行设计才能走的更加长远,如果你会技术架构,能做好一个单体系统,如果想要长远的满足业务迭代,必须要有业务思维,才能布局长线的服务化架构。并且在实际落地上,我们会发现,业务性强的互联网功能会更适合使用DDD的思想和架构全盘落地,而偏基础型与业务关联度不强的功能如果使用DDD落地会比正常架构冗余,领域驱动的优势没有业务系统那么高。2.1 判断决策
如何判断你的系统是否需要进行重构呢?一般大型系统的演进有两种思路,一种是在原有架构基础上不断小规模迭代优化,另一种是从0到1推倒重构。那么如何判断一个系统该不该推倒重来呢?核心决策权不在技术,而在业务。如果一个业务基本固化,只有小规模业务迭代,整体上产品业务没有对他寄予业务快速扩张发展的前提,业务量也在2-3年内不会突破三倍以上,那么基本就没有必要大型重构。如果不满足第一个条件,那么来看整个系统的业务的全景架构,如果一个系统没有基于全局的业务架构,那么在业务领域没有划分清晰之前,内部所有的技术迭代都是徒劳。因此,如果一个系统没有业务架构全景,那就重构吧,重新建立一套面向未来可扩展的系统架构。因此,对一个业务系统的技术架构来说,最重要的第一步是业务架构。2.2 战略设计
基于DDD的战略建模设计,其核心思想就是,明确系统上下游的边界,并且与业务架构做匹配,从而制定合理的领域划分,从系统层面来说就是微服务划分。关于DDD的领域微服务划分,其实可以分为三个步骤:1)明确数量:按团队规模,比如说门店团队现在有10个人规模,平均1.5-2人维护1个微服务,那么门店的服务拆分在5-7个左右;2)业务架构:这里需要穷列举当前系统领域内所有业务元素内容,跟产品一起头脑风暴确认业务边界,重点是按面向人员分层,考虑不同用户对系统的不同需求功能归类,根据多维度划分业务层级,最终定稿共识。3)服务划分:业务架构基本上就已经明确了系统的边界和未来的扩展,但之所以不能直接按业务架构划分是因为业务架构往往没有考虑底层基础服务,因此进行微服务划分的时候就需要充分考虑到业务架构和技术实现。2.3 战术设计
领域战略设计中上下游边界明确,微服务拆分清楚后,接下来最重要的就是如何去战术落地。根据领域驱动在门店3.0中的落地实践,我们总结了一下领域驱动战术落地中的核心思想,欢迎大家一起讨论补充。领域驱动的核心思想是以业务为重,因此发展出来有六边形架构、洋葱架构,所有层级以领域逻辑为核心,外层是基础设施、对外调用、以及应用组装。技术框架的不应脱离业务而追求独美,而是以业务为中心,与业务完美融合,这样才能更好的面对业务扩展,与业务携手与时俱进,长久同行。DDD的架构除了业务核心思想外,最重要的就是分层协作。这个分层其实就跟分模块协作一样,从个人到多人共同协作开发的模式提升。普通MVC架构中,技术架构分层只有controller、service、dao,在项目的合作开发中,如果是MVC架构就只能按照接口维度拆分工作量,每一个开发接口的人都需要从在每一层进行开发,尤其大部分项目协作经常是同一领域模块内迭代扩展,这就会导致底层功能大量冗余,复用性极差。而分层协作最大的优势是每一层独立开发,每一层定义自己的接口后,各层级之间以接口交互,开发逻辑没有互相依赖性,各层级间的单元测试可以Mock独立,协同合作效率高。领域驱动在整个落地过程中,会存在大大小小的多层级领域,整个业务系统跟公司其他业务划分开来算一级领域,内部拆分微服务后每个微服务是二级领域,微服务内部识别出来不同业务功能是三级领域,每个业务功能涉及到多个业务实体,实体层面是四级领域。涉及到多领域之间协同的时候,无论是在哪一层级,都会站在两个服务交互的角度上去思考方案,尽可能的降低两个领域的交互耦合度,在整个领域驱动中是不建议在两个领域实体之间用事务捆绑的,而普通的MVC过程中,由于将不同领域耦合在一起,往往没有两个领域耦合的概念,造成大量的领域交互,长事务控制,这就造成了一旦团队扩张,一些业务发展到可以独立出去的时候技术上很难拆分,只能从头重构成本高昂。设计思路明确后,接来下是战术执行,在DDD的实际落地中,我们可以拆分成以下几个步骤:对于业务系统来说,最重要的是将业务流程梳理清楚,从业务的初始态到业务的终态流程中,一般会存在多状态流转,每个业务动作连接着业务状态的迁移流转,这是业务的核心脉络。在做实体识别的时候,重点是区分出来实体和值对象,实体是存在唯一键可以区别,而值对象只关注数据内容,这个区别就像是==与equal的区别,实体的判断和对象的==判断一样,虽然对象内参数可能相同,但是由于实体唯一标志地址不相同而区分开来,值对象则是equal判断,更多的只关注参数的内容,只要参数内容相同就可以认为是完全相等。一般来说,业务核心的数据是实体,外领域服务的实体在本领域内则使用值对象足够,本领域的一些查询结果可以包装成值对象。实体和值对象已经罗列清楚后,下一步就是要对实体和值对象进行领域聚合划分,这里实际落地时候可以参考表结构设计,一个独立的表结构可以代表一个实体,也有多表构成一个聚合,比如业务的明细表,业务明细是不能脱离业务主表存在的,它的变动会带来主表的频繁变更,因此业务主表和明细表是一个聚合。将服务中的所有的聚合识别出来,我们的领域和动作都是围绕着聚合展开。领域动作往往大部分直接与线下业务真实的动作相结合,通过业务动作,聚合数据从某个状态变更到另一个状态,或者部分的数据更替。在MVC技术体系内,数据变更往往就是dao层的update语句,上层可以被任意地方调用,这就导致在梳理业务数据变更过程中,调用方法散落在各地,业务梳理理解以及扩展性都极差,而在将业务逻辑变更封装在领域动作内,直接进入聚合就可以直接看到业务的每一个流转动作入口,从而新人能快速的上手业务逻辑,更符合技术追求的长期目标,代码可读性高易传承扩展。领域内部的变更是通过领域动作进行的,但是往往有一些业务动作,会同时变更多个领域的内容,这里就需要使用到领域服务。在领域服务内,通过逻辑组装同步对多个领域动作进行调用,同时组装跨领域之间的事件驱动发送。这里要注意,单领域的变更无需升级成领域服务,领域服务相对复杂性高,在领域设计中是越少越方便理解。随着业务的复杂性增加,多个领域之间一定会出现业务相互影响的场景,而直接依赖会使得两个领域的耦合度较深,领域事件在这种情况下应运而生,使用异步交互方式更好的在多个领域间解耦。当然有人会说消息驱动一定会带来业务风险的增加,一项技术带来业务解耦风险的同时,也带来一定的消息堆积/消息丢失的风险,针对业务强一致性和及时性要求非常高的场景,对此在技术上可以用状态标志位兜底任务和双重延迟消息来进行保障兜底,从而既能在领域服务间风险隔离业务解耦性能提升,也能保障业务的可靠性。上面是DDD落地的抽象知识,接下来以门店中的所获业务为例,全程按照领域战术步骤进行落地。锁货单入口:一种是其他业务生成锁货单,另一种是业务人员申请,目的都是让库存暂时不可用;锁货单作用:用于不确定库存是否正常的场景下,如实物暂未找到,实物存在问题不确定是否可卖等情况下,防止库存超卖。创建锁货单:
已保存
锁货单审批通过:
已锁货
锁货单审批拒绝:
审批拒绝
部分解锁:
处理中
全部解锁:
已处理
取消锁货单:
已取消
锁货一次申请可以申请多种原料多种批次的明细;申请后需要确认具体扣减的SKU-批次-库位维度的库存,防止被其他单据预占;预占库存中心库存后,会给每个维度库存唯一码transactionCode,防止一条库存被后续操作多次;单据可以部分操作,每次操作可以操作多条明细数据; 所有的锁货动作全都由锁货聚合实体作为变更入口,外部没有更改入口。 1.审批
2.解锁
3.取消
梳理本业务流程中对外交互的地方,区分是同步调用还是异步调用,跨领域之前尽量用异步消息方式,解耦避免同步的一致性问题,如果有业务必须使用到同步变更调用方式,就需要考虑是否是核心链路,如果是核心链路同步调用,需要考虑使用异步方式来做两个系统的一致性校验补偿监控。 1.发货模块发送创建锁货单MQ给库内,库内消费后生成锁货单;
2.锁货审批和解锁动作下,发送MQ异步驱动通知ATP售卖库存;
3.审批通过后,同一个MQ异步判断全门店的锁货总量,超过业务数量800,大区群内通报,及时让督导和管理层知晓;
4.同步调用库存中心的扣减接口,做两个系统之间的一致性校验,报警和补偿;
5.同步调用库存中心的增加接口,做两个系统之间的一致性校验,报警和补偿;
三、技术落地
上面这些主要是领域驱动的设计,从战略设计到战术战术,对应到我们项目的开发中都是前期准备和技术设计,那么实际进行代码实现应该如何落地是接下来重点介绍的内容,只有落地下来经得起实际业务运营的DDD才是真正经得起考验,而非纸上谈兵夸夸其谈。3.1 代码框架
DDD经典四层结构(接口层、应用层、领域层、基础设施层),将这四层分别映射为:1、除RPC外的其他类型对外交互接口(针对http协议的controller、针对mq协议的mq listener、自触发的task);
2、应用服务层(service);
以门店店内服务举例,sms-internal的整体结构如下:
sms-internal
| -- internal-api
| | -- pom.xml
| -- internal-application
| | -- pom.xml
| -- internal-domain
| | -- pom.xml
| -- internal-infrastructure
| | -- pom.xml
| -- pom.xml
其中pom的继承关系如下图:
3.2 分层规则
不同于MVC架构以数据为核心,DDD架构以domain领域为核心,在这种模式下弱化数据存储结构,强化领域本身的概念。基础设施层和应用层都依赖于领域层,基础设施主要是为了实现领域层的仓储接口、MQ产生接口,在实现的内部中,做到了对外的数据存储层,外部依赖层的防腐功能,应用层则是主要承担着程序的入口,将外部的入参转化成领域层的通用入参,领域层的实体和值对象返回,在应用层组装成各种需要的返回格式。
应用层 application | 承担业务组装和校验转化+单测:
controller承担参数校验和字段转化 impl承担通用的校验和转化功能,不需要定义接口再实现接口逻辑 mq.consumer承担消息消费功能,纯通知类型直接调用基础设施,涉及业务逻辑的必须调用领域层 task 承担定时任务调度功能,纯通知类型直接调用基础设施,涉及业务逻辑的必须调用领域层
dto 承担对外对象定义功能 factory 承担聚合和实体值对象的构造功能,只有涉及第一次保存类的才需要构造实体,其他都是走仓储查询
test承担单元测试功能,各层级的单测入口和mock代码; |
领域层 domain | 承担业务逻辑:
aggregate承担聚合功能,聚合业务逻辑入口都在聚合实体中,聚合内部由实体和值对象组成,聚合不应该再引用聚合,不允许set方法,不能引用仓储接口(聚合必须有唯一码标志) entity 承担实体功能,独立业务的实体里面就包含业务逻辑入口,实体可被聚合引用,不允许set方法,不能引用仓储接口;(实体必须有唯一码标志) valobj 承担值对象功能,无唯一码标志 constants 承担领域内静态变量; enums 承担领域内枚举; service 承担领域服务功能,非跨领域的逻辑直接放在聚合/实体中,本层引用仓储接口和mq发送接口实现跨领域调度逻辑;
mq.producer承担发送消息的接口定义(实现在基础设施层) repository 承担仓储的接口定义(实现在基础设施层) |
基础设施层 infrastructure | 承担对外交互和持久化交互:
adapter承担对外功能,对外接口必须封装adapter方便做容灾; missconf 承担配置对接功能,配置的调用只能封装在仓储内,不允许应用层领域层直接调用missconf repository.impl 承担仓储实现功能,内部可调用adapter,或者持久化数据库完成查询和操作功能,只能返回实体/聚合/值对象/基础类型; mq.producer.impl 承担mq发送实现功能,配置topic等一系列逻辑配置
constants 承担基础类型和交互使用的静态变量; utils 承担本模块领域内跟业务相关的通用方法;(跟业务无关的通用方法抽象到sms-utils jar包中) |
3.3 常见问题
在进行DDD分层设计之前,笔者也调研过网上多种DDD的框架,来聊一聊在DDD框架落地走过的坑,一些DDD的分层区别和误区。1)Domain实体和值对象中能否直接引用自身或者其他领域的仓储方法?答案是不能,这里笔者切实进行过落地实践,如果在Domain实体内直接调用仓储方法,就会使得实体的领域业务逻辑被技术框架冲击,使得领域实体变成了一个类似应用层的存在,这种架构相当于直接将MVC框架中的某一领域的Service改名成实体,与DDD的分层框架原则相背离。2)仓储repository能否直接引用其他领域的仓储方法?同理是不能,repository是一个独立的仓储,一个仓储对应的功能只能有两种,拿取和放置。因此,仓储中封装的其实就是领域实体/值对象的获取与保存,只涉及自身领域仓储的存放,涉及跨领域的存放就需要上升到领域服务层,并且这里要注意,仓储方法只能被应用层或者是领域服务调用。领域服务是最容易被曲解的概念,不是所有的业务逻辑都应该做成领域服务,而是涉及到多个领域的逻辑变动的组合。一般来说,应用层可以直接调用仓储接口,所以理论上跨多个领域的逻辑组合就技术层面上来说放在应用层也是合理的,DDD的核心是面向业务领域,因此所有涉及到业务的逻辑功能需要映射到领域层,就有了跟业务结合的领域服务。这里要注意涉及多个领域的查询组装不涉及数据变动直接放在应用层组装即可,无需放在领域层。4)仓储repository与防腐adapter的关系处理?防腐adapter层主要面向外部应用,对外所有的应用需要经过防腐层的包装,转化成内部使用的实体,这样是为了使得自身服务对外部依赖度更低,避免因为外部接口变动对自身逻辑的侵袭,并且可以更好的做数据降级以及数据mock。仓储是面向应用层的,主要负责领域数据的拿取和存放,这里不要简单的理解成应对数据库,因为在云服务的今天,数据库也完全可以是远程的服务。因此仓储层是存在直接调用adapter层的场景,反之则不会。5)应用层,领域层和防腐adapter层的关系处理?领域层约束禁止调用adapter层,应用层建议非特殊情况不要直接调用adapter层。领域层是最底层,基础设施层依赖领域层,而防腐adapter层的接口定义都在基础设施层,因此天然框架约束上,领域层就无法引用到adapter层。防腐adapter层是直接跟外部服务打交道的,他返回的是外部的对象内容,普通的DTO,而真正被本服务领域层使用的是领域实体和值对象,领域层,如果涉及到从外服务拿取数据,比如常见的基础信息是从外服务获取的,这种需要封装在仓储repository内部才能被上层引用。但是在落地过程中,比如一些多服务之间交互的异步调用,既可以直接走消息调用,也可以是消息自产自消直接去调用外服务的接口,这个时候如果在应用层接收到消息实体内容后,如果还要转化成实体值对象调用仓储层,而仓储层将实体值对象再转化成外系统的入参DTO,就涉及到类转化频繁,这种无业务逻辑的场景下建议可以在应用层直接调用adapter层,涉及到业务判断等场景都一律走仓储模式。DDD虽然有很多优势,但是在实际落地过程中,其中最大的对技术不友好的一点是每一层级都涉及到类的转化。如果对应的业务比较简单,既没有复杂的状态流转变化,也没有丰富的领域动作,例如基础型服务来说,因为自身业务场景限制,导致领域实体内方法少,接近于贫血,这种情况下严格按照DDD框架进行落地的优势就会不那么明显,并且类频繁转化对于数据层经常扩展变化的基础型服务来说改动成本比较大,建议可以对框架缩减弱化领域层概念。3.4 DDD优势
在进行DDD进行门店3.0重构落地,并且经历了这个中大型项目上线平稳运行一个季度之后,笔者来聊一聊基于DDD的实际业务落地,笔者眼中的DDD落地优势。在分层框架中清晰的将业务逻辑拆成应用层,领域层,基础设施层,基础设施对上层暴露的是仓储repository,仓储只对外暴露领域实体/值对象的获取与保存接口,因此,涉及到业务的逻辑变动,只要数据模型不变动,仓储层就不会被波及,如果没有跟前端的交互变动,应用层也无需变动,核心业务逻辑仅仅需要在领域层进行变更。而领域层由于入口统一,也就避免了MVC框架下写在service层新老接口多处方法接口维护成本高的风险。并且封装了接口最大的好处是,内部实现逻辑进行变更对外部来说接口不变,比如存储数据源替换,从mysql换到es或者mongo,只需要在接口实现的内部进行逻辑变动,外部接口调用无需改动。DDD非常符合依赖倒置原则(Dependence Inversion Principle),程序要依赖于抽象接口,不要依赖于具体实现。DDD落地除去APi定义外分成三层,每一层独立抽象进行编程。应用层是提供对外——前端和外服务的接口,领域层是提供领域实体方法,领域服务,基础设施层提供的是仓储方法接口实现,将仓储接口定义放在了领域层,使得领域层是依赖倒置成包层依赖的底层。在进行开发协同时候,就可以分层分派工作量,每一层提前提供接口,各层级之间不需要关注其他层的内部逻辑就可以进行协同开发。DDD最大的优势是将所有的业务数据变动封装在了领域实体方法和领域服务内,这样对于研发来说就有了一个非常大的优势,所有的业务逻辑一定经过领域层。将核心业务逻辑抽象出来,不与多方入口耦合,一般来说对一个业务动作来说,对外可能有多种入口不同入参,手持终端,PC电脑端,定时任务,上下游MQ触发等等,其实来自不同的入口,核心要做的业务逻辑是统一的,这些都被抽象成一个领域实体内的领域方法,我们梳理业务逻辑如果要从外层入口,就会造成可读性差技术理解成本高,而在DDD的框架下,就可以直接从领域层出发,能迅速知晓本领域的所有业务逻辑内容,再循着领域方法被调用的地方,就可以梳理清楚所有的对接入口,尤其是对于复杂业务来说,DDD代码的易读性和可传承成性高。作为技术架构来说,衡量其带来价值很重要的一点是具备可扩展性,能够跟随业务发展的趋势而灵活成长。一般公司在业务初期,由于人员比较少,会划分比较少的服务来方便进行维护,随着业务的不断迭代扩展,团队规模壮大后,为了方便各团队之间明确边界责任,系统服务往往要进行切割划分,普通MVC架构基本上就会面临着大型重构,因为普通的MVC对于业务逻辑交互没有明确约束规则,就会造成大量业务逻辑耦合拆分成本高。而基于DDD的技术架构,由于一开始就划分明确了领域边界,跨领域的交互同步走领域服务异步走领域事件,仓储层是完全隔离的,对于微服务的拆分非常方便,低成本为技术的扩展性打下了良好基础。
3.5 落地技巧
在大的DDD落地框架基础上,实际落地的时候为了更好的让大家适应框架约束写法,有一些小的技巧点可供大家参考。领域层非领域动作不能变更实体数据,但是很多研发由于历史编码习惯经常会出现在应用层去直接改变领域聚合实体内容的情况,为此,我们在实体定义的时候,不使用通用的@Data的注释自动填充set和get方法,而是直接使用@Getter,Domain层领域实体和值对象只允许get获取数据进行判断,对外不暴露任何set方法,强迫所有的变更都必须要走领域方法封装变更领域数据。之前在MVC技术框架下,大家经常会出现一个参数类从上到下全盘引用,既作为controller入参,也作为为dao层入参或者返回值,导致多方混用统一类,扩展风险极高。而在分层的架构下,不同层级直接只能使用作为接口层暴露的参数类进行交互,controller层的入参出参就只能在controller层使用,为此为出参和入参增加Request、Response后缀来区分避免混用,除去应用层,其他层级不允许出现Request、Response后缀类的使用。Domain领域层的返回参数比较好直接约束,因为只能是领域实体或者是值对象,但它的入参可以是领域实体或者是值对象之外的,比如特殊的查询请求参数类,因此对于领域层暴露的仓储repository接口,增加query.dto包来存放仓储的查询接口入参类。在一个前后端分离的大环境下,会存在很多前端依赖后端提供下拉枚举值的场景,这种不应该是前端写死而应该是后端提供枚举接口,但是随着业务扩展这种接口会越来越多,每次新增下拉枚举后端都要开发新的接口的话,对后端成本太高,这里就可以对枚举进行抽象,抽象出通用枚举接口,实际枚举implements通用枚举接口,从而前端根据枚举名称调用通用接口获取到不到枚举名称对应到具体的枚举映射对象。 public interface StringWebEnum {
String getCode();
String getDesc();
}
public interface IntWebEnum {
int getCode();
String getDesc();
}
/**
* 获取枚举
*
* @param className 类名
* @return 返回信息
* @throws ClassNotFoundException 类没发现异常
* @throws NoSuchMethodException 方法没发现异常
* @throws InvocationTargetException 解析失败异常
* @throws IllegalAccessException 转换失败异常
*/
public List<Map<String, Object>> getCheckEnumInfoByClassName(String className) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
//获取已加载枚举(为性能考虑优先走本地缓存)
Map<Object, String> valueMap = ALL_ENUM_MAP.get(className);
if (MapUtils.isNotEmpty(valueMap)) {
return toList(valueMap);
}
//反射获取枚举类
Class<?> clazz = getClass(OutboundConstant.ENUM_ADDRESS + className);
if (clazz == null || !clazz.isEnum()) {
clazz = Class.forName(OutboundConstant.ENUM_FACADE_ADDRESS + className);
if (!clazz.isEnum()) {
throw new BusinessException("入参错误,[" + className + "]非枚举名称");
}
}
//获取所有枚举实例
Enum[] enumConstants = ((Class<Enum>) clazz).getEnumConstants();
Map<Object, String> enumDesc = new HashMap<>(16);
for (Enum en : enumConstants) {
if (en instanceof IntWebEnum) {
Integer code = ((IntWebEnum) en).getCode();
String desc = ((IntWebEnum) en).getDesc();
enumDesc.put(code, desc);
}else if(en instanceof StringWebEnum){
String code = ((StringWebEnum) en).getCode();
String desc = ((StringWebEnum) en).getDesc();
enumDesc.put(code, desc);
}else{
throw new BusinessException("入参错误,[" + className + "]非web提供枚举名称");
}
}
ALL_ENUM_MAP.put(className, enumDesc);
return toList(enumDesc);
}
4.6 未来展望
在DDD的理论研究和落地实践后,笔者有思考过DDD能否更往前演进。当前只有领域层按照依赖倒置的原则进行反转,使得领域层在模型的最底层。而普通的MVC架构基础设施层也就是dao层是最底层的,按照技术推演,其实基础设施层涉及到对外接口,数据交互,这一层更应该是在依赖最上层,如果要将基础设施层抽象到依赖最高层,那么就需要设计合理的抽象接口,使得它与应用层依赖关系倒置,那就是将adapter接口的定义抽象到应用层,这样基础设施层只有逻辑实现,就可以反转成模型最外层。这样一般技术代码的配置文件,对外依赖xml等等都合理的放在本应对应的基础设施层,使得应用层可以专注于对接前端和对接外系统的逻辑组装封装功能。如下图所示理论推演完全符合DDD的思想,笔者会继续在未来的应用落地中进行技术演进学习探索,持续进化。
四、团队运营
上面这些已经介绍了具体的落地过程中的设计和代码落地经验,除了这些之外,还有一个不可忽视的部分是整个团队的运营。因为基于DDD的技术架构,一般不会是个人在单打独斗炫技,而是整个团队一起向着新的目标方向演进,在这过程中团队上从传统MVC架构向着完全不同思想的DDD架构进化过程中,可能会存在一些问题,这里分享一些团队的实践经验,希望大家的团队都能锐意进取,持续进化。4.1 打造范式
DDD的思想渗透更多的是由上至下,那么必然会存在着一线技术开发人员和架构师之间的信息差,如果仅仅靠着宣贯DDD思想让团队内的一线研发各自进行落地,那么由于大家对DDD的学习程度不一,落地过程中将会出现极大偏差,因此需要有第一个完整的业务范式,从技术设计上的战略设计到战术设计,再到具体的代码实现,需要有一套可供大家直观参考的范例来协助大家更好的理解DDD的落地,以及成为一个标准的模版供给参考推广。4.2 制定规范
从架构角度已经有了提供给大家参考的业务落地范式,接下来为了团队内大家的代码落地不受到各自领悟能力的影响,因此需要将DDD的落地过程规范下来,对每一层级,每一个包名下的应该有的功能进行规范制定,从而统一约束,像上面落地技巧中提到的,能把规范约束在系统里的尽量做成系统化,不能在系统中约束的尽量从名称解释上体现,整体的规范与大家提前共识清晰,从而保障整体的DDD落地不走偏。4.3 运营检查
即时我们有了完整的范式,标准的规范,在一线研发实际落地过程中,依然存在着个人理解歧义的风险,怎么在运营中规范约束避免这种情况,就需要我们相对于MVC,有着更全面的运营流程。在技术设计评审阶段严格把关,在技术落地阶段,考虑三段Code Review:第一段CR是领域框架,让开发人员在介入开发的前两天,重点是搭建代码分层框架,定义每一层的类名和方法名,包括领域层的类设计,方法内部可以空缺,这样的话,第三天的CR就可以检查整体的代码架构是否符合设计;第二段CR是中间案例,开发中间阶段,让研发人员重点完整的走一个业务案例的全流程,检查是否有实现偏差,如果存在问题可以及时矫正处理;第三段CR是全部检查,开发尾声,跟传统的CR流程一样,对各个层级的所有流程进行检查,这里更偏向于性能实现,并发兼容,容灾处理等通用代码问题场景的检查。4.4 分享讨论
DDD的架构经过了落地和系统的上线,为了进一步的让大家对思想深入了解,并且方便新人快速上手,团队梯队可以根据不同服务对DDD的设计落地过程进行总结分享,方便团队一起深入交流,碰撞火花,并且可以作为经验向外团队输出,不仅提升团队人员对DDD的掌控力,从而也能提升团队人员的信心度和技术输出。基于DDD的架构虽然已经基本落地,业务架构上趋于高位,但是长期来说,不管是基于稳定性监控还是容灾能力,还是有很多地方可以持续的去完善成长,从而能够在技术框架中越来越好。