DDD领域驱动设计在中小型项目里的落地实现

对于现在交互模式的业务系统而言,普遍追求开发迭代效率高,系统自身的高可用等基础能力。解决方案之一便是引入微服务架构。通过业务拆分,服务冗余,请求分流,快速扩展等方式来满足以上要求。而具体微服务架构的设计,便可以基于DDD的指导思想来完成。DDD是一套设计方法,从大粒度的业务领域来看,DDD设计思想就像是两种代码设计原则的更广层次的推广(高内聚,低耦合/单一职责),目的是识别拆分出职责不同的更小粒度的并且自身功能高度内聚的服务集合;从业务流转层面来说,强调的是业务之间的异步驱动和编排;
摘要由CSDN通过智能技术生成

DDD领域驱动设计在中小型项目里的落地实现



前言

对于现在交互模式的业务系统而言,普遍追求开发迭代效率高,系统自身的高可用等基础能力。解决方案之一便是引入微服务架构。通过业务拆分,服务冗余,请求分流,快速扩展等方式来满足以上要求。而具体微服务架构的设计,便可以基于DDD的指导思想来完成。

DDD是一套设计方法,从大粒度的业务领域来看,DDD设计思想就像是两种代码设计原则的更广层次的推广(高内聚,低耦合/单一职责),目的是识别拆分出职责不同的更小粒度的并且自身功能高度内聚的服务集合;从业务流转层面来说,强调的是业务之间的异步驱动和编排;从业务数据层面说,强调的是隔离和最终一致。

简单说引入DDD架构具有以下几个好处:

1. 开发方式更与现实情况契合,基于抽象化的领域知识,采用自顶向下的开发方式,专注于在领域模型内部完成现实概念的信息内聚,概念行为的内部表达。屏蔽底层库表设计以及避免数据存储方式带来的业务表达上的妥协(如挣扎于将对象的属性映射到表里并满足数据库的范式要求).

2. 领域模型作为功能黑盒,只提供必要的输入输出同外界交互。概念边界清晰,高度自治,数据事务性冲突降低,易于团队聚焦开发和服务的中台化。

3. 以事件驱动作为业务编排的重要途径,系统并行程度高,业务之间的耦合程度降低,可以简化一个复杂业务模型的开发步骤.

4. 提供了各种设计约束和业务保障的方法论,有助于统一团队的开发风格和技术栈依赖.

本文不具体涉及DDD思想中所包含概念的详细说明,主要是通过👇这个具体项目来走一遍架构落地流程,抛个板砖引块玉。结构说明示例项目

 私域引流领域确定多引流渠道来源里单一用户的最早邀请链条关系

 引流渠道包括:
	1. 企业微信群聊
	2. 个人微信群聊
	3. 小程序
	4. 公众号

 渠道1,2 由第三方接口拉回群组成员信息; 3,4 由端上实时上报授权用户的访问信息
【唯一用户】的归类基于昵称一致,头像相似度,地域一致,手动标记等方式完成
 用户邀请关系链条为员工引流业绩,商品售卖提供基础计算数据
 
 要求:
 	1. 实时构建用户所属的最早邀请链条
 	2. 邀请聊条构建过程中不影响当前实时链条数据的高频次查询
 	3. 服务集群高可用,能快速扩容以应对引流活动高峰的情况
 	4. 能追溯用户链条变更的生命周期日志

 项目基于以下技术栈开发:
 	1. springboot+jpa
	2. kafka服务
 	3. redis服务+redisson
	3. mysql8.0(作递归查询完整邀请链条)
	4. 自研事件发布订阅中间件
	5. 自研分布式任务调度中间件
	
 自研部分主要目的有:
	1. 服务自身内部便支持集群环境需要的功能,尽量减少架构落地所依赖部署的额外公共服务数量
	2. 隔离依赖公用服务(消息队列,zk)的不同系统之间带来的级联性能影响,做到当前服务集群内部环境的闭合
	3. 简化引入事件发布订阅/分布式任务调度等框架带来的业务入侵,以一种最简化的方式引入框架功能(springboot编程习惯)
	4. 以多机分片+主从冗余的方式重写框架功能,保证框架功能在服务集群内部的高可用

