使用领域建模梳理业务和分离复杂性

一日,同事C来找我交流,问如何做一个抽奖的功能——

“我们给每个新注册的用户都赠送了一次抽奖的机会,用户进行购买也能够获得更多抽奖次数,我们会不定时开放一些转盘抽奖活动,用户抽奖的时候我们要先用他账号里的注册时赠送的抽奖机会,如果没有的话再用用户消费获得的抽奖次数,一个抽奖次数只能用一次要防止并发冲突导致被多次使用……我们准备把抽奖次数放到redis中,用户每次使用就把次数减一,但是又需要优先使用系统赠送的抽奖次数数,然后抽奖机会又需要有过期时间——”

作为一个愚钝的程序员,我的大脑的思考能力实在是有限的紧,在听了前面一大串已经晕晕乎乎的,听到用redis来实现抽奖次数扣减和过期时间检查这里更是头昏脑胀,仿佛一团乱麻,什么也想不清楚,更别说给出什么有价值的意见了。

我对C说:“你已经把我说晕了,咱们先别说redis什么的了。”

说着我拿出纸笔,开始和C进行对话:

我首先关注的是“抽奖”这个关键词,当然是因为C之前那一大段话都是围绕着“抽奖”讲述的,更具体地,是在讲述使用抽奖次数的策略,在捕捉到“抽奖次数”这个概念后,我在纸上写下:

type 抽奖机会 = { };

同时我对C说:“我们来用‘抽奖机会’来表示‘抽奖次数’吧。”,抽奖次数是一个比较模糊和口头化的概念,它蕴含着数量上的表达,我偏爱更严谨的词汇。

“按照你刚才的描述,抽奖机会有两种,一种是用户注册时赠送的,另一种是用户消费后获得的。”

type 抽奖机会 = {
	类型: '注册赠送的' | '消费获取的'
};

我问C:“除了类型,你认为抽奖机会还应该具备什么属性呢?”

“过期时间!”,C回答。 于是:

type 抽奖机会 = {
	类型: '注册赠送的' | '消费获取的'
	过期时间: DateTime
}; 

我接着说:“抽奖机会是发送给用户的,所以应该还应该记录其所有者。”,C表示同意。 “每个抽奖机会都是独立被使用的,应该也是需要可以被追溯的,那它应该有一个编号作为标识。”

最终,我们有了一个映射抽奖机会的模型:

type 抽奖机会 = {
	编号:抽奖机会ID
	持有人: 用户ID
	类型: '注册赠送的' | '消费获取的'
	过期时间: DateTime
};

接着用同样的方法,我们整理出来了这个业务场景中另外一个相关概念的模型:

type 抽奖活动 = {
	编号: 抽奖活动ID;
	类型: 活动类型;
	过期时间:DateTime
}

有了这两个模型后,我对C说:“现在我们有了对抽奖机会抽奖活动这两个业务概念的描述,现在我们来分析这个抽奖业务的具体行为吧。”

通过分析整理,我们找到了下面几个关键点:

  1. 抽奖发生在一个具体的抽奖活动中
  2. 抽奖时会选取用户的一个抽奖机会
  3. 优先选择‘注册赠送的’抽奖机会
  4. 优先选择临近过期的抽奖机会
  5. 一个抽奖机会只能被使用一次

根据这些,我给出了一段伪代码来对抽奖行为进行描述:

function 抽奖(活动: 抽奖活动, 用户ID: 用户ID) {
	if(已过期(活动)) {
		throw '活动已过期'
	}

	const 机会 = 获取抽奖机会(用户ID) // 优先注册赠送 优先临近过期

	使用抽奖机会(机会, 活动)
}

在这里,我们发现,为了保证“一个抽奖机会只能被使用一次”,我们需要有一个属性来识别抽奖机会是否已经被使用,于是我们更新了抽奖机会的模型:

type 抽奖机会 = {
	编号:抽奖机会ID
	持有人: 用户ID
	类型: '注册赠送的' | '购买获取的'
	过期时间: DateTime
+   状态: '已使用' | '未使用'
};

并且在使用抽奖机会时,更新抽奖机会的状态。

接着,我们又发现,为了避免在并发请求中,获取到已被其它请求获取但是还并未被使用的抽奖机会,我们需要额外一个状态来标志这个抽奖机会已经被某个抽奖请求获取到了,随时可能会被更新为已使用

type 抽奖机会 = {
	// ...
    状态: '已使用' | '未使用' | '已被获取'
};

function 抽奖(活动: 抽奖活动, 用户ID: 用户ID) {
	// ...

	const 机会 = 获取未使用抽奖机会(用户ID) // 同时更新其状态为'已被获取'
	// ...
}

到了这里,C说,“那我明白该怎么做了”

可以看到,经过了这番对话,我们将这个抽奖的业务场景进行分析梳理,写清楚其中的各个概念、规则、行为后,发现其实根本不需要什么redis,而所谓的‘抽奖次数’实际上是具备完整的自己的生命周期的对象,而非是一个加加减减的标量。

有学习过领域驱动设计的朋友应该可以明显看出,上面我和C在做的的其实是在进行简略的领域建模。

关于领域建模

领域建模就是对特定业务问题进行梳理、探索、提炼,将其从一个松散的口头信息描述,抽象转化为一系列能够帮助我们解决问题的概念、关系和流程模型。

就像是问开车到商场要多久,我们并不能直接得到答案,而是先将这个场景转化为:车的时速是多少?从这里到商场的路程要多远?然后再套用速率公式,有了这些,我们才能得到想要的答案。

而对于软件开发,虽然有所不同,但是也有着一样的道理,我们开发软件总是为了解决一些问题,尤其是对于许多业务系统来说,要解决这些业务问题,就一定要对它的业务领域有所了解,领域建模就是一个帮助我们学习目标业务领域中的知识,并将业务问题抽象成可以帮助我们解决这个问题的模型的方法。

事实上,无论我们是否有这个意识,只要我们是在开发业务系统,其相关的业务领域中的知识就或清晰或混乱地存在于我们的代码中,而在开发时,我们一定也会主动或被动地对相关业务领域进行建模——我们设计的类、数据库表结构、模块等,无一不是在对真实世界中的业务进行的抽象,它们都在一定程度上以某种角度描述了相关的业务领域。

但是许多时候,这些领域知识或者说模型,总是松散而重复地分散在系统各处,这边一点,那边一点,又同应用逻辑杂糅在一起,这为我们造成了相当程度上的认知负担,尤其是对于我这样愚钝的程序员来说,接收到的信息稍微多一点混乱一点,我的脑袋就什么都想不清楚了。

所以我们要把我们开发时被动地做的学习业务中的知识,整理业务中的概念等事情,变成在开发时首先做、主动做、专注做。

这么做有几个好处:

  1. 帮助我们更准确地理解业务
  2. 隔离应用逻辑和数据库实现,专注于对业务领域的探索和分析,降低问题的复杂度
  3. 模型成果作为代码存在于项目之中,代码中的业务逻辑更容易理解
  4. 团队成员尤其是新成员可以有效地从代码中学习到领域知识,并且有着统一的认识

上面说过,我和C所做的是“简略”的领域建模,与完整的领域建模有着许多不同,比如在真正进行完整的领域建模时,我应该和C首先以业务专用语言进行用户叙事,来尽量获得业务场景的全貌,又比如还有实体和值对象的识别,领域上下文的整理和映射等。 不过由于这是一个非常有限的业务场景,而我也只是需要来借助领域建模来帮助我梳理这个特定业务场景,帮助我能看清楚这个问题的本质,所以也就不需要那么麻烦。