记录些 DDD 实践规范(3)

DDD的流程,用电商系统为场景

领域驱动设计介绍

领域驱动设计(Domain-driven Design,DDD)是一种软件设计方法,该方法的核心思想是将业务领域作为设计和开发的中心,强调对业务领域的深入理解、业务语言的建模以及领域对象的设计和实现。

这样可以更好地将软件设计和业务需求紧密结合起来,从而提高软件的可维护性、可扩展性和可重用性,使得软件更加贴近业务需求。

DDD设计的两个阶段包括两部分,战略设计部分和战术设计部分:

第1步:战略设计

战略设计主要从业务视角出发,包括了业务场景分析、领域建模、划分边界上下文三个阶段。限界上下文可以作为微服务设计的参考边界。

第2步:战术设计

战术设计则从技术视角出发,侧重于领域模型的技术实现,完成软件开发和落地,战术设计包括:聚合根、实体、值对象、领域服务、应用服务和资源库等代码逻辑的技术设计和技术设计。

第1步:战略设计

在战略设计阶段,我们最主要的过程大致包括了业务场景分析、领域建模、划分边界上下文三个阶段。

实际上战略设计是 DDD 过程中的核心步骤。

1、业务分析

在这个阶段我们所有做的就是进行全面的业务梳理,把业务中涉及到的所有细节都梳理出来,为后续进行领域建模分析提供足够的、全面的业务输入。

战略设计 经常使用到的业务场景分析方法主要包括

  • 用例分析法

  • 事件风暴法

  • 四色建模法。

一般使用事件风暴法。

事件风暴是一种快速,轻量级且未得到充分认可的群体建模技术,它对于加速开发团队而言非常强大,有趣且有用。

事件风暴业务分析的核心环节

这里我们使用事件风暴进行业务场景的分析以及梳理。

(1)事前准备

在进行事件风暴之前我们需要进行一些准备,主要包括贴纸、笔以及讨论的会议室,会议室中最好不要有椅子,目的是想让大家都能够站立在一起、全神贯注的去进行业务讨论。

(2)邀请参会的人

会议的参与方主要包括用户、产品经理、PD、研发、测试、架构师等。

其中,PD为产品设计师,也可能叫产品规划师、需求分析师、小产品经理。

PD侧重于将对应用做功能级的设计,在这个模块上,PD类似是一个小产品经理、模块级别的产品经理。

架构师(系统分析师)会与PD紧密合作,架构师 这时候开始考虑技术可行性,性价比。

PD的职位描述:

(1)调查市场并研究需求,形成市场需求文档。

(2)负责新产品设计,拟定设计规划和方案。

(3)负责新产品的原型设计。

(4)组织产品开发团队,协调资源,跟进产品的开发,保证日程进度。

(5)分析产品运营数据,收集运营意见,及时调整产品形态,优化产品,并提出合理的运营建议。

(6) 以用户体验为中心,改进现有产品,或设计新产品。

(3)业务讨论

首先确定我们今天需要讨论的业务是什么,目标是什么。

咱们业务场景中,如果讨论的业务就是电商,那么,目标就是完成业务梳理,确保没有业务方面的理解 gap,在团队中达成业务理解的的一致性。

事件风暴过程需要问题驱动,一般通过提问的方式来驱动交流。

a、分析业务中的事件,搞清楚事件发生的前因后果

  • 什么动作会导致当前事件的发生

  • 当前这个事件发生后又会导致怎样的后果。

这些我们都需要梳理清楚。

还有一点需要注意, 需要全面梳理

  • 不但要关注正常的业务流程

  • 还要关注异常的业务流程。

b、寻找业务逻辑和业务规则

:在进行事件风暴过程中,所有的参与人都要全身投入整个过程,大家都是站立形式,聚焦全部注意力,大家都放下手机以及电脑,一起参与整个业务梳理过程,只有这样,事件风暴才可能有比较好的效果。

在前面的事件风暴业务梳理中,我们已经把电商业务涉及到的参与者、动作以及事件等都进行了全面的梳理。

参与者、动作以及事件 整个协作流程,是一个粗粒度的 业务流程, 非常粗。

2、领域建模

那么接下来我们就要在此基础之上,进行细粒度的 领域建模,这是整个 DDD 的核心。

DDD领域模型 细分为两步骤,第一步是发散,产生很多实体、命令、事件等领域对象,第二步是收敛,我们从不同的维度对进行聚类形成聚合,建立最终领域模型, 这是一个收敛的过程。

(1)发散阶段:领域对象分析

领域对象分析,也就 实体、值对象、领域事件、领域命令的分析。

(2)收敛阶段:构建业务聚合

完成领域对象分析之后,我们需要构建业务聚合。想要构建聚合,那么首先就要在实体中找到聚合根。来看看,一个 聚合根的特点:

  • 聚合根一定是实体,那么它具有全局唯一的标识

  • 聚合根是具备生命周期的

  • 聚合根需要专门的模块来进行管理

3、划分边界上下文

获得了整个业务流程中的所有聚合后,我们需要更具业务语义上下文将具体的聚合划分到对应的上下文中

  • 域的拆分

    • 按业务抽象进行划分

    • 一个业务拆分成几个独立的域,每个域又可细拆成不同子域

  • 防腐

    • 一个域在访问其他域的模型时,把获取到的模型做层转换映射到自己域的模型中(不直接使用别的域模型作为自己域模型中的一部分)

    • 防止源域模型发生变更,依赖源域模型的调用方,在需要源域模型新功能时,必须要全局依赖修改,才在能兼容

    • 防止域上下文不一致产生的冲突

  • 限界上下文之间的映射关系

    • 合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。

    • 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。

    • 客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。

    • 遵奉者(Conformist):下游上下文只能盲目依赖上游上下文。

    • 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。

    • 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。

    • 发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。

    • 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。

    • 另谋他路(SeparateWay):两个完全没有任何联系的上下文。

第2步:战术设计

在战略设计阶段,我们通过事件风暴法对整体的业务进行了全部的梳理,同时构建了领域模型,以及划分了边界下文。

那么接下来我们就要将领域模型映射到工程结构,以及代码中实现最终的实现落地。

另外在这个阶段实际还有很多细节需要明确的。

1、领域服务设计

需要哪些领域服务,领域服务依赖哪些属性,依赖哪些实体,依赖哪些值对象,这些都是需要在战术设计阶段明确下来。

2、领域分层

在领域分层方面,可以按照cola分层结构来进行。

3、代码结构

当我们把领域对象进行进一步的细化之后,同时把对应的领域服务敲定之后,我们可以把这些分析后的内容映射成工程分层后的代码了。

所以,后续就进入到阶段了,还有很多工作要做,比如详细设计、编写代码以及功能测试,特别在详细设计阶段,还要涉及很多的细节问题的敲定,比如数据库表的设计、比如使用什么 MQ,用不用缓存,怎么保证缓存和数据库的数据一致性问题,分布式服务有没有分布式事务的问题,应该怎么解决?有没有服务幂等问题,应该怎么解决?

这些都是需要在详细设计阶段进行确定的。

因此 DDD 就像是框架,通过它把业务映射成为领域对象以及领域服务和领域事件,再把这些领域相关内容再读映射为实际的代码。

使得我们的服务更加的逻辑清晰以及扩展性更强,但是分布式的技术实现细节,我们还是需要有对应的解决方案来进行解决。

电商的领域驱动设计

第1步:战略设计

1、事件风暴:参与者、动作以及事件 整个协作流程梳理

大致可以梳理出下面的参与者、动作以及事件

  1. 订单创建事件:当用户下单后,系统需要创建一个新的订单,并触发订单创建事件。

  2. 订单支付事件:当用户完成订单支付后,系统需要将订单状态设置为已支付,并触发订单支付事件。

  3. 订单发货事件:当商家完成订单发货后,系统需要将订单状态设置为已发货,并触发订单发货事件。

  4. 订单退款事件:当用户申请退款并经过审核后,系统需要将订单状态设置为已退款,并触发订单退款事件。

  5. 商品上架事件:当商家将商品上架时,系统需要将商品状态设置为上架,并触发商品上架事件。

  6. 商品下架事件:当商家将商品下架时,系统需要将商品状态设置为下架,并触发商品下架事件。

  7. 库存变更事件:当用户下单后,系统需要检查商品库存是否充足,并对库存进行相应的变更,并触发库存变更事件。

  8. 物流状态变更事件:当订单状态变更为已发货时,系统需要记录物流信息,并触发物流状态变更事件。

通过对领域事件的记录和处理,可以在电商系统中实现更加复杂的业务逻辑,提高系统的灵活性和可维护性。

2、领域建模与边界上下文划分

电商系统中的聚合和聚合根是什么?

在电商系统中,可以将订单作为一个聚合,其中包括多个领域对象,例如订单项、收货地址、发票信息等。

订单作为一个聚合,它的内部领域对象之间有明确的关联关系,并且需要满足一些业务规则,例如订单项的数量不能为负数,订单状态必须是待付款、待发货、待收货、已完成等状态之一。

在订单聚合中,可以将订单对象作为聚合根,用于保证订单聚合的完整性和一致性。订单对象具有唯一标识和全局访问权限,它可以被其他领域对象引用,例如订单项和收货地址等。

使用聚合和聚合根可以将复杂的业务逻辑封装在一个边界内,提高系统的可维护性和可扩展性。

电商系统是一个涉及到多个业务领域的复杂应用系统,经过上下文建模和边界上下文划分,最后确定电商系统所包含了众多的子域。

以下是电商系统中常见的一些子域:

  1. 商品子域:包括商品分类、商品管理、商品展示等功能。

  2. 订单子域:包括下单、订单管理、订单配送、订单支付等功能。

  3. 营销子域:包括促销活动、优惠券、积分、礼品卡等功能。

  4. 用户子域:包括用户注册、用户登录、用户个人信息管理等功能。

  5. 支付子域:包括支付方式、支付渠道、支付接口等功能。

  6. 物流子域:包括订单配送、物流跟踪、运费计算等功能。

  7. 客服子域:包括售后服务、用户反馈、在线客服等功能。

  8. 数据统计子域:包括用户行为分析、交易数据统计、业务数据报表等功能。

在实际的电商应用系统中,可能会根据业务需求进一步细分和扩展各个子域。每个子域都可以作为一个独立的微服务进行设计和开发,从而实现更好的业务解耦和系统扩展性。

第2步:战术阶段

核心是领域服务设计

电商系统中包括的领域服务,主要如下:

  1. 商品服务:负责管理商品的上架、下架、库存等信息,并提供商品的查询、搜索、推荐等功能。

  2. 订单服务:负责管理订单的创建、支付、发货、退款等流程,并提供订单的查询、取消、评价等功能。

  3. 用户服务:负责管理用户的注册、登录、个人信息管理等功能,并提供用户评价、收藏、关注等功能。

  4. 支付服务:负责管理系统的支付方式、支付账户等信息,并提供支付的流程和接口。

  5. 物流服务:负责管理物流的配送、快递信息等,并提供物流的查询、跟踪等功能。

  6. 营销服务:负责管理系统的促销活动、优惠券等信息,并提供优惠券领取、使用等功能。

  7. 数据统计服务:负责收集、分析、展示系统的数据统计信息,例如订单量、销售额、用户活跃度等。

  8. 客服服务:负责管理系统的客户服务、投诉处理等工作,并提供在线客服、电话客服等渠道。