一、DDD设计流程

1. 设计流程

领域驱动设计这一概念,可以从以下两点来理解

  1. 由业务涉及的领域知识驱动业务系统的设计
  2. 由业务系统产生的行为变更通知驱动不同业务系统完成自身的业务逻辑

在战略设计阶段,强调的是高度的抽象,无需关心底层代码的具体实现及技术选型,先以一种产研测都能理解的图示标注方式表达出现实业务系统包含的领域概念,领域概念包含的信息,行为以及行为发生后可能需要驱动的后续操作。通过识别出完善的业务领域中的小粒度的领域概念并组织他们的关系来完成最终设计。
后续便是基于DDD的战术设计要求,先设计出小粒度领域概念包含的聚合,实体,值对象,事件等成员类,这里强调的是【面向对象设计】,【自顶向下的开发方向】,【高度内聚】这几个原则,经验上来说包含

1. 使用值对象封装标识类属性(业务主键或者其余领域概念实体的ID),如公司ID(封装出CompanyId),赋予属性明确的领域类型

2. 封装有[数据变更原子性]要求的一批关联属性到单一值对象中

3. 使用枚举类类型的值对象来表征各类状态

4. 非必要的情况下忽略数据库一对多多对多的范式设计要求,以集合类型存储实体所必要的结构化信息

5. 丰富实体的业务行为,将变更业务的操作入口控制在实体内部,对外屏蔽操作细节

6. 丰富实体的通知事件类型,用于事件回溯实体生命周期及后续驱动其他业务流程

7. 抽离单一职责的依赖外部信息的接口用于解耦

8. 必要情况下实现【领域服务】用于编排聚合操作和防止外部输入带来的腐蚀

最终对外层提供服务的是聚合这一概念,包含丰富的业务属性信息以及完善的业务行为,并能通过调用行为引起聚合状态变更对外发出事件通知。

聚合边界划分的指导原则有:

1. 生命周期一致(聚合内部的对象,应该和聚合根具有相同的生命周期,聚合根消失,则聚合内部的所有对象都应该一起消失)

2. 问题域一致(不应该包含脱离当前领域模型还能存在的对象)

3. 操作场景一致(对象操作频率的一致性)

4. 适当的聚合大小

2. 微服务架构

2.1 微服务间架构

基于读写分离原则,邀请链条计算过程(计算资源消耗大)和链条查询(读操作频繁)做服务分离,以微服务单体间事件同步的方式同步数据,底层采用不同的数据库实例保证各自的读写性能,隔离业务之间性能差异带来的相互影响。
即拆分为:
1. invitation_relationship_calc(计算项目)
2. invitation_relationship_query(查询项目)

两个不同系统,分别多实例部署提供服务

invitation_relationship_calc项目通过api接口上报,其余系统推送到消息队列消费的数据作为数据输入来源,输出不同来源聚合后【唯一用户】的邀请链归属关系。

invitation_relationship_query项目输入邀请链归属关系,计算员工【引流报表】,【引流商品订单归属】等基础查询数据。

2.2 微服务内分层

DDD四层架构结构:

  1. 用户接口层(api-前端适配入口)
  2. 应用层(applications-聚合的事务性操作,业务编排等)
  3. 核心领域层(domain-核心业务操作)
  4. 基础设施层(infa-事件监听/数据库/网关/框架配置/解耦类接口实现/任务调度等各类基础依赖)

项目包结构组织关系:
项目包结构组织关系

3. 聚合设计和业务流程编排

