1 背景
通过营销活动实现客户/用户拉新、留存和促活是业界普遍采用的方法。为实现商户增长和留存,美团核心本地商业/商业增值技术部也构建了相应的营销系统来支撑商户的线上营销运营。在系统建设过程中,面临着业务体量大、行业跨度大、场景多样、客户结构复杂,需求多变等挑战。本文试图还原从0到1构建面向商户的营销系统过程中,并通过DDD(领域驱动设计)来应对系统设计和建设中遇到的业务复杂度高、需求多变、维护成本大等问题。
2 基本概念
软件系统的复杂性主要体现在三个方面。
- 隐晦:一是抽象层面的隐晦,抽象系统时,每个人都有自己特定的视角,你需要站在对方的角度才能明白他为什么这么做;其次是实现层面的隐晦,代码是一种技术实现,通常与现实世界的业务概念脱节,无形中增加了理解成本。
- 耦合:代码层面的耦合扩大了修改范围;模块层面的耦合需要跨模块/服务交互;系统层面的耦合则需要跨团队协作。从代码到模块再到系统,耦合的影响逐渐扩大,成本随之增加。
- 变化:业务需求决定了系统功能,不同的用户需求不一样,不同的业务发展阶段需求在不断变化,系统功能要随着业务需求的变化不断调整,这时就涉及到系统改动的频次和范围。
DDD(Domain-Driven Design,领域驱动设计)是应对软件设计复杂性的方法之一,它能很好的解决上述三个问题,但其概念体系复杂(如下图所示),学习曲线陡峭,即便深入研读DDD的两本经典著作,项目落地时依然有点“捉襟见肘”。
在展开介绍DDD之前,这里先回顾一下历史:
- 早期,计算机创新更多聚焦在语言方面,为软件工程师提供功能更强大的语言来操作计算机,充分使用计算机的算力。
- 60年代,面向对象语言诞生,通过封装、继承、多态等特性进一步增强了语言的表达能力。
- 80年代,出现面向对象的分析与设计,解决了如何构建类模型的问题,帮助我们更好地使用面向对象语言来实现系统,但没有解决如何把物理世界映射到计算机世界的问题。
- 2000年,出现领域驱动设计方法,通过分析业务,抽取概念,建立对应的领域模型,再采用面向对象的分析与设计方法构建对应的类模型,达成了从物理世界到计算机世界的映射。
什么是领域?领域由三部分组成:领域里有用户,即涉众域;用户要实现某种业务价值,解决某些痛点或实现某种诉求,即问题域;面对业务价值,痛点和诉求,有对应的解决方案,这是解决方案域。什么是领域驱动设计?通俗地讲,针对特定业务,用户在面对业务问题时有对应的解决方案,这些问题与方案构成了领域知识,它包含流程、规则以及处理问题的方法,领域驱动设计就是围绕这些知识来设计系统。
以营销为例,营销系统所服务的用户有4类:运营、销售、电销人员和商户。解决3个核心问题:如何发券、发给谁、发什么(红包还是折扣券)。解决方案:通过营销活动来承载发券,不同的活动类型对应不同的玩法(如买赠、折扣、充送等);通过目标人群来确定发给谁;通过权益来定义发什么(如:红包、代金券、折扣券等)。
本文将从战略设计、战术设计和代码架构分3个部分介绍领域驱动设计的落地:
- 战略设计:确定用例,统一语言和划分边界。
- 战术设计:概念模型转化成类(代码)模型。
- 代码架构:将系统设计映射为系统实现。
3 战略设计实践
战略设计之前,先要确定用例,也就是业务是怎么玩的,有几种常见的方法:
- 用例图:最简单直观的表达了用户与系统的交互。
- 用户故事:敏捷开发模式下用的较多,从Who、What和Why三个维度描述了业务需求。
- 交互原型:用户操作的页面及其操作流程,其缺点是过于关注用户体验,而忽略了业务底层逻辑。
- 事件风暴:关注业务的底层逻辑,但使用门槛较高,适用于大型而复杂的业务分析。
下图是营销系统的用例图(起初并没有这么完整,这是多次迭代后的结果):
确定业务玩法后,接下来是统一语言。从用例里抽取概念,并对概念进行甄别(去伪存真,抽象合并)找到真正描述业务的概念。比如,有多种方式来描述活动规则:充值送规则、返还规则和档位等,技术可能会泛泛地称其为规则,业务人员则用档位来描述(比如充值送活动,充1000送100红包,充2000送300红包,充3000送500红包,那1000、2000、3000就是业务所认为的档位)。抽取概念时,尽量采纳业务侧的叫法,这样统一语言比较容易推行。
接着是明确概念的含义,概念由术语、Term(术语的英文版)和含义三部分构成。含义明确的术语就是统一语言,这些术语将用在日常需求沟通、产品文档,技术设计以及代码实现中。
明确概念后,接着理清概念之间的关系(1对1,多对1,多对多),确定概念所代表的的业务实体的核心属性和行为,从而得到概念模型。后续在业务需求讨论、产品和技术方案设计时,基于这个概念模型,使用统一语言进行描述,大家能很容易对齐;同时精心抽出的概念和建立的概念模型更接近业务本质,为后续的战术设计打下了基础。
基于统一语言和概念模型,业务 - 产品 - 技术三个角色比较容易就需求达成共识,保障沟通的一致性。
缺少这些就很容易出问题,如:刚开始做营销系统时,在如何描述“商户”上,没有统一语言,资金域有三个概念来描述商户(资金账户、账号ID、资金账号),商家域有四个概念描述商户(商家账号、商家ID、登录号、登录ID),到了营销域,不同的人采用不同的概念来描述商户,造成了沟通的混乱。给商户发红包时,“资金账户、账号ID、资金账号、商家账号、商家ID、登录号、登录ID”这些概念都可以描述商户,但业务人员弄不清这些概念之间的区别,导致ID误用,红包发错。事后对这些概念进行了梳理和统一,营销域只关注资金账户和商家账号,系统功能上明确使用资金账户或商家账号来发送红包,这样就不易出错了。
概念模型是一张大网,描述了概念间的关系以及关键属性,但还不能直接映射为代码模型,要映射为代码模型,还需拆解,化繁为简。
本源论认为世界的本质是简单的,复杂问题由多个简单问题构成;康威原理认为系统架构受制于组织沟通架构,系统落地时,首先要确定系统边界,再依据系统边界组织分工。这两个原理表明:我们可以将复杂问题拆解为多个简单问题,并针对团队资源组织分工协作。
这里给出一种拆解方法(该方法来自美团技术内部分享):按纵和横两个维度来拆,纵是从业务价值和目标维度划分,横是从功能的通用性维度划分。这里尝试从业务角度来拆,没有系统支持时,业务要在线下运转,通常根据要达成的业务目标,将业务流程或业务组分拆解为多个节点,并定义每个节点的职责以及对应的规范和标准,安排对应的组织或人员执行。简单地说,就是从业务问题和解决方案出发,拆解到对应的人。因此基于业务的拆分通常能实现系统用户、业务问题和解决方案之间的一致性。业务系统是把业务的玩法从线下搬到线上,在进行系统拆分时,也可以使用这个思路。从三个层面来进行:
- 基于涉众域拆解:也就是按用户相关性进行拆解,不同的用户使用不同的系统功能,如:CRM由市场人员、销售人员、客服人员三类角色协同完成客户触达,签约合作,售后服务三大职能,针对这三个角色建设相应的系统能力。这种拆解方式比较简单,但也存在较大的局限性,可能导致功能的重复建设。
- 基于问题域拆解:不同角色/用户要解决的问题是相同/相似的,可基于问题域进行拆解,如营销系统的用户包括销售、商户、销运等角色,但它核心是要解决如何发券(活动),发给谁(人群),发什么(权益)的问题。基于问题域的拆解相较于基于涉众域的拆解更加抽象,但也可能复用性不够。
- 基于解决方案域拆解:不同的问题,可能有相同的解决方案,如HR域有请假审批、财务上有报销流程、CRM领域存在客户资质审批,三个领域各自需要解决审批流程的问题,可以构建通用的审批流引擎来统一解决,这是基于解决方案域进行拆解。基于解决方案域的拆解最抽象,也最贴合业务本质,但也容易陷入过度设计的陷阱。
营销系统基于问题域拆解为五个子域(活动域,权益域,人群域,推送域,数据域),每个子域解决特定的问题,各子领域相对内聚和简单:
业务系统要运转起来,需要子域之间相互配合,这就要定义上下文映射,实现不同子域间的协作。如活动域关注的两个目标人群:一是资金账户(表示已签约的商户);另一个是商家账号(表示未签约商户)。资金账户是财务域定义的,而商家账号是账号域定义的,两个概念都不是营销域原生概念。此时,营销域需通过某种方式依赖外部概念,将外部概念映射到营销域,通过防腐层来对接外部服务来实现这种映射。领域驱动设计里定义九种上下游映射关系,这里不赘述:
下图是营销系统的整体上下文关系:
从用例分析,统一语言到子域拆分,初步完成战略设计,但这并非终局,战略设计是一个持续迭代的过程,迭代的来源主要有3个:
- 用例精化:在探讨需求的过程中,用例不断丰富。
- 需求变更:业务不断发展带来需求变化,进而影响用例及相关概念的内涵,概念模型亦随之调整和迭代。
- 方案选型:当产品,业务或技术发生较大变化时,可能需要采用另一种方式实现它,这时所采用的概念会有所不同。比如早期构建营销活动域时,通过参与规则来定义谁可以参加活动,将商户与参与规则进行匹配,符合就能参与。这种方式带来的问题是无法提供一个完整的活动人群列表,除非将所有商户匹配一遍。随着业务方越来越重视活动参与商户的分层,触达和转化,引入目标人群的概念,通过目标人群来保存所有可参加活动的商户。从参与规则到目标人群,概念发生了变化,底层模型也完全不一样(参与规则是一套规则体系,而目标人群由筛选服务提供),实现了战略设计上的迭代。
有了战略设计,构建了统一语言和概念模型后,如何验证概念模型呢?通常用两个方法:
- 场景走查:把模型代入到所有的场景确认一遍,确定所抽象出来的概念模型和统一语言能正确描述它。
- 业务预判:未来业务的变化会在哪里,当变化发生时,概念模型的内涵和外延是否方便扩展并支持到变化。
4 战术设计实践
战略设计得到了概念模型,战术设计则是将概念模型映射为代码模型,有很多编程范式,比如事务脚本、表模式、面向对象,函数式等,最好的方式是面向对象的实现。
从概念模型到对象模型:
- 首先,概念是分层的,如营销活动是一个泛化概念,其下还有充值送活动、消费返活动,买赠活动等具体活动。构建对象模型时,通过派生/继承来实现概念分层。
- 其次,概念关系映射成对象关系,比如营销活动包含了档位和库存,那在构建营销活动对象时,可通过组合实现这种包含关系(档位对象和库存对象成为营销活动对象的属性)。
- 最后,概念的属性行为,可以直接变成对象的属性和行为;概念的状态机以及生命周期也会变成对象的状态机。
两类对象:实体和值对象,这两者的区别是是否有统一标识和自己的状态。
有了对象模型,还需通过聚合根完成封装,如何确定聚合根的粒度?营销活动包含活动、库存、档位、档位项、目标人群五个对象,如果采用小聚合根模式,一个对象对应一个聚合根,这样每个聚合根都很简单。但从业务角度看,库存或档位会影响活动的状态,如:修改了库存或档位,活动需要重新审批和上下线,这种业务上的耦合需要在技术上进行处理。此时,就得在小聚合根上构建领域服务来封装这些逻辑。
另外一种模式是大聚合根。围绕活动,把活动相关的概念(活动、库存、档位、档位项、目标人群)都封装起来,但聚合根比较复杂,影响活动加载(一些活动的目标人群上百万,懒加载可解决问题,但增加了复杂度)。
聚合根的设计要遵循一定的原则:
- 满足业务一致性、数据完整性、状态一致性。比如库存档位和活动状态要一致,在数据上也要完整,不存在没有档位的活动,也不存在没有库存的活动。
- 技术限制。有些实体会带来技术挑战,如数据量太大,可抽出来单独考虑。
- 业务逻辑不灭,在业务封装与适度的职责边界之间寻找平衡。不管是大聚合根还是小聚合根,业务逻辑永远都是存在的,就是看把它放在哪里。
如下图是营销系统的聚合根:
聚合根已经非常接近代码实现,落地代码时,大家还会纠结用贫血模型还是充血模型。Spring MVC通常运行在单例模式下,引入充血模型会增加理解成本和技术复杂度。另外,不适合放在聚合根里的领域逻辑,可以放在领域服务里,如:同时存在多个充值送活动时,用户只能参加优先级最高的一个,在充值送活动聚合根里会标识活动的优先级,但挑选优先级最高的活动并非聚合根的职责,但确实是领域逻辑的一部分,此时可通过领域服务实现。
从概念模型,类模型到代码实现,整个过程都要使用统一语言。在落地代码时,代码要体现出业务含义,比如下图的例子,要避免左边updateStatus()这样的方法,它没有体现业务含义(必须阅读代码实现,才知道这个方法做了什么);图中右边的submitCampaign(),approveCampaign(),cancelCampaign()则有明确的业务含义。
5 代码架构实践
完成战术设计后,如何组织代码架构?无论是六边形架构,整洁架构还是洋葱架构本质上都是围绕着领域模型展开,应用层、基础设施层和外部接口都依赖领域模型:
下图是我们团队的工程实践,与前面三个图本质上是一样的。领域层和应用层次放在中间(两者都属于领域逻辑),基础设施和用户接口依赖中间层:
6 总结
- 我们做的大部分系统都不是全新系统,如CRM、HR或SCM等,已经有很多业界实践,可充分借鉴这些实践,没必要自己创造新概念。
- 要重视统一语言。没有统一语言就不会有概念模型,没有概念模型就不可能有靠谱的代码模型,拿到需求后就开始设计代码模型是不靠谱的。
- 领域驱动设计是团队工作。现实中没有一个是严格意义上的领域专家,所有参与到这项工作的人都可以是领域专家,整个工作可以由技术团队主导,但一定要落地到产品和业务。
- 拥抱变化,持续迭代。模型是相对稳定的,但并非一成不变,业务理解的深度,抽象的角度与方式,业务的变化都会影响到领域模型,领域模型的建立是持续迭代的过程。
这里分享几个常见的误区:
- 深陷领域驱动设计的概念体系。在代码里生搬硬套领域驱动设计里的概念,比如聚合根、值对象、实体等,掰扯概念之间的细微差异,设计复杂的领域事件等。这反而增加理解成本,让系统变得复杂。领域驱动的精髓在于从业务出发,抽象出业务领域知识,构建概念模型,一步一步将这些概念模型映射成系统。至于如何采用聚合根、领域服务、实体、值对象、领域事件等,可以灵活取舍。
- 试图通过精心设计来获得领域模型。领域模型不是设计出来的,而是通过战略设计的几个步骤,从业务中抽象出来的,最重要是理解业务,对业务进行抽象。
- 使用了DDD就一定会产生好的领域模型的想法也不可取,我们知道飞机怎么造,但我们不一定能够造出好飞机,但如果我们知道这个方法,可以少走弯路。
在聊需求的那一刻,设计就开始了,统一语言就是设计的一部分。
解决方案域在模型维度分为四层:
- 功能模型:产品表达给我们业务的玩法,我们把它变成了用例,从用例里抽取出功能模型。
- 概念模型:对功能模型进一步抽象,统一语言,形成概念模型。
- 代码模型:将概念模型映射为代码模型。
- 数据模型:业务数据需要存储,需要设计对应的表结构。
这里有两个陷阱:
- 看到功能模型后,就开始设计数据模型,考虑数据该怎么创建、怎么更新、什么时候该删除,沦落为CRUD boy。
- 看到功能模型后,就开始考虑操作数据的流程是什么,陷入到事务脚本陷阱。(对于一些简单的功能,不排斥使用事务脚本,但对于复杂功能,事务脚本的维护成本非常大)
另外,领域至少可以分为两大类:一是学科型,比如财务、会计、图形学、动力学,这类系统的设计须先深入理解学科知识;二是实践型,如CRM、订单交易等,是业务经验的总结,这类系统的设计不妨参考前人的实践。当然,如果自己的业务具有独特性,那就只能靠自己摸索了。
7 参考资料
[1] 《DDD 实战课》 欧创新
[2] 《领域驱动设计》 Eric Evans
[3] 《企业应用架构模式》 Martin Fowler
[4]《实现领域驱动设计》Vaughn Vernon
[5] 《The Clean Architecture》Robert C. Martin
[6] 《The Onion Architecture》 Jeffrey Palermo
[7] https://carlalexander.ca/what-is-software-complexity/
[8] https://martinfowler.com/bliki/BoundedContext.html
原创 HZ [美团技术团队](javascript:void(0)😉 2024-05-23 19:58 北京
转载自公众号:美团技术团队