这些领域服务之间相互协作,共同实现电商系统的核心功能和业务流程。

通过领域服务的划分和封装,可以提高系统的可扩展性和可维护性,降低系统的耦合度和复杂度。

领域驱动设计与微服务的关系

领域驱动设计(DDD)和微服务是两种不同的概念,但它们可以结合在一起来提高软件的质量和可维护性。

微服务架构是一种将应用程序拆分成多个小型、自治的服务的架构模式,每个服务专注于某个特定的业务功能,通过轻量级通信机制进行协作。

微服务架构通常需要处理大量的业务逻辑,因此需要一个清晰的领域模型来管理这些业务逻辑,这时领域驱动设计就能够发挥作用了。

通过领域驱动设计,开发人员可以更好地理解业务需求,更好地建模业务领域,将领域模型作为微服务架构的核心,从而更好地支持微服务架构。

具体来说,领域驱动设计可以帮助实现以下方面:

  1. 微服务的拆分和划分:通过领域驱动设计,可以将业务领域划分为不同的子域,并将每个子域划分为一个或多个微服务,从而实现微服务的拆分和划分。

  2. 服务间的通信:微服务架构中的服务需要通过通信机制进行协作。

    通过领域驱动设计,可以在微服务之间定义明确的接口和协议,使得服务之间的通信更加清晰和稳定。

  3. 微服务的扩展和维护:通过领域驱动设计,可以将业务逻辑清晰地划分为不同的领域模型,从而使得微服务的扩展和维护更加容易。例如,在需要修改业务逻辑时,只需要修改对应的领域模型和微服务,而不需要修改整个应用程序。

因此,领域驱动设计可以帮助开发人员更好地支持微服务架构,并实现更好的软件设计和开发。

领域驱动设计与敏捷开发相结合

领域驱动设计和敏捷开发是两种不同的方法论,但可以相互结合,提高开发效率和软件质量。

在敏捷开发过程中,团队通常采用迭代式的开发模式,不断地根据用户反馈和需求变化进行调整和优化。

这种敏捷开发模式与领域驱动设计的思想相契合,因为领域驱动设计强调将业务领域建模为一个相对独立的系统,同时也强调不断优化和迭代。

以下是一些结合领域驱动设计和敏捷开发的方法:

  1. 划分迭代周期:在敏捷开发中,团队通常会划分出多个迭代周期来实现软件的开发和交付。在每个迭代周期中,可以采用领域驱动设计方法进行领域建模和设计,以确保软件系统的质量和可维护性。

  2. 与领域专家和用户紧密合作:在敏捷开发中,团队需要与领域专家和用户紧密合作,及时获取用户反馈和需求变化。在领域驱动设计中,也需要与领域专家和用户紧密合作,以确保领域建模的准确性和实用性。

  3. 重构和优化:敏捷开发中,团队需要不断地重构和优化代码,以提高软件系统的质量和可维护性。在领域驱动设计中,也需要不断地优化领域建模和设计,以提高系统的可扩展性和可维护性。

  4. 测试和验收:在敏捷开发中,团队需要进行测试和验收,以确保软件系统的质量和稳定性。在领域驱动设计中,也需要进行测试和验收,以确保领域建模和设计的准确性和实用性。

去哪儿网的DDD架构实操之路

一、架构设计理念与技术

1. 架构演变路径

图片

  • 单体(又称巨石系统):将所有业务集成在一个系统中。在项目的早期阶段,企业通常会选择单体架构以降低运营等成本。

  • 服务化:随着业务的快速发展和流量增长,进入服务化阶段。这个阶段包括服务拆分、治理和模型抽象。

  • 平台化:在业务膨胀期过后,服务化维护成本上升,服务粒度过于细分,重复开发等问题暴露,因此走向平台化(服务能力积累、服务整合、领域自治等)。

  • 中台化:构建企业级能力复用平台,是平台化的下一步,具备数据互通能力和业务变化高响应力。

这种业务架构的演变路径,从侧面反映了互联网企业的演变路径。每种架构的好坏并非绝对,选择与否,仅取决于是否适应当前和可预见的未来。

本次分享主要介绍服务化到平台化的过程,即从服务细粒度到领域能力积累的演进过程。

2. 架构设计理念

图片

以业务为中心、适应业务变化是架构设计成功的关键。指导业务架构设计的维度包括:

1)商业模式及成熟度

传统行业的业务相对稳定和成熟,非必要情况下建议构建单一服务。如需拆分,建议将变化频繁和不频繁的业务拆分。

互联网行业分为初创公司和成熟稳定的公司:

  • 初创、商业不稳定的公司:需要多种业务快速试错,可采用微服务。通过微小的单体服务器,快速构建探索场景,以技术的确定性应对未来发展的不确定性。例如,去哪儿网的某些团队在制定简单方案后,可使用微服务获取市场反馈,快速验证效果。

  • 商业稳定或固化的公司:不再需要技术端的灵活性,也不愿承担灵活性带来的架构维护成本,此时可以考虑合并微服务,以降低运营成本。

目前旅游行业已相对稳定,去哪儿网符合上述第二种情况,可以考虑将之前拆分过细的微服务进行合并。这也是去哪儿网架构演进的原因之一,原有业务拆分过细,达到人均10个应用,维护成本极高。

2)面向业务的变化

  • 组件设计围绕业务变化

  • 组件调用非强依赖

  • 组件业务复用

  • 组件颗粒度与成熟度

为快速适应业务变化,需识别业务核心问题,明确业务边界,实现业务组件的最大化复用;区分变化与不变的业务,将变化限制在一定范围内,从而降低影响。

面向业务变化与不变的情况下,组件颗粒度要拆分到什么程度?

组件拆分粒度过细时,可复用性强,但组装复杂;拆分粒度过大时,使用方便,但应用场景有限。

3)技术延迟决策

《架构整洁之道》一书提到:“良好的架构设计应关注用例,并将它们与其他周边因素隔离。”

在前期,应关注用例,后期再决策具体技术。

图片

4)康威和逆康威定律

  • 康威定律:产品的形态无意识地映射了其组织沟通的格局。系统的构建本质上复制了公司的组织架构,而系统各部分之间的接口则展现了公司各部门间的信息传递与合作方式。

  • 逆康威定律:当前组织效率不够高时,可以优先进行系统设计,利用系统设计来逐步优化后的组织架构。例如,去哪儿网在 2021 年实施了内部领域驱动设计,外部 API 的战略,对团队和职责进行了合理划分,这就是逆康威定律的一个实践案例。

5)面向测试、运维

测试是确保系统质量的关键环节,采用测试驱动开发(TDD)来验证架构的合理性、可隔离性和易测试性。

6)软件质量属性

在开发和运行阶段,软件质量特性表现为可用性、可维护性、性能、安全性、易用性等。

以功能性为核心进行架构设计,依据质量特性进行增量式的迭代重构和优化。

图片

上图是架构的一些关键技术,这张图的粒度较粗。

从下往上观察,公司底层主要由容器和自动化技术支持,上层则是监控和治理、前后端分离的系统。

根据这张图,领域驱动设计(DDD)成为整个架构的指导原则。

二、业务系统重构背景

1. 业务介绍:酒店基础信息

图片

上列四张图简单展示了去哪儿网本次重构的主题,也是酒店基础信息部所负责的业务。

  • 列表页:用户打开 APP,输入目的地和入住时间,点击搜索,便会转到列表页,展现搜索地点的所有酒店列表信息。

  • 详情页:点击希望入住的酒店,即可进入详情页。

  • 酒店Info:点击酒店名字,则可进入酒店Info页。

  • 进订页:用户决定预定酒店房间后,就跳转到进订页。

2. 基础信息业务架构

图片

上图展示了酒店基础信息业务对应的架构。

去哪儿网售卖的酒店,来源于各个代理商和集团分销的信息,按照图示自下到上,经过基础层,然后到达基础信息部门的主要业务层。

业务层最重要的内容是酒店聚合,包括代理商酒店Tree和Q物理酒店。

我们将各个代理商提供的酒店信息,按照一定业务逻辑规则,聚合到去哪儿网的Q侧物理酒店,并将这部分信息对外销售,以优化用户体验。

为什么能够提升用户体验?

举个例子,比如现在投放的是季枫酒店,A代理商将其称为季枫酒店北京店,B代理商将其称为季枫酒店北京中关村店,C代理商称之为季枫酒店北京中关村苏州街店,用户容易混淆。

因此,去哪儿网将各代理商提供的酒店信息,按照一定的业务逻辑、规则整合为外部可见的唯一物理酒店。

酒店Tree的含义是,每个代理商投放的酒店,对应到去哪儿网的酒店,以去哪儿网的酒店为根,下方挂接不同代理商投放的酒店信息,形成对应关系。

目前,我们团队的核心业务是将基于业务层的酒店信息,提供给应用层,如 APP 搜索或筛选时展示的信息。

3. 落地技术中心战略,偿还技术债务

图片

旅游业可能是受疫情影响最大的行业之一。在此背景下,技术中心在 2022 年提出了“巩固效率之本,分担产品之忧”的战略,从而开启了领域驱动设计(DDD)的重构之旅。

如上图,重构前,业务和业务架构存在以下问题:

  • 数据写入链路长:缺乏核心业务的统一出口,数据更新不够及时。由于没有实时查询能力,其他团队在调用数据时,需要先拉取并自行缓存。

  • 核心业务入侵:产品需求交付效率不高,一个产品需求可能需要对五个微服务进行调整。

  • 核心业务分散:图中灰色条块代表着我们的核心业务,它受到整个链路的耦合影响,分散在各个系统中,导致核心业务遭受侵入。

为了解决这些问题,技术中心决定采用领域驱动设计(DDD)进行系统重构。通过这一方法,我们希望能够提高产品需求交付效率,缩短数据写入链路,以及降低核心业务受到的侵入程度。此外,DDD 还能够帮助我们实现核心业务的模块化,使得各个系统之间的耦合度降低,从而提高整个系统的灵活性和可扩展性。

4. 系统重构模式选择

没有最好的架构,只有最合适的架构。

以下是备选的系统重构模式:

图片

  • 修缮者:在现有系统基础上,新增一层抽象层,确保对外服务能力不变,然后对内部进行优化改造。

  • 绞杀者:修缮没有办法适应现状的情况下,需要另起炉灶,在外部构建新功能,逐步剥离原有逻辑。为新功能提供服务,逐步淘汰或重构各个需下线的服务。在重构过程中,要权衡颠覆者的优缺点。

  • 优点:不影响原有业务,一旦条件成熟,新系统可完全替代旧系统。

  • 缺点:在一段时间内需要维护两套系统,付出额外的开发维护成本。

  • 演进式:对于老项目逻辑模糊的情况,采用演进式迭代。识别老系统中的核心业务逻辑,从最小可行性产品(MVP)版本开始,快速迭代核心业务,率先上线观察效果,然后逐步将剩余的边缘业务切换过来,并及时调整。

  • 优点:控制迭代风险,避免全面替换系统,降低不可预测的影响。