引流员工在不同引流来源下进行引流操作,如微信环境发布邀请链接,分享小程序卡片,埋点行为上报;群聊环境拉目标用户入群等操作。
微信环境包含用户的unionid标识,群聊环境包含用户的群成员ID标识,其余便是用户都包含的
【昵称】【头像】【位置】【父邀请人unionId/群成员ID】【被邀请时间】等信息。

我们的目的是识别出不同来源下是同一个人的用户,并根据不同来源下被父级邀请的时间先后确定该用户准确的最早引流员工。
并且在当前用户的最早邀请员工发生变化时,需要实时更新当前用户以及被当前用户邀请的下级用户链条里所有人的归属员工信息。
最后实时同步到其他依赖这个基础数据的系统里用于查询计算。

预设业务性能场景:
	1. 普通微信环境的用户访问在引流活动高峰时期QPS可能在几千到几万的量级
	2. 群聊用户数量在几千到几万的量级,且实时新入群用户QPS在几百到几千的量级
	3. 实时邀请链条计算出的引流归属员工信息的查询QPS在千级别

从业务描述中我们可以大致识别出:

1. 不同来源下的用户概念
	1. 普通微信环境
	2. 群聊环境
2. 合并不同来源用户信息的概念
3. 群聊相关信息概念
4. 发起引流活动的员工概念

基于此,我们可以大略组织出聚合包含的信息,但在继续设计详细聚合内部结构之前,需要考虑最开始提到的高性能和高峰处置能力,即以下两个思考问题:

1. 如何避免单一数据的并发修改冲突?
2. 如何做到高性能且实时构建每个用户所属的邀请链条?

可以预想到的是,高峰时期,同一个来源下用户可能被不同的父邀请人邀请,同时群聊里同一用户会被不同的父邀请人拉进群聊。父邀请人自身的邀请关系也会实时变动。此时,单一来源下用户数据的并发修改,由不同来源合并出的【唯一用户】数据的并发修改,【唯一用户】参与的邀请链条的归属员工的计算和计算结果的更新导致的数据并发修改等情况会非常频繁。

容易想到的解决方案是,数据并发修改这一块,我们可以通过数据库事务锁,分布式锁等方式控制并发修改;基于各种长期任务读取快照数据进行滞后性计算完成不同来源用户识别为【唯一用户】的合并过程和计算所有【唯一用户】的邀请链条;

但这样的方案无法做到实时以及良好的性能,数据库事务锁有冲突和死锁的可能,并且很可能对数据库造成性能压力;合并不同来源用户/计算邀请链条归属需要加载全量数据,计算过程中内存资源消耗大且无法进行任务数据分治以多机执行分担压力;无法给其他业务提供准实时计算结果。

此时按照DDD的指导思想我们可以通过【响应实时数据的变更】【排队对单一数据的并发修改请求】【避免对数据进行重量级加锁操作,并允许在数据冲突发生后有限次数重试修改操作】等方式来降低数据冲突风险和加速数据的处理过程。

落地方案无外乎以下几种:

1. 识别并隔离不同业务领域的数据关联
2. 在数据修改冲突较低的情况下使用乐观锁方式,重试失败操作
3. 任何聚合内部状态发生变化,实时发布必要的变更事件用以驱动其他业务流程的进行
4. 将对同一数据的修改请求分片到单一操作队列中(带有分区发布的消息队列/CQRS+EventSource模式),不同数据的操作队列由可扩展的节点并行处理

这里以具体流程示例来说明上面思考问题反映的实际情况和对应的解决方案

3.1. 单来源下用户信息的处理流程

