DDD 实战 (8):冲刺 1 战术之聚合设计
本篇开始我们对“群买菜”首个冲刺的战术设计进行描述。上篇中,我们已经识别了首个冲刺的 14 个业务用例和 23 个服务契约的识别,并分别给出了相应的业务用例规约和服务契约设计。下面我们分两篇来分别完成:1)按照 14 个业务用例规约完成聚合设计;2)按照 23 个服务契约,在聚合设计的基础上,完成服务设计(含应用服务、领域服务);3)作为首个冲刺,完成必要的战术层面相关技术决策(这一步工作一般只在首个冲刺的时候会做,后面的冲刺可能会有补充完善)。
3. 首个冲刺的概念模型与聚合划分
本篇就先完成第一个工作:基于 14 个业务用例规约完成聚合设计。对于每个上下文来说,其实我们按照如下的 4 步走的“快速建模法”来完成聚合设计:
名词建模。这一步其实就是查看该上下文的所有业务用例,从其中识别出所有的“名词”,包括那些带定语的名词,并初步建立这些名词之间的关联关系。
动词建模。动词建模的主要目的,是为了发现“时标对象”。时标对象我们可以这样来理解:它是用来记录在某些关键时刻涉及到管理责任、法律纠纷或财务风险的“过程性记录”。这种记录的真正作用,并不是业务本身所需要的,而更多是从企业管理角度来考虑的。下图展示了“时标对象”的概念定义和识别方法(绿勾即为时标对象,而红叉则不是):
归纳抽象。在完成了名词建模、动词时标对象识别后,即可以对对象模型进行抽象归纳,并识别出哪些是值对象、哪些是实体对象。这一般包括这些工作:
1)通过合并同类项,主要是那些定语修饰的不同名词、其实是一个对象类的情况(如:配送地址、家庭地址等,这种属于定语引起的值的差异);
2)通过定语识别出新的对象,主要是那些定语修饰的不同名词、其实是不同类型的情况(如:订单状态、商品状态);
3)去掉一些没有必要存在的对象,比如:没有业务意义的名词、其实可以使用语言基本类型的名词等;
4)区分值对象、实体对象。按照一个基本的原则来识别,即:是否对象的所有属性相同,但仍然可被认为是不同的对象,这种情况必须要有标识 ID 才能区分不同。
5)确定实体对象之间的关系,包括:泛化、关联、依赖。泛化是父类子类之间的关系;关联是对象的属性中引用另一个对象,又包括合成关系(A 由 B 合成,表示 B 为 A 的组成部分,并且 B 存亡依赖于 A 的存亡,如学校和班级的关系)、聚合关系(A 由 B 聚合,表示 B 为 A 的组成部分,但 B 存亡并不依赖于 A 的存亡,如班级和学生的关系)、普通关联(即 A、B 之间的普通属性引用关系,允许 1 对 1、1 对多、多对多);依赖是方法出入参引用到另一个对象。
一般来说,对象模型的建立,采用“多比少好”的基本原则。
划分聚合。将一个上下文中的多个实体对象进行聚合划分。一般来说,我们本着“小聚合”的原则,区分聚合的唯一判定规则:该实体对象是否存在从用户角度被直接查询和处理的必要。例如:订单里面的订单项,用户就没必要跳过订单、而直接查询订单项的必要,这种情况下“订单项”就作为“订单”聚合的内容,而不需要单独作为聚合存在。之所以这么做,是因为按照我们的菱形架构,对于数据资源类的端口采用“资源库”接口来实现,而一个聚合对应一个资源库。所以,如果不需要为某个实体对象单独开发“资源库”端口(及对应的适配器类),就没必要将其作为独立的聚合。
3.1 鉴权上下文
3.1.1 名词建模
根据用例“登录系统”规约查找名词:微信 openid、授权记录、用户 ID、有可管理店铺标记、有可管理接龙标记、店铺 ID、位置、距离。
3.1.2 动词建模(时标对象)
重大时刻:登录系统
可能的过程性记录:登录日志
是否关联到管理责任、法律纠纷、财务风险:考虑到用户微信可能被盗用,而且“群买菜”是个双边开放平台,允许任何人注册店铺和销售商品,为了规避法律和财务风险,故有必要将登录日志保存下来。为此,识别出“登录日志”这一时标对象。
3.1.3 归纳抽象
根据如上识别的所有对象,我们绘制概念模型图如下:
对上图进行相应的归纳抽象后,我们发现:
“授权记录”其实就是在我们“群买菜”系统的“用户”,其实质是微信用户在授权登录后在“群买菜”系统的一对一映射。这样“用户 ID”其实就是“用户”对象的标识。所以,“用户 ID”应该是值对象。
“店铺”其实是“店铺上下文”的实体对象,授权上下文只关心“店铺 ID”,由于跨上下文,故只需要作为“用户”实体对象的“计算性属性”(最近一次浏览的店铺 ID、或距离最近店铺 ID),且使用基本类型 String 即可。
“微信 openid”应该作为“用户”实体对象的属性。考虑到它实际上是一种特定平台、特定格式、特定含义的字符串,故设计为值对象。
“位置”。由于我们并不是一个物流或地图类应用,不需要对位置进行精确的匹配,所以作为值对象。并且,在我们的“授权上下文”中,其应该是用户对象在某个时刻的一个计算属性(根据手机定位计算)。
“距离”。这是从经纬度计算出来的一个整数或浮点数(视采用的计算单位而定),但它有特定的业务含义,故设计为值对象。同时,它是根据位置进行计算的,所以它和“位置”值对象之间是一种方法调用上的“依赖”关系(“距离”对象会使用“位置”对象来构建自身)。
“有可管理店铺标记”、“有可管理接龙标记”,这明显是两个计算属性(根据该用户是否被店铺创建人授权、是否创建接龙等计算),可以作为“用户”实体对象的属性存在。考虑到这类标记性属性,会随着“群买菜”系统业务逻辑的演变、以及前端展示需求的变化等需要,我们可以设计一个“用户状态”值对象类。
“登录日志”应该是实体对象,且“用户”和“登录日志”之间应该是“合成”关系(后者是因为前者存在而存在的)。
根据上面的归纳抽象,再考虑用英文表达对象名称,我们修改概念模型图如下(值对象用阴影表示,箭头表示单向关联,实心菱形表示合成关系,空心菱形表示聚合关系,无箭头实线表示双向关联):
3.1.4 划分聚合
本上下文只有两个实体对象:用户、登录日志。唯一要回答的问题是:“登录日志”是作为“用户聚合”的内容、还是独立聚合存在?这取决于业务上有没有不需要通过“用户”实体对象而直接访问“登录日志”的需求场景。从实际需求来说,“登录日志”是动词时标对象,我们前面做分析时已经意识到记录它的目的是为了方便以后的财务、法律风险核查,也就是说可能会开发针对“群买菜”平台后端运营的相关功能,而这些功能是可能是直接查询某个时间段、或满足某登录地理位置范围等审计条件下的“登录日志”,而并不需要通过“用户”对象来访问它。为此,我们将聚合划分如下图(图中<<AR>>标记表示是“聚合根”):
上图中,需要说明的是:考虑到“位置”和“距离”与业务的完全无关性,建议将“Location”和“Distance”两个类放到“共享内核”上下文中,不归属到某个聚合。
3.2 订单上下文
3.2.1 名词建模
根据各业务用例规约查找名词如下表:
我们将上表的所有名词对象进行汇总,得出如图所示的概念模型:
3.2.2 动词建模(时标对象)
对订单上下文各业务用例的时标对象分析如下表:
总结起来,对订单上下文动词建模新增的对象有:“微信支付结果”、“订单确认记录”。调整后的对象模型如下图:
3.2.3 归纳抽象
现在我们来对这个初步的对象概念模型进行归纳抽象,首先我对某些对象的存在必要性进行分析如下:
“商品”、“商品有货状态”、“售罄商品”都属于“商品上下文”的内容,我们在这里作为考虑。
“订单生效事件”、“订单确认事件”属于菱形架构中“南向网关”部分,不属于核心领域,可以不作为对象模型考虑。
“订单状态通知订阅”、“订单状态通知消息”按照上下文职责划分属于“平台集成上下文”,也不在这里考虑。
“订单列表”其实就是“订单”对象的一种 List,且仅用于前端界面查询显示,不需要作为对象模型考虑。
“购物车商品列表”其实是购车中保存的商品信息、下单份数、计量数量(比如:胡萝卜 0.5 斤一份,下单了 3 份就是 1.5 斤)、下单金额小计等信息,故改名为“购物车商品行”。
修改后的对象概念模型如下图:
其次,我们识别这个模型的值对象、实体对象,并适当地再次提炼归纳。分析如下:
“购物车商品总数”其实就是个普通整数,并没有特定的业务逻辑需求,故将其从对象模型删除。
“购物车待结算总价”、“订单支付金额”其实就是 Money 值对象类,没必要专门为其设定特定的值对象类,故统一为“金额 Money”值对象。
“购物车商品小计”、“购物车商品分类计数”,这两个是有结构性的、多字段属性的值对象,作为“购物车”和“购物车商品行”的属性存在,而无需作为带标识 ID 的实体对象存在。
“订单状态”、“订单可见状态”、“订单支付状态”这 3 个对象明显也是作为值对象而存在。因为其分别作为“订单”和“订单支付记录”的属性而存在,并且需要定义特定的业务取值,并不能无限任意取值,所以需要有自身的业务逻辑知识。
“订单提货方式”、“订单联系信息”、“订单送货地址”这 3 个对象也适合作为值对象而存在。其中,“订单提货方式”具备取值范围的业务知识;而“订单联系信息”、“订单送货地址”是多属性字段组合的整体概念,因为“群买菜”不是物流类软件,没必要将它们识别为具备 ID 标识的实体对象,所以它们也都作为值对象存在。
“订单备注”目前只是一段普通文本,其实是可以作为基本数据类型的,但考虑到将来的业务扩展性,可能会出现格式化要求。况且,即使现在我们也是需要限制其字符串长度的,这也算是一种“业务知识”。为此我也将其作为值对象考虑。
“手机号”是一种特定格式和取值范围要求的数字字符串,故也作为值对象。
“订单”、“订单行”、“订单商品快照”、“订单支付记录”这 4 个对象,是需要有 ID 标识存在的实体对象。很显然,一方面这些对象是具备多个字段属性的结构体,另一方面它们的“唯一性判别”不是基于其属性取值而是基于 ID 标识的。所以它们都是实体对象。
“品牌商品”可以理解它是品牌商店铺的商品库中某个商品、被加盟商销售后,形成在订单下的一种特殊的“订单商品快照”。“品牌商品”这个说法,只有在特定“订单”记录里面才生效。所以说,其实“品牌商品”是“订单商品快照”的一个子类,所以它也是一种实体对象。
“品牌商子订单”是在客户确认订单收货后,系统为品牌商品关联的品牌店铺自动生成的子订单,所以也是一种实体对象。不过,它属于“订单”的子类。但同时,“品牌子订单”又需要关联到“订单”作为其父订单,故“品牌子订单”和“订单”实体之间就有两重关系:泛化关系、关联关系。
需要说明的是:“品牌商品”、“品牌商子订单”不属于本次冲刺的工作范围。
“订单支付完成时间”可以使用 java 语言的基本数据类型 TDateTime 或 TLocalDateTime 即可,因为其取值并没有限制,故从对象模型中删除。
“微信预支付订单”其实是微信支付平台返回的、一系列用来给微信小程序前端调起微信支付的参数组合。它是依附在订单支付记录上的,随着微信支付的成功与否而更新内容,因此它也可以作为值对象存在。
“微信支付结果”类似“微信预支付订单”,它是微信支付平台返回的支付结果信息,也可以作为依附于订单支付记录而存在的值对象。
“订单确认记录”也是依附于订单而存在的一种数据记录,且它没必要作为实体对象存在。因为“订单确认记录”是与“订单”一一对应的,它并不需要自己的 ID 标识,只需要直接依附于订单 ID 标识即可,故也作为值对象。
“订单支付时限”、“订单确认时限”这两个看起来是某个对象。但实际上,它们是某种业务规则,是用来在订单创建是创建该订单的支付截止时间、确认截止时间。所以说,这两个名词更像是某种业务规则的配置参数。对于这种情况,有两种处理方式:一种是设立“规则上下文”并引入规则引擎,将它们全部纳入规则引擎的设计框架下,不再遵循 DDD 思想对其进行设计;另一种是将其转化为某种 DDD 对象模型。考虑到“群买菜”前面的战略设计中,已经舍弃规则引擎的引入,所以我们采用第二种处理方式。鉴于“订单支付时限”、“订单确认时限”实际上是某种业务参数配置,为了通用性,我们在对象模型中引入“业务参数”实体对象,该实体对象的 ID 即为“参数编码”,用于区分获取不同的业务配置参数。这样,就将“订单支付时限”、“订单确认时限”作为某种“参数编码”的“业务参数”来看待,而计算订单支付截止时间、确认截止时间的业务逻辑则由“订单上下文”的相关领域服务来实现。
“订单剩余支付时限”是用来给客户提示支付剩余时间的。显然,它是一种“计算结果”,是根据订单支付截止时间(见上条)结合系统时钟自动读秒倒计时的。事实上,这是一个“瞬间”取值,仅用于前端界面提示客户支付,并没有其它的业务价值,而且技术上并不适合要求后端服务给出准确的计算结果(会导致大量的前后端交互)。更为可取的方法,还是由前端界面根据“订单创建时间”进行计算。当然,这样做的坏处是:前端界面具备的一定的业务知识。但考虑到在“设计价值”和“实现简便性”上的权衡,我们还是建议这部分计算在前端界面实现。为此,从对象模型删除该对象。
经过上面的提炼归纳,我们调整订单上下文的对象模型如下图:
为了方便代码实现,我们将对象模型的中文名改为英文名,如下图:
3.2.4 划分聚合
事实上,上面的对象模型已经基本将聚合划分清晰了。唯一需要考虑的,就是“品牌子订单”需要和“订单”这两个实体对象分开在不同的聚合中,因为“品牌子订单”对于品牌商来说,是需要有独立的访问入口的(如:查询某品牌商收到的子订单),故在聚合上必须区分开来。划分聚合后的对象模型如下图:
需要说明的是:Money、Visible、MobileNumber 放到“共享内核上下文”,不作为任何聚合的内容。
3.3 商品上下文
3.3.1 名词建模
根据各业务用例规约查找名词如下表:
需要说明的是:“店铺”属于店铺上下文、“购物车”、“购物车状态标记”属于订单上下文,这里不作为考虑范围。商品上下文根据名词初步建模的对象模型如下图:
3.3.2 动词建模(时标对象)
由于冲刺 1 只涉及到查询类用例,故没必要分析时标对象。
3.3.3 归纳抽象
我们对上面的商品上下文对象模型做归纳抽象,并去掉一些没必要的对象。分析如下:
“售罄商品”其实是商品的一种,在我们已经有“商品有货状态”对象后,这个对象就显得多余,故去掉。
“关键词列表”其实就是“关键词”的 List,没必要单独出来一个对象,去掉。
甚至“关键词”都可以直接使用 java 语言的基本类型 String,暂时还没有诸如关键词热度、关联属性等特定业务规则,也可以去掉。
“商品最小下单量”就是普通的浮点数,不作为对象模型。
“商品显示顺序”、“商品类别显示顺序”都可以视作普通的整数,不作为对象模型。
我们再来对该对象模型识别识别值对象、实体对象,并给对象加上英文名称。分析如下:
“商品名称”、“商品描述”其实是受限制的字符串类型,显然作为值对象;
“商品图片”并不会独立存在于“群买菜”系统中,而总是依附于商品存在,故也作为值对象,并改名为“图片”;
“商品定价”其实就是一个金额,取值没有限制、也没有特别的业务逻辑,直接改为使用 Money 值对象。
“商品计量单位”、“商品优惠”、“商品限购”、“商品有货状态”显然也不会独立存在,而总是依附于商品存在,故也作为值对象。
最终修改后的对象模型如下图:
3.3.4 划分聚合
该对象模型中,需要区别直接访问入口的实体对象有“商品类别”、“商品”。“商品类别”需要单独访问是需要在前端界面支持列出所有的商品类别,“商品”需要单独访问入口显而易见。而其它实体对象“商品图片”、“商品月销量”是不需要单独访问入口的,故最终聚合划分如下图:
需要说明的是:Money 仍然使用“共享内核”上下文的对象。Image 因为与业务完全无关,而且大概率会被其它上下文用到,也放到共享内核上下文。
3.4 平台集成上下文
3.4.1 名词建模
该上下文目前只有一个用例“获取微信绑定手机号”在冲刺 1 中,分析其用例规约,识别出名词:“微信手机号”。
加上前面“订单上下文”战术设计中识别出“平台集成上下文”的名词对象:“订单状态通知订阅”、“订单状态通知消息”。
为此,绘制本上下文的对象模型图如下:
3.4.2 动词建模(时标对象)
目前只有一个用例“获取微信绑定手机号”,这其实是微信平台提供的功能,涉及到的法律纠纷、管理责任等都属于微信平台,故不存在时标对象。
3.4.3 归纳抽象和聚合设计
这里的对象模型特别简单,微信手机号其实也是手机号,故其也是值对象,并纳入“共享内核”上下文中。另外,“订单状态通知订阅”和“订单状态通知消息”之间是 1 对多的关系,并且两个实体对象都需要有单独的访问入口,故其必须放在两个聚合中。最终,带聚合划分的对象模型如下图:
到此,我就完成了“群买菜”冲刺 1 战术设计的聚合设计部分,剩下我还会用 1~2 篇完成冲刺 1 的服务设计和战术层面技术决策,然后就开始实际的 TDD 编码实现了。
版权声明: 本文为 InfoQ 作者【深清秋】的原创文章。
原文链接:【http://xie.infoq.cn/article/33bdaca7fbf406807614d660a】。文章转载请联系作者。
评论