三、系统重构改造模式与架构选择

前文讲解了架构的演变路径、理念及改造模式的选择,最终衍生出来的系统重构框架是什么样子?

系统重构模式选择

图片

以业务为导向,适应业务变化是现代架构设计成功的核心要素,而领域驱动设计(DDD)的理念恰恰符合这一成功架构设计的原则。

1)服务业务战略

站在EA(企业架构)角度(包括业务架构 BA、应用架构 AA、数据架构 DA、技术架构 TA)的角度出发,DDD 能够将业务架构与应用架构相结合,将问题领域与应用架构分离。通过分解业务架构中的“价值流 + 业务能力”,实现能力的下移。同时,根据 DDD 划分的限界上下文和聚合,构建应用架构,实现自下而上的“高内聚、低耦合”。

2)演进式架构

DDD 的核心思想包括:

  • 战略层面:业务问题分析→分解子问题域,识别核心域→分而治之,降低业务复杂度;

  • 战术层面:识别问题域的不同业务上下文→领域建模,定义聚合,组件化业务需求→指导微服务的拆分;

  • 实现层面:利用成熟的分层模式、依赖倒置屏蔽掉技术细节复杂度,通过DDD方法设计的微服务,不仅可以通过限界上下文和聚合实现微服务内外的解耦,同时也可以很容易地实现业务功能积木式模块的重组和更新,从而实现架构演进。

总之,自上而下地拆解业务,并以此为指导,自下而上地构建模型,最终达到高内聚低耦合的状态。

在系统重构过程中,我们选择了绞杀模式和演进模式。鉴于系统复杂度高,了解业务细节的人员较少,为了降低重构对现有业务的影响,我们将核心资源投入到核心业务中,快速上线以观察效果。以下将详细介绍演进实践。

四、以业务驱动的微服务架构演进实践

1. 领域驱动设计过程

图片

上图是以业务驱动的微服务架构演进的实战过程,介绍DDD的完整流程和关键路径。

进行领域驱动设计时,需要对组内成员进行定位。

最重要的是识别领域专家,即那些对特定领域有深入认知的人,他们能帮助团队成员更好地理解业务,有利于后续的头脑风暴和建模过程;其次是技术专家和开发团队。

领域驱动设计的关键路径如下:

第一步,领域专家与开发团队针对具体问题,明确业务愿景,探讨需求,从而确立统一的语言,积累领域知识。统一语言意味着对问题领域内的概念达成共识,例如,团队成员对某个词语的定义有明确的认识,没有歧义,从而降低沟通成本。

第二步,分析问题域并划分子域(比如核心子域、支撑子域、通用子域),进而划分限界上下文,构建上下文地图。

第三步,领域建模并实现模型。将以上两步分析,映射到代码层面,进行模型实现。这一步骤可以概括为“两关联一循环”。“两关联”指的是统一语言与模型之间的关联,以及模型与软件实践之间的关联。“一循环”意味着在实践过程中,可能会遇到各种困难和不确定性,需要在不断迭代的过程中提炼知识,最终趋近于完美的模型。

总之,领域驱动设计是一个动态迭代的过程,通过明确团队成员的角色,发掘领域专家,建立统一语言,分析问题领域,进行建模和实践,不断优化和完善模型。在这个过程中,团队成员需要密切合作,充分发挥各自专长,共同推动领域驱动设计取得成功。

2. 基于DDD落地实践

图片

上图展示了基于DDD落地实践的过程。

首先是定位愿景,其重要性在于决定了后续的发展道路;

其次,分析问题域中的现有业务场景;

然后,根据划分的子域,识别限界上下文;

最后,在限界内进行领域建模和实现模型。

1)问题域分析

① 定位愿景

图片

麦肯锡提出“电梯演讲”概念是指,在乘坐电梯的30秒之内,向顾客清晰准确地解释解决方案,即使用简短的语言精准说明业务价值。

比如,去哪儿网的核心价值是“总有你要的低价”。因此,所有核心工作都应围绕低价展开。落实到基础信息团队,我们的愿景就是提供丰富的信息聚合。

② 明确领域专家

由于产品迭代频繁,系统演进缺乏领域专家。因此,按照产品、QA、技术人员的顺序来确立领域专家。

图片

为了分析原有项目中,哪些用户用例与我们的愿景密切相关,我们整理了用例图,并安排同学逐一分析。

分析结果显示,经过多年的迭代,许多业务已经不再使用,旧业务无法适应现有的商业模式。

因此,我们将这些业务下线或重新分配资源,最终将 188 个用例精简为 79 个,大大简化了工作内容。

图片

③ 事件风暴

事件风暴主要关注三个方面:识别领域事件、识别决策命令、识别领域名词。

事件风暴的输出将作为后续领域建模的输入,需要遵循以下原则:

  • 业务视角事件:从业务视角分析领域事件,例如,某个业务操作产生特定数据,引发业务流程状态变化,并向外发送消息。在进行头脑风暴时,技术人员容易陷入代码细节,而忘记最初的目标。

  • 先发散,再收敛:首先列出所有想法,然后在基础上进行收敛。

  • 决策命令:分析导致事件发生的人员和动作。

④ 统一语言

图片

没有 DDD 经验的同学可能会问,什么可以作为统一语言?

答案是,什么都可以作为团队的统一语言。

例如,如果一个老系统的内部逻辑复杂且难以理解,重构成本高,不易修改,可以将这部分代码作为团队内部的统一语言。这样一来,产品和技术的同学都了解老系统的目标、能力和应对策略。

由于技术同学在描述事物时偏向于使用代码逻辑,这可能导致与产品同学的沟通出现偏差。

在这种情况下,统一语言可以拉近双方的认知差距。技术同学只需抛出几个领域名词,产品同学就能理解。

需要注意的一点是,统一语言的术语表应包含中英文对照,以便在后期编码和解码过程中,在代码层面实现认知统一。

2)识别限界及子域划分

图片

识别限界上下文的总体原则是先业务后技术,上图展示了领域层面的划分流程。

  • 降低技术复杂度:根据限界上下文承受的流量不同,我们通过创建弹性边界、部署和可用性测试,采用不同方法对待不同的限界上下文。

  • 降低管理复杂度:基于领域层划分的业务边界会影响工作边界的划分。上述提到的康威定律和两个披萨原则(一个高效的技术研发团队,最佳团队规模应控制在能吃两个披萨的人数)都对工作边界具有指导意义。

  • 降低业务复杂度:在业务层面,要消除语言的歧义。例如,在去哪儿网内部,商务和技术团队可能对“酒店”这个词汇的理解有所不同,因此在确定限界上下文时要避免这种情况。

图片

限界上下文的特征

  • 独立进化原则:对外提供稳定接口,内部变化不影响外部。

  • 稳定空间原则:外部变化不会影响自治单元。

  • 自我履约原则:自治单元自行决定职责。例如,在进行代理商酒店数据抓取时,无需进行后续解析。

  • 最小完备原则:自治单位职责完整,无需向其他自治单位求助以获取自身信息。

在绘制上下文依赖地图时,要遵循三不原则:避免双向依赖、循环依赖和过长依赖。

如上右图所示,我们在制作上下文依赖地图时,我们发现酒店解析依赖酒店抓取,酒店抓取依赖酒店聚合信息,酒店聚合信息依赖静态信息,静态信息依赖酒店解析出的数据,形成循环依赖。因此,划分方式不合适。针对这种情况,我们创建了“酒店上下文”环节,打破循环依赖。

图片

在限界上下文后,需要识别核心域、支撑域和通用域,划分参考是与业务愿景的相关性。

识别子域的好处是,对外可以明确告知自身核心竞争力;对内可以明确人员、设备资源分配,评估产品需求优先级以及是否处于核心领域。

3)领域建模

图片

根据事件风暴和限界上下文的输出,我们可以构建领域模型。

① 建模意义

  • 使用聚合表达业务的“高内聚,低耦合”特性;

  • 降低业务复杂度,以便更好地应对业务变化。

② 建模过程

  • 识别实体、值对象、丰富领域逻辑

  • 定义聚合、识别聚合根

在建模过程中,最棘手的问题在于如何把握尺度,也就是判断哪些方法应被纳入模型,哪些属性应置于哪个模型。我个人认为,只要在团队内达成共识,无论实际结果如何,都可以视为正确。因为建模是一个不断优化的过程,随着对业务理解的深入,要推翻之前的结论并一次性构建完美模型较为困难。

在分层架构中,领域层和业务层都应存在。若盲目将功能和用例纳入领域层,可能导致领域层过于庞大,进而影响其可重用性和业务表达力。因此,我们需要正视模型和领域能力的不确定性,逐步采取迭代方式,将能力下沉至领域模型中。

③ 建模原则

  • 重点关注核心域建模:投入核心资源

  • 聚合尽量小,适应业务变化

  • 聚合边界内强一致性

  • 抽象模型,防止过多属性拍平(DP):属性被拍平的弊端是,模型内字段太多,无法识别识别模型。

图片

上图展示了两个原则:共性业务能力优先下沉到领域,共性技术问题抽象成业务。

没有完美的模型,也没有正确的模型,领域模型的共识即为正确。因此,团队的整体能力决定了模型完美程度的上限。提供一个参考的检验技巧:在建立完模型后,可以使用业务场景检验模型的完整度。如此循环往复,模型将逐渐完善。

图片

④ 落地实践时划分微服务

如上所示,业务边界、康威定律、业务变更频率、弹性边界、技术选型等,都可作为划分依据。

需要指出的是,一个微服务可以包含多个限界上下文,但只能包含一种子域类型(核心、通用、支撑),不能将核心域和支持域放在同一微服务中。如果支持域的可用性不佳,可能会影响核心逻辑,因此可能为这个问题付出沉重代价。

4)模型实现

① 业务流程和领域模型映射

图片

如上所示,业务流程或业务用例包含多个阶段,每个阶段又包含一系列活动。我们将这些业务活动与整个分层架构相结合,构建相应的映射关系。

建立映射关系的好处在于,在分层架构和领域模型高度凝聚、完善的情况下,便于后续需求的接入和扩展。从上到下分解业务流程,进行分层映射,技术负责度得以隔离。

② 模型映射代码清单

图片

如上图所示,应用层、领域层、基础设施层是领域对象的聚合,模型映射代码清单明确了每个层次的能力、领域层的领域对象、是否存在前置依赖对象,以及包名、类名和方法名。这些内容与前面提到的中英文对照表相对应。

构建这份代码清单有助于团队内部协作,提高开发效率;为新成员提供参考,快速了解项目核心逻辑和能力。

③ COLA应用架构

图片