基于业务描述可以识别出【普通微信环境下带unionid的用户】【群聊环境下带群成员ID的用户】两个概念,除了标识ID不一致外,其余信息字段都保持一致,我们可以抽象出【访问用户】这一领域模型,而通过上面对解决方案的讨论,我们首先可以通过【数据库乐观锁】的方式来处理单一【访问用户】的数据变更操作;接下来思考【访问用户】和其他领域模型的联系,我们最终目的是基于基础信息合并不同来源下的【访问用户】为【唯一用户】,并计算【唯一用户】的最早邀请关系。所以我们让【访问用户】在【【昵称】【头像】【地域】】等信息发生变化的时候发布变更事件出去,用于驱动【唯一用户】领域模型按规则寻找待合并的其他来源下的【访问用户】并进行数据合并。让【访问用户】内部保存最早父邀请人信息,并当最早父邀请人信息发生变化的时候,发布变更事件出去,用于驱动【唯一用户】领域模型比较更新多来源里真正的最早父邀请人信息。

至此,我们可以设计出以下【访问用户】的聚合内部结构:

1. 用户来源包含微信环境和群聊环境,将【环境类型】和【标识ID】二元数据构建为单一值对象作为主键标识(VisitorId)
2. 用户【昵称】,【头像】,【地域】,【更新时间】多元数据需要被整体读写,设计为值对象(VisitorProfile)
3. 用户当前最早【邀请人ID】【邀请时间】二元数据需要被整体读写,设计为值对象(VisitorFirstInviteBy)
4. VisitorProfile基于【更新时间】判断是否需要更新,更新后发布用户基础信息变更的领域事件
5. VisitorFirstInviteBy基于【邀请时间】先后判断是否需要更新,更新后发布用户最早邀请人变更的领域事件

示例代码如下:

@Data
@Entity
@DynamicUpdate
@NoArgsConstructor
@AllArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(
        name = "visitor_user",
        uniqueConstraints = {
   
        		@UniqueConstraint(columnNames = {
    "taskId", "visitorId" })},
        indexes = {
   
        		@Index(columnList = "taskId,nickName")})
public class Visitor extends IdentifiedEntity {
   
	
	private InviteTaskId taskId;
	private VisitorId visitorId;
	private VisitorProfile profile;
	// 访客的最早邀请记录
	private VisitorInviteBy visitorInviteBy;
	
	public Visitor(
			InviteTaskId taskId,
			VisitorId visitorId,
			VisitorProfile profile) {
   
		this.taskId = taskId;
		this.visitorId = visitorId;
		this.tryModifyVisitorProfile(profile);
		DomainEventPublisher.publish(
				new NewVisitor(
						this.taskId,
						this.visitorId,
						this.profile));
	}
	
	/**
	 * 更新邀请记录
	 * 
	 * @param visitorInviteBy
	 */
	public void newInvitation(
			VisitorInviteBy visitorInviteBy) {
   
		// 自身邀请,忽略
		// 如果之前没有父邀请人,但现在有了条最早邀请比当前记录创建时间更早的邀请记录,忽略(避免循环邀请)
		if (this.isSelfInvite(visitorInviteBy)
				|| visitorInviteBy.getVisitTime().isAfter(this.createdAt())
				|| visitorInviteBy.equals(this.visitorInviteBy)) {
   
			return;
		}
		if (this.visitorInviteBy == null
				|| this.visitorInviteBy.getVisitTime().isAfter(
						visitorInviteBy.getVisitTime())) {
   
			this.visitorInviteBy = visitorInviteBy;
			// 发布用户最早邀请人变更的领域事件
			DomainEventPublisher.publish(
					new VisitorInviteByChanged(
							this.taskId,
							this.visitorId,
							visitorInviteBy,
							this.visitorNickName()));
		}
	}

	public void tryModifyVisitorProfile(
			VisitorProfile profile) {
   
		// 忽略头像为空的
		if (profile.imageNotValid()) {
   
			return;
		}
		if (this.profile == null
				|| (!this.profile.equals(profile) 
						&& this.profile.isOldThan(profile)
						&& this.profile.profileFromWayAcceptUpdate(
								profile.profileFromWay()))) {
   
			VisitorProfile oldProfile = this.profile;
			this.profile = profile;
			// 发布用户基础信息变更的领域事件
			DomainEventPublisher.
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值