本文首发于政采云前端团队博客:编写高质量可维护的代码:数据建模 https://www.zoo.team/article/data-modeling
数据建模是一种用于定义和分析数据的要求和其需要的相应支持的信息系统的过程。
随着前端页面的交互变得更加细腻复杂,原本存放于服务端的状态放置在了前端,类似 flux、redux、mobx、dva、rematch、vuex 的状态管理库也成了每个项目的标配。
因为分层理念的普及,前端工程师们需要把更多精力放在数据管理上,数据建模也成了基本功。
而建模的产物是数据模型,数据模型是定义数据如何输入和输出的一种模型,其主要作用是为信息系统提供数据的定义和格式。
数据模型包括数据结构、数据操作、数据完整性约束条件这三要素。
简单理解就是数据模型提供了一个“模具”,数据按照预先的设计和约束进行放置。
好的数据结构必须要有约束,例如描述同一个状态的字段有时候是字符串,有时候是数字,这样的话就容易造成预期之外的情况。添加约束可以最大限度保障这份数据是干净整齐的。
// status 是字符串的时候不通过
if (status === 1) {
...
}
// 按照一定约束
model.define(
'user',
{
name: {
field: 'name',
type: STRING(64),
allowNull: false,
comment: '姓名',
},
sex: {
field: 'sex',
type: INTEGER(1),
allowNull: false,
comment: '性别',
}
}
);
描述模型本身的性质之外,还需通过某些字段表达模型(表)和模型之间的关联。
在数据结构上对数据或者数据之间的关联关系的操作。
在围绕着数据模型进行应用开发的时候,我们会思考如何进行建模呢?
实际上,软件开发行业中已经积累了一些方法论,例如领域驱动设计 (DDD) 就被广泛采用。
在进行软件开发前,通常需要先进行业务知识梳理,而后到达软件设计的层面,最后才是开发。而在业务知识梳理的过程中,我们必然会形成某个领域知识。根据领域知识来一步步驱动软件设计,就是领域驱动设计的基本概念。简单来说领域驱动设计就是关注精简的业务模型及实现的匹配。
按照领域驱动设计的分层架构可以将应用进行分层
按照这个分层,越往左边代码变动越频繁。随着业务复杂,应用层和领域层的边界变得模糊,领域之间也容易交错在一起。
良好的设计应该避免层与层之间产生过多依赖,如果代码没有被清晰隔离到某层中,它会迅即混乱和难以维护。
通过分层架构和高内聚低耦合的设计思想,最终实现系统与需求有较好的一致性,在业务迭代中快速响应需求变更。
实体在领域模型中是必需的对象,并且它们应该在建模过程开始时就被考虑。例如要实现一个“猫”的概念,我们可能会去创造一个 Cat 的类,这个 Cat 可能包含名称、性别、品种等属性,但是这些属性都不足以区分这只猫,所以我们需要创建一个唯一不重复的 ID 来区分他们,也就区分实体的标识符。
创建 ID 的方式有很多种,它可以是主键、可以来自外部、也可以由系统自己产生,但它必须符合模型中的身份差别。
用来描述领域的特殊方面,且没有标识符的一个对象,叫做值对象。例如画布上的一个点 Customer 会跟姓名、省份、城市、区、街道相关。最好是将地址分离出来,保留对地址的引用,因为它们都是同一个址属性。
你可以简单地将行为理解成一种服务。例如你去商店购买商品,你的朋友也可以去购买商品。如果将购买这个能力作为一个属性放在 Person 这个实体里显然有点不对劲,因为“去购买”这个功能并不属于你和你的朋友(实体或者值对象),同时去购买也可能涉及到商品对象。
保证服务的单一性和隔离非常重要,注意区分领域服务和应用服务。决定一个服务所应归属的层是非常困难的事情,我们在设计阶段建立模型时,需要确保领域层保持从其他层中隔离开来。
模块是一种被用来作为组织相关概念和任务以便降低复杂性的方法,通常情况下由功能或者逻辑上属于一体的元素构成,以保证高内聚,同时通过接口的形式暴露给第三方以降低模块之间的耦合。
聚合是针对数据变化可以考虑成一个单元的一组相关对象。聚合基于(有且仅有)一个实体(根),聚合通过这个根被外部访问,它可以引用任意聚合或者被其他聚合引用。以下是一个简单的聚合例子:客户作为聚合的根,其他信息都是客户内部的,如果需要地址则将地址的拷贝传递出去( Javascript 中特别需要注意)。
工厂用来封装对象创建所必需的知识,它们对创建聚合特别有用。工厂方法是一个对象的方法,包含并隐藏了创建其他对象的必要知识。
资源库作为一个全局可访问对象的存储点而存在。它是一个独立的层,介于领域层与数据映射层(数据访问层)之间。它的存在让领域层感觉不到数据访问层的存在,它提供一个类似集合的接口,提供给领域层进行领域对象的访问。
数据建模和后端的工作关联较为紧密,前端的数据模型更多是依赖后端传递的数据传输对象(DTO)进行二次构建。无论二次构建是发生在服务端聚合阶段还是用户端 AJAX 请求完成阶段,前端都需要参与一定的数据清洗,并应用到前端的数据模型之上。
现在你可以开始尝试划分你应用内的业务领域。以一个商城为例子,它可能会包括用户、商品、货架、订单、结算、账户等内容。
每一个业务领域都可以至少拆分成一个领域,按照业务领域来组织代码,例如在交易领域中按照以下目录结构划分:
src
modules
...
trading # 交易领域
components/ # 组件
models/ # models
pages/ # 页面
redux/ # redux
services/ # 交易模块相关api
styles/ # 交易模块样式
index.ts
...
数据建模的前提是对业务的充分理解,充分理解业务相当于在更高的视角去看待业务之间的关系,有利于更好地完成模型建设。
尝试回想一下你所维护的业务(应用)场景,你是否清晰业务场景和业务对象之间的关系以及具体交互?
使用思维导图梳理出概念模型,这个阶段可以不用严格遵守三要素,目标清晰表达现实世界就行。
定义模型可以依据概念模型,补充细节和关联关系,例如简单定义一个营销商品:
以上展示了商场货架上划分的一块活动区域,规则是满 XX 减 XX,再将参与该活动的商品在区域内进行上架。
在大部分情况下,特别是展示逻辑这块,前端不应该是重逻辑的。
以商品为例,不同商品的营销类型背后隐藏着复杂的价格体系,尽管是同一种营销类型,商品在不同的状态展示的价格也不一定相同。你可以想象这背后的字段,以及计算规则。
假如后端把这些字段、各种 price
和规则一股脑抛给你,先不谈前后端对称问题,光挑字段都能让你目瞪狗呆。
遇到类似情况更好的办法是:尽量避免在前端(用户端)去处理复杂的业务判断,在聚合层或者让后端同学给你处理好这些展示逻辑。
特别是在 C 端场景下,数据直出显得更加重要,同时前端同学也有更多时间去做性能优化(早点下班不香么?)。
另外一个好处是假如出现展示问题,你只要确定读取的字段正确,剩下的仅需一个人排查就够了;
// Bad
const switchPrice = product => {
switch(product.status) {
case 0:
return product.priceA;
case 1:
return product.priceB;
case 2:
return product.priceB;
default:
return product.priceBase;
}
}
<Price value={switchPrice(product)}/>
// Good
<Price value={product.price}/>
设计上需要区分应用逻辑(业务逻辑)和展示逻辑。应用层注重对领域层的调度,是业务逻辑的实现,展示层专注渲染和交互动作。
在一个大型项目中,同一个 Model 可能被多处引用,你很难确定谁最终会对同一份数据进行怎样的操作。
同时 Model 中仅保留数据源的抽象结构,而不修改数据源的内容。
// 在视图层只做展示逻辑处理
// 组件A
...
<>
<span>日期:{format(res.date, 'YYYY-MM-DD')}</span>
</>
// 组件B
...
<>
<span>日期:{format(res.date, 'YYYY-MM')}</span>
</>
在设计模型的时候,尽可能与后端保持统一字段。比如某些表单场景在回显和提交的时候要多一层转换,后期维护会带来多一层心智负担。在前后端分离的开发模式下,不一定能保证后端会先给出字段,我的习惯是标记字段,等联调的时候全局替换一下就行了。
简化字段、明确语义、改变不合理的前后端交互是做好数据建模的基础,否则你将花费大量时间去理解这些字段背后的含义和计算规则。
没有一个十全十美的数据模型可以适用任何需求场景,模型的落地需要综合考虑业务实际场景和技术选型。在构建模型的过程中,锻炼系统性思考能力、从更高的视角看待业务,才能创造出一个生命周期更长的模型。