在开发阶段,我们采用了 COLA 开源框架,其分层架构包括适配层、领域层、应用层和基础设施层。

无论是 COLA 还是 DDD 的分层架构,都围绕业务核心,基于稳定的领域模型,对外提供领域能力。

选择COLA的原因如下:

  • 定义良好的分层结构、规范;

  • 层内部结构“聚合分包,功能分类”;

  • 提供了实用的应用架构最佳实践:《领域驱动设计》一书主要提供设计思想,而 COLA 则为实际应用提供了可借鉴的模板。

下图是我们内部基于COLA架构落地微服务的实践。

图片

  • Adaptor:多端适配

  • Client:业务提供的接口,比如Dubbo

  • APP:业务用例Case的编排,含executor、publish、qschedule等

  • Domain(聚合、实体、值对象的定义)

    • 业务规则显式化,包括逻辑判断和对象设值代表的含义,纯内存操作;

    • 实体(Entity):解决单个对象的逻辑变更,领域服务解决多对象的业务逻辑变更;

    • 领域服务(Domain Service):处理多个对象的业务逻辑变更;

    • 限制:不允许跨聚合调用;

    • 特点:充血模型

  • Insfrastructure:Repisitory实现;ACL的定义和实现

  • Common:提供公共属性、工具类,由domain调用

通过运用 COLA 框架,我们希望实现业务核心的模块化,提高系统的可扩展性和灵活性。在实际开发过程中,团队成员需遵循分层设计原则,紧密协作,不断积累和共享领域知识,共同推动项目的成功。

图片

上图是对前一张图的具体描述,我们采用了 CQRS 模式,即命令与查询职责分离。

前文的一张图描述了架构重构前的状况:核心业务分散在各个服务中,没有得到收敛和整合,业务耦合度较高。通过限界划分和领域建模,我们实现了业务的分离。

在改造前,系统缺乏实时查询功能,各团队将数据拉回并本地缓存。经过重构,我们采用了异步调用机制,实现了数据持久化,并对外提供查询功能。

在重构前,系统没有提供实时查询能力,各团队将数据拉走并进行本地缓存。重构后,基于异步调用机制,实现了数据持久化,并对外提供查询功能。

④ 领域模型与代码模型映射

图片

上图是领域模型与代码模型的映射。分层对应上一张图展示的架构,在 Domain 层,根据领域划分进行聚合分包。图中标蓝的 Hoteltree 就是前文提到的酒店聚合,在该领域内进行功能划分。

图片

Domain Primitive 是 Value Object 的进阶版,它在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象。在 VO 的不可变基础上增加了有效性(Validity)和行为。

DP 特征如下:

  • 拥有完整的概念整体,精准定义

  • 使用业务域中的原生语言

  • 业务域的最小组成部分,可构建复杂合

  • 隐式转显式

例如,联系信息对外显示为电话号码,但背后隐藏了区号、国内外来源等隐式属性。通过 DP,我们可以找出这些隐式属性并将其转化为显式,这是推荐使用 DP 的关键原因之一。

通过以上映射,我们希望实现领域模型的清晰划分,降低业务耦合度,提高系统的可扩展性和灵活性。在实际开发过程中,团队成员应紧密协作,不断积累和共享领域知识,共同推动项目的成功。

在架构重构之前,系统核心业务分散在各个服务中,耦合度较高。通过限界划分和领域建模,我们实现了业务的分离。重构后,系统具备了实时查询功能,数据持久化,并对外提供查询服务。在这个过程中,团队遵循分层设计原则,紧密协作,不断积累和共享领域知识,共同推动项目的成功。

五、总结和思考

1. 项目落地效果

1)组织效率
  • 组织资源是否集中在了核心业务领域;

  • 是否能用统一语言沟通描述业务,体现在需求评审、站会等有关会议的效率上;

  • 领域知识是否得到沉淀,是否有人能承担“领域专家”;

  • 团队间职责模糊地带少,相互扯皮的机会少。

2)开发效率
  • 模块粒度是否合适、模块间依赖是否健康;

  • 接口数量是否稳定,不膨胀;

  • 因为功能理解不足引起的bug数量是否低;

  • 模块和接口的自测性程度高不高;

  • 代码可读性,人员交接和新人上手是否足够快。

3)巩固效率之本,分担产品之忧
  • 清晰领域,核心子域重点投入;

  • 统一语言,减少产运研测沟通成本,增加2名研发业务专家;

  • 承接产品需求75%,助力0.5PM;

  • 21个应用微服务,通过DDD领域划分后下降到13个,微服务减少33%;

  • 开发工时3pd以下产品响应效率提升52.3%,3pd以上32.5%,QA工时下降62.3%;

  • 架构、聚合分层,功能分类,新人上手快

2. 思维模型改变

图片

技术团队从被动了解业务转变为主动了解业务,解读业务策略变化,为其定义测量,提出数字化方案。产品经理的核心价值是成为技术与业务之间的桥梁,但通过 DDD,技术同学也更加关注业务,实现产研融合。

1)问题域分析领域建模
  • 分治思维

  • 模型思维

  • 抽象思维

  • 结构化思维

2)模型实现
  • 简单思维

  • 契约思维

  • 解耦思维

3. DDD带来的优劣势及建议

1)优势
  • 隐性知识显性化,统一团队语言

  • 围绕业务变化,隔离“变化”

  • 积木式组合业务演进

  • 关注点分离,隔离技术细节

  • 面向测试、运维

  • 业务思维(主动向前看业务,主动提想法,0.5PM)

2)劣势
  • 团队上手有门槛(概念-理解-困惑-深入理解)

3)使用建议
  • 业务场景复杂

  • 业务变化频繁

  • 重点核心业务领域

  • 可部分取用(分层思想、聚合、限界、架构设计、解耦思维等)

  • 团队共识即正确

业务架构是领域,技术架构是容器,脱离灵魂的容器没有技术意义。

Q&A

Q1:DDD重构时,如何协调产品上线需求的矛盾?

A1:首先,我们在进行 DDD 重构时,要依托公司技术中心的战略,公司对此表示鼓励和倡导;其次,重构模式包括修缮者、绞杀者、演进式。面临与产品上线需求的矛盾时,我们可以选择绞杀者,重新构建优化,在原有业务中也不会影响产品新需求的接入。

Q2:选择COLA架构作为DDD重构业务模型的原因是什么?

A2:首先,COLA 是阿里开源的,具有大厂背书,可信度较高;其次,COLA 具备优秀的分层架构和规范,项目 Github 中提供了最佳实践。如果初期不确定如何进行重构,可以直接参考官方 demo,将其映射到自己的业务中,后期再加入自身见解,进行系统优化。

腾讯视频DDD重构之路,看DDD极大价值

DDD 的指导思想很多时候较为晦涩,与实际业务场景下的架构设计往往难以紧密结合。本文尝试通过引入架构映射等方法,将二者相互融合,旨在提供一套量化的评估体系,并通过腾讯视频一起看系统的实践案例阐述如何运用。

本文将以腾讯视频一起看系统的架构重构实践为例,展示一套具有参考价值的领域建模、架构设计与量化评估标准。

01 领域驱动

1.1 DDD 简介

视频会员部门正在推进一个领域驱动的项目,期望运用 DDD 的一些理论,对会员技术体系的进行全面梳理。内容作为其中的一个子领域,也希望借助 DDD 的一些方法进行整个体系的建设:

  • 复杂度:业务复杂性(包括播放、购买、活动、内容展示、内容互动等全场景),以及技术复杂性(如业务规则、模块繁多、高并发、信息安全等),需要全面考虑。

  • 跨部门合作:目前的会员内容体系,涉及会员、直播中台、腾讯云、安平审核等多个部门,是一个典型的跨部门合作项目。

  • 体系梳理:会员业务涵盖内容展示、内容互动、内容合作和内容创新四个方面。

  • 领域模型:本文将重点借助领域图,将整个会员内容体系以直观的方式展现出来。

DDD 的核心方法,总结起来是以下四点:

图片

1.2 领域建模

在理解产品需求的基础上,从中提取出核心概念,然后建立起核心概念的逻辑结构,概念的逻辑结构即领域模型,通常可以用领域图或 ER 图来表示。领域模型有助于我们以一种抽象的视角来理解复杂业务。

对于复杂大系统,DDD 给出的基本操作方法就是<大分形>,即大而化之、分而治之、形而上之。将这个方法应用在内容体系上,可以做一个初步的领域建模。

图片

1.3 领域建模-例1

将上述方法应用于视频会员内容体系,进行初步的领域建模。针对会员内容体系业务梳理,以内容为核心,划分为以下四个部分:

  • 内容展示:包括频道页、一起看底层页的 3tab、端外频道页等。

  • 内容互动:涉及一起看业务、观看剧集、聊天、赠票、连麦、陪看等。

  • 内容合作:涵盖小说、漫画、知识付费等。

  • 内容创新:如 NFT 等。

图片

1.4 领域建模-例2

02 软件架构

2.1 定义架构

软件架构并无明确的定义,但我们可以通过【结构、架构特性、架构决策和设计原则】来阐述。

图片

  • 结构:指的是系统的架构风格类型,如分层架构、微服务架构、管道架构、事件驱动架构等。

  • 架构特征:指的是系统必须支持的功能,如可用性、可扩展性、容错性、性能、可维护性等。

  • 架构决策:是一组构建系统的规则,例如规定表现层只能通过逻辑层访问持久层。

  • 设计原则:是构建系统的指导原则,如采用服务间的异步消息传递以提高性能。

相较而言,架构决策的约束力比设计原则更强。

图片

2.2 架构特征

常见的架构特征有两大类,运营性架构与结构性架构,其定义如下:

图片

2.3 架构特征

除开上述的运营性和结构性架构特性,我们在过去的系统开发实践中,似乎一直忽视了一个重要的架构特性——模块化。模块化可以被视为一种隐性的架构特性。

下面一段引用自《软件架构》的话,很好地解释了模块化的概念:

关于软件架构的用语中有95%以上都在称颂“模块化”,而关于如何实现“模块化”的用语却少之又少。-Glenford J.Myers(1978年)

不同的平台提供了不同的代码重用机制,但所有平台都支持以某种方式将相关代码分组到模块中。尽管模块化在软件架构中具有通用性,但却一直没有统一的定义。从 Myers 的引言中可以看出,这是一个历史悠久的问题。

对于架构师来说,了解模块化及其多种形式,在开发平台中至关重要。许多分析架构的工具(如度量指标、适应度函数和可视化)都依赖于模块化概念。模块化是一种组织原则。如果架构师在设计系统时没有关注各个部分如何相互连接,那么最终构建的系统必将出现问题。从物理学的角度看,软件系统是对趋于熵(或无序)的复杂系统进行建模,必须向物理系统添加能量以保持秩序。软件系统也是如此:架构师必须不断消耗精力以确保良好的结构稳定性。

保持良好的模块化体现了我们对一种隐式架构特性的定义:几乎没有项目要求架构师确保良好的模块化划分,并以模块化为主题与项目成员进行沟通。而可持续的代码需要秩序和一致性。

