有赞 | 领域建模在有赞客户领域的实践
作者:Joker
部门:有赞美业
一、What's DDD?
从定义入手:
DDD全称Domain-Driven Design,即领域驱动设计,由Eric Evans于2003年提出。那既然是一种设计方法,ddd的作用对象是什么呢?这个问题光从定义是看不出来的,我们再往下看看。
换一个更高的视角:
我们在谈论到架构设计的时候,可以简化为三个层面:系统架构、技术架构和业务架构,这三者从三个不同的视角来描述我们的系统。系统架构关注系统的架构分层,技术架构决定使用的技术栈和框架。而作为一个偏向业务开发的工程师,我们日常施展拳脚的平台离不开业务架构这一层面,它根据业务需求设计相应的业务模块及其关系,决定了业务系统是否有足够的灵活性来面对业务的发展。领域驱动设计 就是用来做业务架构设计的一种思想方法。
二、为什么要使用DDD
当前业务系统的现状是?
把笔者接手过的应用比喻成一个大泥球是一点也不为过,各种各样的业务功能杂糅在一起,不论是从应用名还是目录都无法确认该应用的功能边界。这种大泥球形态的杂糅不仅造成业务边界的不清晰,维护困难,也加大了系统崩溃的风险,你永远不知道哪个业务方会突然写出一个bug从而影响应用上的所有功能。
分而治之的思想
分治的思想在降低复杂度的问题上从来都没有过时。比起所有功能都糅合在一起的大泥球,DDD通过在其战略设计层面对限界上下文的划分来做到这一点。各个业务领域内关注自身能力的内聚,明确分工,不耦合其他业务领域的能力,这不仅符合 高内聚-低耦合 的架构思路,更是与当下 微服务 拆分的思想不谋而合。
编程是对现实世界的抽象
这句话深得我心,尤其是在面对复杂业务设计的时候,过于流程化的视角容易使我们的抽象失去重心。传统分层开发模式 action/service/dao 所导致的贫血模式就存在这样的问题,service层只关注行为,而do/dto等对象只关注数据,这在简单业务阶段其实是很敏捷的一种开发思路。然而随著业务趋于复杂,你可能会发现service层的行为逻辑很难再直观地与业务功能点对应起来。从一个功能接口进去,直面的就是层层嵌套的判断循环逻辑与数据对象,而数据对象又不一定能够直接映射为业务核心对象,这无疑是增加了业务理解的难度的。
相反地,DDD通过在其战术设计里提倡的富血模型(实体里既有数据又有行为),使得我们的代码抽象能够更加贴近业务与现实,毕竟在现实场景里,数据模型与行为总是不可分割的。
三、我们对DDD的一点实践
出于本身所负责的业务关系,接下来我们会以客户领域来作为DDD设计实践的切入点。这个设计又分为两部分,战略设计 & 战术设计。
3.1 战略设计
3.1.1 划分限界上下文
战略设计的概念其实通过各种DDD书籍博客都能找到相关的定义,我们更想传达的是一种战略设计的思考方式,其最终目的是通过建模,划分"清楚"这个业务领域的边界,以指导开发。
对业务领域的设计,我们的做法是先梳理业务流程。客户领域这个定义本身很宽泛,那我们要怎么来描述它呢?通常人在没有经过专业的模块化思维训练的情况下,时间是我们描述一个业务的基本维度。而在美业的场景下,商家与顾客作为业务的参与者,在同一时间下的行为表现又各有不同,因此他们被选取为描述流程的另一个维度,即业务角色。除此之外,不论是在流程梳理还是后续的模块划分中,我们始终要聚焦的一个点就是业务领域的业务目标,对于当前美业客户领域来说,业务目标就是帮助商家提升用户的ARPU值。而ARPU值是通过顾客的消费行为来累计的。综合上述的三点思考,我们整理了以下流程:
整体流程以客户消费的阶段作为划分,从商家和顾客的视角描述了与客户领域相关的业务行为。其实从严格意义上来说,居中的消费中阶段并不是客户领域要关注的东西,只保留消费前和消费后的两个时段也能覆盖客户领域的业务内容。这里我们为了时序完整性而保留了这个阶段,但只对其作笼统的描述。
业务流程图在这里起到了一个帮我们梳理业务行为的作用,但要进一步完成战略建模设计,接下来还要对这些行为进行合理划分。实践中我们还是围绕着业务目标来做,流程图帮我们筛选出了有助于提升客户ARPU值的行为,那这些行为分别是怎么对我们的业务目标产生影响的呢?按照这个思路,我们将客户领域划分成两个子域:客户成长 和 客户运营客户。
成长领域 :关注点落在客户个人的生命周期。从客户注册到产生第一笔消费并通过消费行为获得成长值、积分等一系列跟生命周期相关的为提升客户ARPU值而做的业务,都纳入客户成长领域
客户运营领域 :则更加侧重从群体视角和活动的维度去把控对应要采取的运营动作(例如对客户进行分群营销、做一些客户跟进、做RFM客群筛选等)
很多的文章在做战略建模的时候都会提到问题空间和解空间的说法,我们的实践中好像都没有体现这两个部分。其实我们回头看看业务流程中的那些业务行为,不就是一个个问题的解法嘛。而在梳理过程中我们聚焦的业务目标,不也正是问题空间拆分的源头。两者本质上的思想都是为了解决一定的业务问题而梳理出来的一系列业务行为和解法。
以客户成长领域为例 划分完大方向后,我们需要更进一步,对各自领域的内部进行更细粒度的划分,识别子领域。在对客户成长领域的定义中,其实我们已经对它的职责划分得很清晰了:
来看下客户领域内部的划分: 首先,客户子域作为核心域,自然包含了客户领域最核心的客户对象,客户生命周期的相关行为都归属于它。除此之外还涵盖了客户的个人档案信息、行为数据等核心的内容。其次,等级会员和付费会员的两套体系服务于客户身份转变的能力,自然抽象成两个支撑子域,涵盖了相应的付费规则、入会门槛等功能。标签子域则将客户打标筛选的能力收入囊中。最后,在积分子域的划归上其实我们经过了比较激烈的思想斗争。首先是潜意识里,当我们听到积分的概念很容易都会跟客户联系起来,但是从业务性质的定义上又有一个声音告诉我们积分其实是一种用户资产,似乎应该跟卡、券一道列入资产领域。
两种声音互相争执不下的时候,我们最终还是回归到业务目标,积分是一种为了提高客户ARPU值,提高复购率而诞生的产物,这样来看将其纳入客户领域就比较合理了。从上述子域识别的过程来看,我们会发现子领域的拆分基本都是通过抽象出与客户核心不直接相关的内容来实现的,比如入会门槛的计算、打标规则的计算等等,但是他们最终又要回归到服务于核心域的目的。
3.2.2 上下文映射关系
一份清晰的领域上下文映射关系图,能够帮助我们明确各个业务之间的关系,在进行业务分工的时候也能够有所依据。在ddd中各个上下文之间有多种关系,包括合作、防腐、遵奉等等,我们基于简单认知对客户领域的领域关系做了如下梳理(为了图例简单,这里并不包含所有的和外部领域的交互,只是列出几个作为代表):
左上角的五个领域都是归属于客户领域,互相之间都是共生共存的关系(PartnerShip,简称PS)。而右下角的几个上下文都是外部领域,需要通过防腐层(Anticorruption Layer,ACL)来转换交互,以隔离业务。箭头的方向标明了各个子域之间的上下游业务关系。
3.2 战术设计
3.2.1 客户核心域
上述第一part的战略设计帮助我们划分清楚了业务边界,梳理清楚了不同领域之间的交互关系。但是仅依靠战略设计并不能有效落地开发。为此,我们需要更加详细的战术设计,来指导代码层面的开发。战术设计中涉及的一些概念,比如实体、值对象、领域服务等的概念在此就不作赘述。我们下面看看客户核心域内,详细的战术设计:
核心域里面我们以客户实体为中心,同时关联了客户档案和行为数据等值对象,这些值对象不具有唯一性标识,只关心本身属性值。客户实体本身具有一系列的行为,是客户核心域的核心行为,如客户身份变更,客户档案变更等。同时我们定义了一个聚合,这个客户聚合以客户实体为聚合根,外加客户档案和行为数据两个值对象,代表着像客户行为数据等的值对象访问都需要通过客户实体来进行,他们的生命周期也是同步的。
在定义客户实体的行为的时候,我们也遇到了一些难以一下想清楚的问题。由于客户这个领域的特殊性,我们很容易把客户跟自然人的概念联想在一起或者直接等同,按照这个思路,我们就会发现客户有很多的行为,比如客户执行了下单,客户进行了预约,客户进行划卡操作等等,所有行为都向人看齐,最终形成所有业务都耦合到自然人身上的大泥球。自然也失去了领域划分的意义。那么,我们要怎么分辨,哪些才是客户本身真正的行为呢?其实截止目前我们也没有一个很系统的说法,但是我自己思考的实践是,分辨一个行为是否归属于客户实体,可以把这个行为涉及到的领域先罗列出来,再把他们放到一起衡量一下。举个例子:客户下单行为,这里字面上看会涉及到客户和交易两个关键词,但仔细斟酌后你会发现,下单过程中交易系统是主要工作的地方,而客户顶多充当一个订单归属人的角色,因此我们认为下单这个动作不归属于客户领域,而是交易领域的行为。
做战术设计时候遇到的另一个问题,就是容易从现有的数据库表设计去倒推我们的业务设计。美业现有的数据库表结构里,成长值是存储在客户表里的,在行成这种思维定式之后,我们一度在战术设计的时候都把成长值归为客户实体本身的属性之一。这样的想法导致的后果是,在成长值相关的开发的时候我们必须强依赖于客户实体。等级会员作为客户领域的一部分业务,却在每次开发的时候都需要牵扯到核心域的改动,这是不能容忍的。而如果把成长值独立抽象出来,就可以在等级会员的领域内完成自己的能力闭环,这才是符合我们预期的。
3.2.2 付费会员子域
看完核心域的设计,我们再用付费会员子域来举例,其余的支撑域都是类似的设计方法:
付费会员子域作为客户核心域的支撑域,囊括了对付费会员付费时长的计算逻辑和规则配置数据,为客户核心域的客户身份转变等提供逻辑支撑。我们不难从分析中看到,作为客户支撑领域的几个子域都有类似的结构,通过配置规则实体和配置记录值对象来构成单独的子域,并为核心域某一个行为提供支撑。例如:打标签、客户身份转变、会员成长等...... 鉴于上述支撑子域类似的结构,我们一度也想把这几个子域统一成一个配置域(或叫规则域),但是后面发现规则域这样的领域过于笼统,因此最后还是按照业务将几个配置规则划分开,在战略设计的时候也能更好地反映业务内容。
3.3 DDD的工程实践
详细的项目模块划分我们这里就不作讲解,主要的几个核心模块就是 domain/infrastructure/service/dependency 。
工程实践上我们基本参照CQRS的思路,以客户领域为例,我们会拆分出两个领域服务,以期做到普通查询和业务操作分离。
下面我们也是以这两个维度来看看具体的一些实践代码:
3.3.1 单纯读操作
本人日常业务开发上面各种情况的读操作占据了相当的百分比,在刚开始接触ddd的时候,秉承着要构造富血对象的理念,将所有的读操作都封装到实体对象里去,以至于在开发修改的过程中真切感受到什么叫冗余,明明只是一个简单查询,却要经过service -> entity -> repository -> mapper的4层铺垫,并且在实体操作的时候,代码与面向对象的设计理念有明显的违和。举个栗子,我们要去查询一个客户信息:
CustomerEntity customer = CustomerFactory.buildCustomer("123");CustomerEntity targetCustomer = customer.getById();
这里的代码是先通过一个工厂去构造一个客户对象实体,然后再通过实体行为去获取一个对象。第一步明明都已经构造出实体了,居然还要自己去查自己,这里给人的感觉就很变扭。如果说单查尚且能忍受,那批量查询使用这种写法就更离谱了,看下面一段:
CustomerEntity customer = CustomerFactory.buildCustomer();List<CustomerEntity> customerList = customer.batchGet()
操作的目的原本是批量查,结果要去强行构造一个实体来执行,我们都知道实体有唯一id,那么这个来执行批量查询行为的客户实体到底是哪一个呢?难道是随便一个?不管怎么样,如果把类似的批量查询行为归结到某一个实体上,是显然不合适的,也违背了实体行为抽象的初衷(让业务逻辑看起来更清晰易懂)。解决上述问题的方法其实也很简单,我们知道领域服务层是可以直接访问到仓储层的,通过service -> repository -> mapper这样的访问形式就省略了我们实体层对读操作违和的烦恼,而不需要通过实体再委托给仓储层进行读操作。这样看起来跟我们原先的 service -> manager -> mapper操作就无二致,也提高了开发效率。
3.3.2 复杂业务操作
这里我们通过一个付费会员订购后履约的例子来给大家展示一下**(以下代码参照原实现进行了阅读体验优化,仅供参考)**:
结合我们上面战略设计的时候对付费会员领域的能力划分,我们看到在领域服务里的操作逻辑已经很清晰,先直接通过仓储层获取到这个付费客户的实体和对应付费模板。调用客户实体的能力判断当前客户是否已经是付费会员,区分新老客的付费逻辑后,调用付费模板的能力计算出对应的付费时长,再将结果交由客户实体,进行客户身份转变的操作,每一步操作都符合我们面向对象的设计理念。最后跟权益领域的交互,我们通过封装的rightClient来进行,避免外部领域对象对客户领域的侵入。
到这里可能会有细心的读者有疑惑,前面做客户核心域的战略设计的时候,划分的有一个客户聚合,怎么在具体代码里都没有体现呢。这里就涉及到一个具体业务场景的问题了,聚合从理论设计上是包含了实体和值对象的,但是在付费后履约这个业务场景下,我们是没有需要去表达这个客户聚合的概念的,因此从开发成本和简洁程度上考虑,当时在这个场景下我们就没有引入聚合这一层概念。这个理念跟在读操作的时候我们绕过了实体直接走repository层是一样的,任何的架构设计理论在落地时,都需要结合工程的实际情况来灵活调整,而不是生搬硬套。
四、结语
到这里我们对ddd实践的介绍就告一段落了,之所以文章中基本没有对ddd相关术语的介绍,是因为我觉得这篇文章的目标读者是那些已经对领域驱动设计有基本入门概念的小伙伴。我相信很多人接触学习ddd都是从基本术语概念开始的,但是在实战应用的时候却往往觉得不知道从何下手,因此希望自己的一点粗浅的经验总结能够给到大家一点思路。
另外,文中也提到:当你的业务还处在很简单的阶段的时候,传统的三层模式可能是你更好的选择。就算真的需要应用到领域设计的理念的时候,也需要紧靠高内聚低耦合的本质和本着提高开发效率,写出清晰易懂的代码为目的,结合自身理解来落地。
鉴于作者经验有限,对领域驱动设计的理解难免会有不足。其实从最初着手客户领域设计到这篇文章撰写完成,整个领域模型也经历了数次推倒重构。每过一段时间回过头来看就会发现之前设计中一些逻辑无法自洽的地方。并且这些问题有的是在设计中暴露的,有的是要到具体开发编码的时候才会发现。这也反映了理论设计与实践相结合的重要性,不加以实践探究,理论终归是无法真正消化理解的。
最后,也希望这篇拙文能起到抛砖引玉的作用,欢迎各位共同探讨进步。