我们用模块化来描述代码的逻辑分组,可以是面向对象语言中的一组类,也可以是结构化语言和函数式语言中的函数。大多数编程语言都提供了模块化的机制(如 Java 中的包(package)、NET 中的命名空间(namespace)等。开发人员通常使用模块作为将相关代码分组在一起的一种方式。

模块化对架构师至关重要,研究人员创建了各种跨语言的度量标准来帮助架构师理解模块化。我们重点关注三个关键概念:内聚性、耦合性和共生性。

通过对模块化特性的深入了解,架构师可以更好地应对复杂多样的系统架构挑战,确保软件系统的稳定性和可持续发展。在实际项目中,了解并践行模块化原则,对于提升系统质量及降低维护成本具有重要意义。

图片

内聚性及其度量:

图片

耦合性及其度量:

图片

共生性及其度量:

图片

统一耦合性与共生性:

图片

2.4 架构风格及度量

平时的实际工作中,常见的是以下六种架构:

分层架构。管道架构。微内核架构。微服务架构。事件驱动架构。编制驱动架构。

图片

我们可以通过以下特征来对各个架构进行评级:

图片

图片

03 架构映射

3.1 DDD 过程模型

作为开发,我们工作的本质就是把一个产品需求转化成一个可以运行的系统,中间涉及产品设计、领域建模、架构设计、详细设计、代码编写、测试等步骤。

如果不考虑编码过程,就 DDD 的基本过程进一步展开来说,大体是以下三点:

  1. 基于产品需求,提取核心概念,并构建核心概念的逻辑结构,即领域模型。领域模型通常可以用领域图或 ER 图表示。领域模型帮助我们以抽象视角理解复杂业务,但仅限于理解业务。我们最终目标是将领域模型转化为实际可运行的系统。

  2. 从领域模型到代码实现,通常需要通过系统架构进行衔接。将领域模型映射为系统架构至关重要。通常采用分层微服务架构,将领域模型中的概念分解到架构的各层。

  3. 在拥有领域模型和系统架构的基础上,对各个模块进行详细设计,包括模块流程、数据结构设计、数据库表设计等。完成这一步后,即可启动编码工作。

在整个 DDD 开发流程中,我们需要不断迭代和优化领域模型、系统架构和模块设计,以确保最终实现的系统能够满足业务需求并具备良好的可维护性。在实际项目中,合理运用 DDD 开发流程模型,有助于提高开发效率,降低维护成本,并使系统更具可扩展性。通过遵循这一流程,我们可以更好地应对业务的变化和挑战,为客户提供高质量的软件产品。

为便于理解,以下是我自己梳理的一个 DDD 过程模型:

图片

3.2 架构映射

在过去的项目中,很多时候我们在开始系统架构设计之前并没有进行领域建模。这主要是因为:

  1. 业务本身并不复杂,例如核心实体只有一个,然后仅需针对这个核心实体进行属性增删改查操作。大部分管理系统开发都属于这种情况。产品的交互和视觉设计基本可以替代领域建模,尤其在腾讯的研发体系中,我们通常根据产品 UI 稿来设计系统架构。

  2. 对于一些特别复杂的业务,我们需要在理解产品需求的基础上,提取核心概念构建领域模型,并将领域模型中的概念分解到系统架构的各层和模块。架构映射就是将领域模型映射到系统架构的过程。

  3. 在进行架构映射时,我们需要关注以下几个方面:

  • a. 分析业务需求,找出核心概念,构建领域模型。领域模型可以帮助我们更好地理解业务,为后续架构设计提供指导。

  • b. 将领域模型分解为系统架构的各层和模块。这样可以将复杂的业务逻辑分解为更易于管理和维护的模块。

  • c. 在架构设计中充分考虑系统性能、可扩展性、安全性等因素,确保系统能够稳定运行并适应业务发展。

  • d. 随着业务的发展和变化,不断优化和调整架构,使其保持与业务需求的匹配。

架构映射,以视频一起为例:

图片

04 架构映射

4.1 DDD 与重构

领域驱动设计的焦点在于系统设计阶段,但同样重视重构。在一定程度上,软件工程师仍是手工艺者,软件开发没有绝对的银弹,重构在系统成长过程中是不可或缺的调整手段。

因此,我们无需迷信所谓的银弹,也不必担心过度设计或设计不足,通过多次重构迭代,让合适的设计逐步浮现。

图片

4.2 一起看的技术债

视频一起看,经过几年不断的功能开发,已经堆砌了比较多的模块,而且是由两个跨部门的团队一起开发的,整体上缺乏统一设计,技术债务积累较多,亟需来一次整体重构。

图片

4.3 发起重构

整个重构规划如下:

图片

4.4 架构重构

在架构方面,分层不明确,下层服务过于理解业务逻辑,存在下层调用上层的问题。部分接口或函数职责重复,可以合并或归为一处。重复代码和逻辑较多,缺乏公共抽象。

老系统架构图:room_adapter 是结构瓶颈。

图片

具体的解决办法分为两点

  1. 明确架构分层,降低模块间不必要的耦合;

    严格遵守分层架构原则,上层服务可调用下层服务,下层服务不涉及业务逻辑。如上下层服务需交互,可通过逻辑解耦方式实现,如消息队列或中转。

  2. 调整领域划分,合理分配功能,避免模块过大或过小;

    重新规划领域划分,使功能分配更合理,各模块专注专属领域能力,便于后续开发与维护。

图片

重构前后架构对比:

图片

4.5 逻辑重构

涉及较多业务逻辑的部分,重构方法主要包括以下两点:

  1. 剥离非核心逻辑子域,确保业务主流程可读性;

    将非核心业务逻辑从主流程中抽取出来,作为子域进行封装,提高可读性和可维护性。

  2. 提取公共组件,消除代码复制,提高复用性;

下面是一个房间重构的例子:

以 room_adapter 为例,因为属于业务适配层,掺杂过多特殊业务逻辑,导致可读性和可维护性差。

通过梳理服务流程,将非核心业务逻辑剥离为子域,并封装为 trpc 拦截器,确保业务主流程的可维护性。

图片

在进一步分析系统重构完之后的效果时,我们先补充一点架构方面的知识点,这可以更方便地以一种量化的方式来评估重构效果。

05 效果评估

这部分通过介绍视频会员今年做的一个系统重构项目,结合上面的知识点,对这个项目做一整体量化分析。

5.1 问题与目标

一起看房间系统,这是一个重构项目,如果说明这个项目的意义,通常的结构就是“问题-目标-方案-效果”。

图片

5.2 效果的定性说明

在系统架构层面,重构前后的架构对比:

图片

重构前后,到底有什么效果,我们可以定性来说,比如:

  • 重构前:单体架构,难维护难扩展;

  • 重构后:微服务架构,内核简单,易于扩展。

  • 重构前:功能堆砌,没有整体规划;

  • 重构后:架构清晰,产品功能有序扩展。

对于非技术同学(产品、运营)来说,定性说明可能更容易理解,但定性说明本身比较随意,任何一个重构都可以讲出以上这些好处,所以没有区分度。

图片

5.3 效果的量化分析

在定性说明的基础上,有必要做进一步的量化分析,这样效果就更有说服力。

  • 在架构层面,重构前后的改进

我们可以利用上一节的架构风格及特征参数,从而能够做出量化分析,这样得出的结论就更有说服力。

  • 重构前系统:可以看做是微内核模式;

  • 重构后系统:可以看做是微服务模式。

这样我们就可以对比重构前系统与重构后系统的区别,可以看到重构后的系统在可测试性、扩展性及模块化方面都有明显改进。

图片

  • 在模块层面,重构前后的改进

  • 重构前:room adapter 模块包含了太多低耦合逻辑,除房间业务逻辑外,还有定时任务、房间回调、进房审批鉴权、消息发送。

  • 重构后:room_adapter 模块只保留房间业务逻辑,其他逻辑按高内聚低耦合拆分为独立小模块。

图片

  • 在代码层面,重构前后在四个维度(代码规范、千行问题、圈复杂度、千行超标复杂度)上的改进

图片

  • 代码量方面,重构前代码量约 4w 行,重构后 1.8w 行,降幅55%

图片

  • 性能方面,重构后 Top4 接口耗时都有明显优化,平均耗时降低45%左右

图片

  • 成本方面,通过下图趋势线可以看出,Top4 成本均呈下降趋势,其中 PCG-123 和日志服务 CLS 成本降幅明显,达到40%左右

图片

5.4 设计关键点

这里不准备长篇大论,通过以下几个关键设计点,从整体上了解一下【视频一起看房间系统】:

  • 系统架构

图片

  • 核心数据结构

图片

图片

  • 技术与业务指标

图片

关键技术指标:请求量 QPS 峰值:约3万/QPS,Redis 存储占用量:11.16GB;

关键业务指标:线上房间总数:约6k+,其中用户房约3K,系统房3k,运营房0.25k。

06 几点说明

在开发领域耕耘多年,我一直被以下几个问题所困扰。现在,我想针对这些问题分享自己的见解并进行深入探讨:

  • 当我们描述系统架构时,通常会提及高性能、可扩展性、可观察性等关键词。然而,我们缺乏一个统一的量化分析框架,以便不同架构风格之间进行对比。本文将尝试解决这一问题,使各种架构风格具有可比性;

  • 领域建模和架构设计之间的关系是什么,本文提出“架构映射”的概念,试图建立二者之间的转换关系;

  • 架构理论与架构设计的实践相结合,试图给出一套可操作、能落地、能量化对比的设计方法;

  • 做过很多重构,重构前后系统效果的说明,通常会采用定性描述,或者列举单点说明,比如性能、扩展性等,如何以一种可量化的整合的方式说明重构的效果。

美团抽奖平台DDD架构实操之路

至少30年以前,一些软件设计人员就已经认识到领域建模和设计的重要性,并形成了一种潮流,后来由 Eric Evans 将其命名为领域驱动设计(Domain-Driven Design,简称 DDD)。在互联网开发环境中,DDD 似乎显得有些“陈旧且缓慢”。然而,随着互联网公司逐渐深入实体经济,业务日趋复杂,我们在开发过程中也遇到了越来越多的传统行业软件开发所面临的问题。本文将首先探讨这些问题,然后尝试在实践中运用 DDD 的思想来解决这些问题。

过度耦合

在业务初期,我们的功能相对简单,普通的 CRUD 操作就能满足需求,此时系统结构清晰易懂。然而,随着迭代演进,业务逻辑变得越来越复杂,系统也逐渐变得庞大。各个模块之间的关联日益紧密,以至于难以明确划分某个模块的具体功能意图。在修改某个功能时,不仅要花费大量时间回溯所需修改的点,还需担忧修改带来的未知影响。

下图是一个常见的系统耦合病例。

图片

服务耦合示意图

在订单服务接口中,提供了查询、创建订单相关的接口,以及订单评价、支付、保险等接口。同时,我们的表格也是一个包含大量字段的订单大表。在维护代码时,一个小变动可能会影响到整个系统。很多时候,我们只想修改评价相关的功能,却意外地影响了创建订单的核心路径。尽管我们可以通过测试确保功能的完备性,但在订单领域有大量需求同时进行开发时,修改重叠、恶性循环,导致我们疲于奔命地修复各种问题。

这些问题本质上源于系统架构不清晰,划分出来的模块内聚度低、耦合度高。

为解决这一问题,我们可以采用演进式设计的理念,让系统设计随着实现的增长而增长。我们无需提前设计,只需让系统随业务成长而演进。敏捷实践中的重构、测试驱动设计和持续集成可以帮助我们应对各种混乱问题。重构可以改善代码质量,同时保持行为不变;测试驱动设计可以确保对系统的修改不会导致现有功能丢失或破坏;持续集成则让团队共享同一代码库。

在这三种实践中,重构是克服演进式设计中大杂烩问题的关键,通过在单独的类及方法级别上进行一系列小步重构来实现。我们可以很容易地将通用逻辑重构为一个独立的类,但你会发现这个类很难用业务含义来描述,只能给予一个技术维度上的解释。这会带来什么问题呢?新同学并不总是知道对通用逻辑的改动或获取来自该类。显然,制定项目规范并不是一个好主意。这种情况下,我们再次嗅到了代码即将腐败的气味。

实际上,你可能已经意识到问题所在。在解决现实问题时,我们将问题映射到脑海中的概念模型,然后在模型中解决问题,最后将解决方案转换为实际代码。上述问题在于我们解决了设计到代码之间的重构,但提炼出来的设计模型缺乏实际的业务含义,导致在开发新需求时,其他成员无法自然地将业务问题映射到设计模型。设计似乎变成了重构者的独角戏,代码继续恶化,不断重构……形成恶性循环。

领域驱动设计(DDD)能有效地解决领域模型到设计模型的同步和演化问题,最终将反映业务领域的设计模型转化为实际代码。

注:模型是我们解决实际问题所抽象出的概念模型,领域模型描述与业务相关的事实;设计模型则表示了要构建的系统。

贫血症和失忆症

贫血领域对象

贫血领域对象(Anemic Domain Object)指的是仅具备数据承载功能,而缺乏行为和操作的领域对象。

在习惯于 J2EE 开发模式(如 Action/Service/DAO)的情况下,我们很容易写出过程式代码,从而使得所学的面向对象(OO)理论无法发挥其作用。在这种开发方式中,对象仅作为数据的载体,缺乏实际的行为。以数据为核心,数据库实体关系(ER)设计成为驱动。在这种开发模式下,分层架构可以理解为数据移动、处理和实现的过程。

以笔者最近开发的系统抽奖平台为例:

  • 场景需求

奖池中设有多种奖项,我们需要根据运营预先设置的概率抽取其中一个奖项。实现方法很简单,生成一个随机数,然后匹配符合该随机数生成概率的奖项即可。

  • 贫血模型实现方案

先设计奖池和奖项的库表配置。

图片

抽奖ER图

  • 设计AwardPool和Award两个对象,只有简单的get和set属性的方法

class AwardPool {
    int awardPoolId;
    List<Award> awards;
    public List<Award> getAwards() {
        return awards;
    }
  
    public void setAwards(List<Award> awards) {
        this.awards = awards;
    }
    ......
}

class Award {
   int awardId;
   int probability;//概率
  
   ......
}
  • Service代码实现

设计一个LotteryService,在其中的drawLottery()方法写服务逻辑

AwardPool awardPool = awardPoolDao.getAwardPool(poolId);//sql查询,将数据映射到AwardPool对象
for (Award award : awardPool.getAwards()) {
   //寻找到符合award.getProbability()概率的award
}
  • 按照通常的实现思路,我们会发现:业务领域中至关重要的抽奖功能,其逻辑几乎全部写在 Service 中,而 Award 对象仅作为数据载体,没有实际行为。简单的业务系统采用这种贫血模型和过程化设计是没有问题的,但随着业务逻辑的复杂化,业务逻辑和状态分散在众多方法中,代码意图逐渐模糊,我们称之为“贫血症引发的失忆症”。

相较而言,领域驱动设计(DDD)将数据和行为封装在一起,并与现实世界中的业务对象相对应。各类对象分工明确,领域逻辑分散在领域对象中。以抽奖为例,概率选择和对应奖品的处理应放入 AwardPool 类中。

通过引入领域驱动设计,我们能更好地应对复杂业务场景,提高代码的可读性和可维护性。

软件系统复杂性应对

解决复杂和大规模软件的武器可以被粗略地归为三类:抽象、分治和知识。

分治:将问题领域划分为若干较小的、易于处理的子问题。这些子问题要足够简单,以便个人能够独立解决。同时,要考虑如何将这些子部分整合为整体。合理的分割能降低问题的复杂性,使整体装配过程中的细节追踪更为简便,从而更容易设计各部分的协作方式。评价分治效果的好坏,即判断其高内聚低耦合的程度。

抽象:运用抽象能简化问题空间,越小的抽象问题越容易理解。以从北京到上海的出差为例,我们可以先将其理解为使用交通工具出行,而不必一开始就详细考虑是乘坐高铁还是飞机,以及乘坐过程中应注意的事项。

知识 顾名思义,DDD可以认为是知识的一种。

DDD提供了这样的知识手段,让我们知道如何抽象出限界上下文以及如何去分治。

与微服务架构相得益彰

微服务架构与 DDD 在应对复杂性方面有相似之处。在创建微服务时,我们需要构建高内聚、低耦合的微服务。DDD 中的限界上下文理念与微服务不谋而合,可以将限界上下文视为一个微服务进程。

上述是从更直观的角度来描述两者的相似处。

在系统复杂之后,我们都需要用分治来拆解问题。一般有两种方式,技术维度和业务维度。技术维度是类似MVC这样,业务维度则是指按业务领域来划分系统。

微服务架构更强调从业务维度去做分治来应对系统复杂度,而DDD也是同样的着重业务视角。 如果两者在追求的目标(业务维度)达到了上下文的统一,那么在具体做法上有什么联系和不同呢?

我们将架构设计活动精简为以下三个层面:

  • 业务架构——根据业务需求设计业务模块及其关系

  • 系统架构——设计系统和子系统的模块

  • 技术架构——决定采用的技术及框架

以上三种活动,实际开发中,这些活动有先后顺序,但不一定固定。在面对常规问题时,我们自然会采用熟悉的分层架构(先确定系统架构),或选择合适的编程语言(先确定技术架构)。在业务不复杂的情况下,这种做法是合理的。

然而,跳过业务架构设计出来的架构关注点不在业务响应上,可能就是一个大泥球。在面临需求迭代或响应市场变化时,这种架构就会让人痛苦不堪。

DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据

综上所述,领域驱动设计(DDD)和微服务架构在应对复杂性方面有诸多相似之处,但它们在具体实践中有各自的侧重点和优势。在实际项目中,我们可以根据业务需求和系统复杂度,灵活运用这两种方法,实现更高效、易于维护的软件系统。

可以参见下图来更好地理解双方之间的协作关系:

图片

DDD与微服务关系

我们将通过一个抽奖平台的实例,详细阐述如何运用领域驱动设计(DDD)来拆解一个基于微服务架构的中型系统,实现系统的高内聚和低耦合。

首先看下抽奖系统的大致需求: 运营——可以配置一个抽奖活动,该活动面向一个特定的用户群体,并针对一个用户群体发放一批不同类型的奖品(优惠券,激活码,实物奖品等)。 用户-通过活动页面参与不同类型的抽奖活动。

设计领域模型的一般步骤如下:

  1. 根据需求划分出初步的领域和限界上下文,以及上下文之间的关系;

  2. 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象;

  3. 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;

  4. 为聚合根设计仓储,并思考实体或值对象的创建方式;

  5. 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。

通过以上步骤,我们能够将复杂系统分解为更具内聚性和耦合度的模块。在实际开发过程中,团队成员需密切协作,不断积累和共享领域知识,以确保项目的顺利进行。此外,在面临需求变更或市场波动时,我们应灵活调整业务架构,实现系统的高效响应和持续优化。

战略建模

在领域驱动设计(DDD)中,战略和战术设计有着明确的划分。战略设计主要关注高层次和宏观层面的限界上下文划分与集成,而战术设计则聚焦于运用建模工具对上下文进行更为具体的细化。

领域

现实世界中,领域包含了问题域和解系统。软件开发可以视为对现实世界的部分模拟。在 DDD 中,解系统可映射为多个限界上下文,每个上下文代表针对问题域的一个特定、有限解决方案。

限界上下文

限界上下文

一个由显式边界限定的特定职责范围。领域模型位于此边界内。在此范围内,每个模型概念(包括其属性和操作)都具有特殊含义。

一个给定的业务领域包含多个限界上下文。要与某个上下文进行通信,需通过显式边界进行交互。系统通过确定性的限界上下文实现解耦,使每个上下文内部组织紧密、职责明确,具备较高的内聚性。

一个形象的隐喻:细胞质之所以存在,是因为细胞膜限定了细胞内外的物质,并确定了何种物质可通过细胞膜。

划分限界上下文

划分限界上下文的方法在 Eric Evans 和 Vaughn Vernon 的著作中并未详细提及。

我们不应根据技术架构或开发任务来创建限界上下文,而应关注语义边界。

我们的实践是,首先考虑产品所使用的通用语言,提取一些术语称为概念对象,并探讨对象之间的联系;或者从需求中提取动词,观察动词与对象之间的关系;我们将紧密耦合的元素圈在一起,研究其内在联系,从而确定相应的界限上下文。形成后,我们可以尝试用语言描述界限上下文的职责,看其是否清晰、准确、简洁和完整。简而言之,限界上下文应从需求出发,根据领域进行划分

如前所述,我们将用户分为运营和用户。运营负责复杂但相对低频的抽奖活动配置,而用户对配置的使用则呈高频且无感知。根据这一业务特点,我们将抽奖平台划分为面向 C 端的抽奖和面向 M 端的抽奖管理平台两个子域,实现完全解耦。

图片

抽奖平台领域

在明确了 M 端和 C 端的界限上下文后,我们进一步对各自主体内进行界限上下文的划分。此处,我们以 C 端为例进行说明。

产品的需求概述如下:

  1. 抽奖活动有活动限制,例如用户的抽奖次数限制,抽奖的开始和结束的时间等;

  2. 一个抽奖活动包含多个奖品,可以针对一个或多个用户群体;

  3. 奖品有自身的奖品配置,例如库存量,被抽中的概率等,最多被一个用户抽中的次数等等;

  4. 用户群体有多种区别方式,如按照用户所在城市区分,按照新老客区分等;

  5. 活动具有风控配置,能够限制用户参与抽奖的频率。

依据产品需求,我们提炼出关键概念作为子域,构建限界上下文。

图片

C端抽奖领域

首先,抽奖上下文作为整个领域的核心,负责处理用户抽奖业务,涵盖奖品和用户群体概念。

  • 在设计初期,我们曾考虑将抽奖与发奖划分为两个领域,前者负责选奖,后者负责发放奖品。然而,实际开发过程中发现这两部分逻辑紧密相连,不易分割。并且,发奖逻辑相对简单,仅需调用第三方服务发放奖品,不足以独立成为一个领域。

针对活动限制,我们定义了活动准入的通用语言,将活动开始/结束时间、活动参与次数等限制条件纳入活动准入上下文中。

关于抽奖奖品库存量,库存行为与奖品本身相对独立,库存关注点更多在于库存核销,且库存具备通用性,可被非奖品内容使用。因此,我们设立了独立的库存上下文。

鉴于 C 端存在刷单行为,我们根据产品需求设立了风控上下文,用于对活动进行风险控制。最后,活动准入、风控、抽奖等领域均涉及次数限制,故我们设立了计数上下文。

通过领域驱动设计(DDD)的限界上下文划分,我们明确了抽奖、活动准入、风控、计数、库存等五个上下文,确保每个上下文在系统中具备高度内聚性。

上下文映射图

在进行上下文划分之后,我们还需要进一步梳理上下文之间的关系。

康威(梅尔·康威)定律

任何组织在设计一套系统(广义概念上的系统)时,其提交的设计方案在结构上都会与该组织的沟通架构保持一致。

康威定律表明,系统结构应尽可能与组织结构保持一致。在这里,我们认为团队结构(无论是内部团队还是跨团队)都属于组织结构,而限界上下文则是系统的业务结构。因此,团队结构应与限界上下文保持一致。

梳理清楚上下文之间的关系,从团队内部的关系来看,有如下好处:

  1. 任务更好拆分,一个开发人员都能全力以赴地投入到某个单一的上下文中;

  2. 沟通交流更为顺畅,每个上下文都能明确自己对其他上下文的依赖关系,使团队内部开发工作更好地相互对接。

从团队间的关系来看,明确的上下文关系能带来以下帮助:

  1. 每个团队在自己的上下文中能更明确地理解自己领域内的概念,因为上下文是领域的子系统;

  2. 对于限界上下文之间的交互,团队与上下文的一致性保证了我们有明确的对接团队和依赖的上下游。

限界上下文之间的映射关系

  • 合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。

  • 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。

  • 客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。

  • 遵奉者(Conformist):下游上下文只能盲目依赖上游上下文。

  • 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。

  • 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。

  • 发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。

  • 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。

  • 另谋他路(SeparateWay):两个完全没有任何联系的上下文。

上述内容定义了上下文之间的映射关系。经过仔细考虑,抽奖平台上下文的映射关系图如下:

图片

上下文映射关系

由于抽奖、风控、活动准入、库存、计数五个上下文均属于抽奖领域内部,因此它们之间形成了“同甘共苦,共进退”的合作关系(Partnership,简称 PS)。

同时,抽奖上下文在发放奖品时,会依赖券码、平台券、外卖券三个上下文。抽奖上下文通过防腐层(Anticorruption Layer,ACL)与这三个上下文隔离,而三个券上下文则通过开放主机服务(Open Host Service)作为发布语言(Published Language)为抽奖上下文提供访问机制。

通过上下文映射关系,我们明确的限制了限界上下文的耦合性,即在抽奖平台中,无论是上下文内部交互(合作关系)还是与外部上下文交互(防腐层),耦合度都限定在数据耦合(Data Coupling)的层级。

战术建模——细化上下文

梳理清楚上下文之间的关系后,我们需要从战术层面上剖析上下文内部的组织关系。首先看下DDD中的一些定义。

实体

当一个对象的识别(而非属性)使其具有独特性时,该对象被称为实体(Entity)。

例:最简单的,公安系统的身份信息录入,对于人的模拟,即认为是实体,因为每个人是独一无二的,且其具有唯一标识(如公安系统分发的身份证号码)。

在实践上建议将属性的验证放到实体中。

值对象

当一个对象用于对事务进行描述而没有唯一标识时,它被称作值对象(Value Object)。

例:比如颜色信息,我们只需要知道{“name”:“黑色”,”css”:“#000000”}这样的值信息就能够满足要求了,这避免了我们对标识追踪带来的系统复杂性。

值对象很重要,尤其在习惯使用数据库进行数据建模后,容易将所有对象都视为实体。使用值对象可以优化系统性能、简化设计。

它具有不变性、相等性和可替换性。

实践中,值对象创建后不应再允许外部修改其属性。在不同上下文整合时,可能会出现模型概念的共享,如商品模型在电商的各个上下文中都有。若在订单上下文中仅关注下单时的商品信息快照,将商品对象视为值对象是合理的选择。

聚合根

Aggregate(聚合)是一组相关对象的集合,作为一个整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。

聚合是一个非常重要的概念,核心领域往往都需要用聚合来表达。其次,聚合在技术上有非常高的价值,可以指导详细设计。

聚合由根实体,值对象和实体组成。

如何创建好的聚合?

  • 边界内的内容具有一致性:在一个事务中只修改一个聚合实例。若边界内难以实现强一致性,无论出于性能或产品需求考虑,都应考虑拆分成独立的聚合,采用最终一致性。

  • 设计小聚合:大部分聚合仅包含根实体,无需包含其他实体。即使需要包含,也可以考虑将其创建为值对象。

  • 通过唯一标识来引用其他聚合或实体:当对象之间存在关联时,建议引用其唯一标识而非整体对象。如果是外部上下文中的实体,引用其唯一标识或将需要的属性构造为值对象。若聚合创建复杂,建议使用工厂方法来屏蔽内部创建逻辑。

聚合内部多个组成对象的关系可以用来指导数据库创建,但不可避免存在一定的抗阻。如聚合中存在List<值对象>,那么在数据库中建立 1:N 关联需将值对象单独建表,此时应有 id,建议不要将该 id 暴露到资源库外部,对外隐蔽。

领域服务

一些重要的领域行为或操作可以归为领域服务。领域服务既不属于实体,也不属于值对象的范畴。

采用微服务架构风格后,所有领域逻辑的对外暴露都需通过领域服务进行。例如,原本由聚合根暴露的业务逻辑也需要依赖领域服务。

领域事件

领域事件是对领域内发生的活动进行的建模。

抽奖平台的核心上下文是抽奖上下文,接下来介绍下我们对抽奖上下文的建模。

图片

抽奖上下文

在抽奖上下文中,我们通过抽奖(DrawLottery)这个聚合根来控制抽奖行为,可以看到,一个抽奖包括了抽奖ID(LotteryId)以及多个奖池(AwardPool),而一个奖池针对一个特定的用户群体(UserGroup)设置了多个奖品(Award)。

此外,在抽奖领域中,我们还使用抽奖结果(SendResult)作为输出信息,以及用户领奖记录(UserLotteryLog)作为领奖凭据和存根。

谨慎使用值对象

在实践中,我们发现部分领域对象符合值对象理念,但随着业务变化,许多原有定义需调整。值对象可能在业务层面具有唯一标识需求,而对这类值对象的重构成本较高。因此,在特定场景下,我们要根据实际情况权衡领域对象选择。

DDD工程实现

在对上下文进行细化后,我们开始在工程中真正落地DDD。

模块

在 DDD 中,模块(Module)被明确作为控制限界上下文的一种方法,我们一般在工程中力求用一个模块来体现一个领域的限界上下文。

如代码中所示,一般的工程中包的组织方式为{com.公司名.组织架构.业务.上下文.*},这样的结构可以明确地将一个上下文限制在包内。

import com.company.team.bussiness.lottery.*;//抽奖上下文
import com.company.team.bussiness.riskcontrol.*;//风控上下文
import com.company.team.bussiness.counter.*;//计数上下文
import com.company.team.bussiness.condition.*;//活动准入上下文
import com.company.team.bussiness.stock.*;//库存上下文

代码演示1 模块的组织

对于模块内的组织结构,一般情况下我们是按照领域对象、领域服务、领域资源库、防腐层等组织方式定义的。

import com.company.team.bussiness.lottery.domain.valobj.*;//领域对象-值对象
import com.company.team.bussiness.lottery.domain.entity.*;//领域对象-实体
import com.company.team.bussiness.lottery.domain.aggregate.*;//领域对象-聚合根
import com.company.team.bussiness.lottery.service.*;//领域服务
import com.company.team.bussiness.lottery.repo.*;//领域资源库
import com.company.team.bussiness.lottery.facade.*;//领域防腐层

代码演示2 模块的组织

每个模块的具体实现,我们将在下文中展开。

领域对象

前文提到,领域驱动的一个重要目标是解决对象的贫血问题。这里,我们用之前定义的抽奖(DrawLottery)聚合根和奖池(AwardPool)值对象来进行具体说明。

抽奖聚合根保留了抽奖活动的 id 以及该活动下所有可用奖池的列表,其核心领域功能是根据抽奖场景(DrawLotteryContext)选择合适的奖池,即 chooseAwardPool 方法。

chooseAwardPool 的逻辑如下:DrawLotteryContext 会携带用户抽奖时的场景信息(如抽奖得分或所在城市),DrawLottery 根据这些场景信息,匹配一个能为用户发放奖品的 AwardPool。

package com.company.team.bussiness.lottery.domain.aggregate;
import ...;
  
public class DrawLottery {
    private int lotteryId; //抽奖id
    private List<AwardPool> awardPools; //奖池列表
  
    //getter & setter
    public void setLotteryId(int lotteryId) {
        if(id<=0){
            throw new IllegalArgumentException("非法的抽奖id"); 
        }
        this.lotteryId = lotteryId;
    }
  
    //根据抽奖入参context选择奖池
    public AwardPool chooseAwardPool(DrawLotteryContext context) {
        if(context.getMtCityInfo()!=null) {
            return chooseAwardPoolByCityInfo(awardPools, context.getMtCityInfo());
        } else {
            return chooseAwardPoolByScore(awardPools, context.getGameScore());
        }
    }
     
    //根据抽奖所在城市选择奖池
    private AwardPool chooseAwardPoolByCityInfo(List<AwardPool> awardPools, MtCifyInfo cityInfo) {
        for(AwardPool awardPool: awardPools) {
            if(awardPool.matchedCity(cityInfo.getCityId())) {
                return awardPool;
            }
        }
        return null;
    }
  
    //根据抽奖活动得分选择奖池
    private AwardPool chooseAwardPoolByScore(List<AwardPool> awardPools, int gameScore) {...}
}

代码演示3 DrawLottery

在匹配到一个具体的奖池之后,需要确定最后给用户的奖品是什么。这部分的领域功能在AwardPool内。

package com.company.team.bussiness.lottery.domain.valobj;
import ...;
  
public class AwardPool {
    private String cityIds;//奖池支持的城市
    private String scores;//奖池支持的得分
    private int userGroupType;//奖池匹配的用户类型
    private List<Awrad> awards;//奖池中包含的奖品
  
    //当前奖池是否与城市匹配
    public boolean matchedCity(int cityId) {...}
  
    //当前奖池是否与用户得分匹配
    public boolean matchedScore(int score) {...}
  
    //根据概率选择奖池
    public Award randomGetAward() {
        int sumOfProbablity = 0;
        for(Award award: awards) {
            sumOfProbability += award.getAwardProbablity();
        }
        int randomNumber = ThreadLocalRandom.current().nextInt(sumOfProbablity);
        range = 0;
        for(Award award: awards) {
            range += award.getProbablity();
            if(randomNumber<range) {
                return award;
            }
        }
        return null;
    }
}

代码演示4 AwardPool

与以往的仅有getter、setter的业务对象不同,领域对象拥有了行为,变得更加丰满。同时,相较于将此类逻辑写在服务中(例如**Service),领域功能的内聚性更强,职责更加明确。

资源库

领域对象需要存储资源,存储方式多种多样,如数据库、分布式缓存、本地缓存等。资源库(Repository)负责对领域的存储和访问进行统一管理。在抽奖平台中,我们采用如下方式组织资源库。

//数据库资源
import com.company.team.bussiness.lottery.repo.dao.AwardPoolDao;//数据库访问对象-奖池
import com.company.team.bussiness.lottery.repo.dao.AwardDao;//数据库访问对象-奖品
import com.company.team.bussiness.lottery.repo.dao.po.AwardPO;//数据库持久化对象-奖品
import com.company.team.bussiness.lottery.repo.dao.po.AwardPoolPO;//数据库持久化对象-奖池
  
import com.company.team.bussiness.lottery.repo.cache.DrawLotteryCacheAccessObj;//分布式缓存访问对象-抽奖缓存访问
import com.company.team.bussiness.lottery.repo.repository.DrawLotteryRepository;//资源库访问对象-抽奖资源库

代码演示5 Repository组织结构

资源库对外的整体访问由Repository提供,它聚合了各资源库的数据信息,同时也承担了资源存储的逻辑(如缓存更新机制等)。

在抽奖资源库中,我们避免了直接访问底层奖池和奖品,只对抽奖的聚合根进行资源管理。代码示例展示了抽奖资源获取的方法(最常见的 Cache Aside Pattern)。

与过去将资源管理放在服务中的做法相比,由资源库负责管理资源,职责更明确,代码可读性和可维护性更强。

package com.company.team.bussiness.lottery.repo;
import ...;
  
@Repository
public class DrawLotteryRepository {
    @Autowired
    private AwardDao awardDao;
    @Autowired
    private AwardPoolDao awardPoolDao;
    @AutoWired
    private DrawLotteryCacheAccessObj drawLotteryCacheAccessObj;
  
    public DrawLottery getDrawLotteryById(int lotteryId) {
        DrawLottery drawLottery = drawLotteryCacheAccessObj.get(lotteryId);
        if(drawLottery!=null){
            return drawLottery;
        }
        drawLottery = getDrawLotteyFromDB(lotteryId);
        drawLotteryCacheAccessObj.add(lotteryId, drawLottery);
        return drawLottery;
    }
  
    private DrawLottery getDrawLotteryFromDB(int lotteryId) {...}
}

代码演示6 DrawLotteryRepository

防腐层

亦称适配层。在一个上下文中,有时需要对外部上下文进行访问,通常会引入防腐层的概念来对外部上下文的访问进行一次转义。

有以下几种情况会考虑引入防腐层:

  • 需要将外部上下文的模型转换为本地上下文能理解的模型。

  • 不同上下文之间的团队合作,如果是供体关系,建议引入防腐层,以防止外部上下文变化对本上下文的侵蚀。

  • 该访问本上下文使用广泛,为了避免改动影响范围过大。

如果内部多个上下文需要访问外部上下文,可以考虑将其纳入通用上下文中。

在抽奖平台中,我们定义了用户城市信息防腐层(UserCityInfoFacade),用于处理外部用户城市信息上下文(在微服务架构下表现为用户城市信息服务)。。

以用户信息防腐层为例,它接收抽奖请求参数(LotteryContext)作为输入,输出城市信息(MtCityInfo)。

package com.company.team.bussiness.lottery.facade;
import ...;
  
@Component
public class UserCityInfoFacade {
    @Autowired
    private LbsService lbsService;//外部用户城市信息RPC服务
     
    public MtCityInfo getMtCityInfo(LotteryContext context) {
        LbsReq lbsReq = new LbsReq();
        lbsReq.setLat(context.getLat());
        lbsReq.setLng(context.getLng());
        LbsResponse resp = lbsService.getLbsCityInfo(lbsReq);
        return buildMtCifyInfo(resp);
    }
  
    private MtCityInfo buildMtCityInfo(LbsResponse resp) {...}
}

代码演示7 UserCityInfoFacade

领域服务

上文中,我们将领域行为封装至领域对象,资源管理行为纳入资源库,外部上下文交互行为通过防腐层实现。如此一来,领域服务的职责变得更加明确,即充当领域内对象行为(如领域对象、资源库和防腐层等)的串联,为其他上下文提供交互接口。

我们以抽奖服务(issueLottery)为例,省略防御性逻辑(如异常处理、空值判断等)后,领域服务逻辑简洁明了。

package com.company.team.bussiness.lottery.service.impl
import ...;
  
@Service
public class LotteryServiceImpl implements LotteryService {
    @Autowired
    private DrawLotteryRepository drawLotteryRepo;
    @Autowired
    private UserCityInfoFacade UserCityInfoFacade;
    @Autowired
    private AwardSendService awardSendService;
    @Autowired
    private AwardCounterFacade awardCounterFacade;
  
    @Override
    public IssueResponse issueLottery(LotteryContext lotteryContext) {
        DrawLottery drawLottery = drawLotteryRepo.getDrawLotteryById(lotteryContext.getLotteryId());//获取抽奖配置聚合根
        awardCounterFacade.incrTryCount(lotteryContext);//增加抽奖计数信息
        AwardPool awardPool = lotteryConfig.chooseAwardPool(bulidDrawLotteryContext(drawLottery, lotteryContext));//选中奖池
        Award award = awardPool.randomChooseAward();//选中奖品
        return buildIssueResponse(awardSendService.sendAward(award, lotteryContext));//发出奖品实体
    }
  
    private IssueResponse buildIssueResponse(AwardSendResponse awardSendResponse) {...}
}

代码演示8 LotteryService

数据流转

图片

数据流转

在抽奖平台实践中,数据流动如图所示。首先,领域开放服务通过数据传输对象(DTO)与外界进行数据交互;领域内部利用领域对象(DO)作为数据和行为载体;资源库内部则沿用原有数据库持久化对象(PO)进行数据库资源交互。DTO 与 DO 的转换发生在领域服务内,DO 与 PO 的转换则在资源库内完成。

相较于传统业务服务,当前编码规范可能多了一次数据转换,但各数据对象职责清晰,数据流动更加明确。

上下文集成

通常有多种方法可以集成上下文,常见的包括开放领域服务接口、开放 HTTP 服务以及消息发布 - 订阅机制。

在抽奖系统中,我们使用开放服务接口进行交互。最明显的例子是计数上下文,它作为一个通用上下文,为抽奖、风控、活动准入等上下文提供了访问接口。同时,如果在集成一个上下文时需要一定的隔离和适配,可以引入防腐层的概念。这部分的示例可以参考前文的防腐层代码示例。

分离领域

接下来讨论如何在实施领域模型的过程中将系统架构应用到实际项目中。

我们采用微服务架构风格,与 Vernon 在《实现领域驱动设计》中的观点略有差异。具体差异可以参考他的书籍。

如果我们维护一个从前到后的应用系统:

在下图中,领域服务采用微服务技术剥离,独立部署,对外仅暴露服务接口。领域对外的业务逻辑需依托于领域服务。而在 Vernon 的著作中,未假设微服务架构,因此领域层除领域服务外,还包括聚合、实体和值对象等。应用服务层相对简单,负责接收接口层请求参数,调度多个领域服务以实现界面层功能。

图片

DDD-分层

随着业务发展,业务系统快速膨胀,我们的系统处于核心地位时:

应用服务虽然没有领域逻辑,但涉及了对多个领域服务的编排。当业务规模庞大到一定程度,编排本身就富含了业务逻辑(除此之外,应用服务在稳定性、性能方面所做的措施也希望统一起来,而非散落各处),那么此时应用服务对于外部来说是一个领域服务,整体看起来则是一个独立的限界上下文。

此时应用服务对内仍属于应用服务,对外已是领域服务的概念,需要将其暴露为微服务。

图片

DDD-系统架构图

注:具体架构实践可根据团队和业务实际情况进行,此处仅为作者业务实践。除分层架构外,CQRS 架构也是不错的选择。

以下为一个示例。我们定义了抽奖、活动准入、风险控制等多个领域服务。在本系统中,需集成多个领域服务,为客户端提供功能完备的抽奖应用服务。这个应用服务的组织如下:

package ...;
  
import ...;
  
@Service
public class LotteryApplicationService {
    @Autowired
    private LotteryRiskService riskService;
    @Autowired
    private LotteryConditionService conditionService;
    @Autowired
    private LotteryService lotteryService;
     
    //用户参与抽奖活动
    public Response<PrizeInfo, ErrorData> participateLottery(LotteryContext lotteryContext) {
        //校验用户登录信息
        validateLoginInfo(lotteryContext);
        //校验风控 
        RiskAccessToken riskToken = riskService.accquire(buildRiskReq(lotteryContext));
        ...
        //活动准入检查
        LotteryConditionResult conditionResult = conditionService.checkLotteryCondition(otteryContext.getLotteryId(),lotteryContext.getUserId());
        ...
        //抽奖并返回结果
        IssueResponse issueResponse = lotteryService.issurLottery(lotteryContext);
        if(issueResponse!=null && issueResponse.getCode()==IssueResponse.OK) {
            return buildSuccessResponse(issueResponse.getPrizeInfo());
        } else {   
            return buildErrorResponse(ResponseCode.ISSUE_LOTTERY_FAIL, ResponseMsg.ISSUE_LOTTERY_FAIL)
        }
    }
  
    private void validateLoginInfo(LotteryContext lotteryContext){...}
    private Response<PrizeInfo, ErrorData> buildErrorResponse (int code, String msg){...}
    private Response<PrizeInfo, ErrorData> buildSuccessResponse (PrizeInfo prizeInfo){...}
} 

代码演示9 LotteryApplicationService

在本文中,我们采用了分治的思想,从抽象到具体探讨了领域驱动设计(DDD)在实际互联网业务系统中的应用。通过这一有力工具,我们使得系统架构更加合理。

然而,需要指出的是,如果你的系统相对简单,或者只是类似于 SmartUI 这样的项目,那么可能并不需要采用 DDD。尽管本文对贫血模型、演进式设计提出了一些观点,但在特定范围和场景下,它们可能更为高效。读者应根据自身实际情况做出选择,找到最适合自己的方案。

本文通过介绍 DDD 的软件设计原则和方法,旨在实现高内聚低耦合,紧密贴合本质。读者可以根据自己的理解和团队状况实践 DDD。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值