战术设计02-数据模型2

在DDD出现之前,大多数的系统都是通过基于数据的角度来建立模型的,即数据模型驱动设计。

通过对数据的正确建模,设计人员就可以根据模型建立数据字典。数据模型会定义数据结构与关系,有效地消除数据冗余,保证数据的高效访问。由于软件系统的业务功能归根结底是对信息的处理,由此建立的数据模型也可以通过某种编程手段来实现,满足业务需求。

1、数据分析模型

数据建模过程中的分析活动会通过理解客户需求寻找业务概念建立实体(Entity)。在数据模型中,一个实体就是客户希望建立和存储的信息。这是一个抽象的逻辑概念,位于数据模型的最高抽象层。

一个实体不一定恰好对应一个数据表,它甚至可能代表一个主题域。在识别实体的同时,我们还需要初步确定实体之间的关系。由于这个过程与数据库细节完全无关,因而称之为对数据的“概念建模”,建立的模型称之为实体关系模型

实体关系模型是数据建模的开始,目的是让我们可以从一开始抛开对数据库实现细节的考虑,寻找那些表达业务知识的重要概念,并明确它们之间的关系。但对数据模型的分析并不会就此止步,我们必须在分析阶段对实体做进一步细化,形成具体的数据表,并定义表属性,确定数据表之间的真实关系。这时获得的分析模型称之为“数据项模型”。实体关系模型与数据项模型之间的关系如下图所示:

1.1关系数据库的数据项模型

关系数据库体现了关系模型,形成了一种扁平的结构化数据,这就要求进一步规范数据表的粒度,将实体或主题域拆分为多个遵循数据库范式的数据表,明确一个数据表的主要属性。

数据库范式是面向数据的分析建模活动的一个关键约束。这些范式包括一范式(1NF)、二范式(2NF)、三范式(3NF)、BC 范式(BCNF)和四范式(4NF)。遵循这些范式可以保证数据表属性的原子性、避免数据冗余和传递依赖等。

例如在确定数据项时,该如何考虑避免数据冗余?这就需要合理地设计表以及表之间的关系。

比如公司的员工具备多种角色这种场景:

在创建数据模型时,应该将角色属性从员工剥离出去,分别形成数据表 t_employeet_role;又因为员工和角色之间存在多对多的关系,需要引入一个关联表 t_employee_roles。这个数据模型如下图所示:

当数据模型出现多对多关系时,之所以要引入一个关联表,是因为多对多关系会引入数据表之间的交叉关联。这个数据项模型中的 t_employee_role 并无映射的业务概念,引入该表,纯粹是数据库实现细节对模型产生的影响。

在确定数据项模型时,还需要考虑访问关系数据库的性能特性,从而决定数据的粒度与分割。通常,需要考虑数据表的规范化,避免设计出太多过小的包含少量数据的数据表。一个数据表的粒度较小,就会导致程序在访问数据库时频繁地在多张小表之间跳转。这个访问过程既要存取数据,又要存取索引以找到数据,导致I/O的过度消耗,影响到整体的性能。

因此,数据模型很少具有一对一关系,即使现实世界的概念存在一对一关系,也应该尽量通过规范化将两张表的数据组织在一起,合到一个实体中。

例如,我们说一位员工有一个家庭电话号码和工作电话号码,若站在领域概念角度,就应该建模为拥有两个不同电话号码(PhoneNumber)的员工(Employee)对象:

数据模型却不能这样建立,因为我们需要考虑分开两张表带来的 I/O 开销。虽然家庭电话号码和工作电话号码都是相同的 PhoneNumber 类型,但却属于两个不同的属性,将它们合并放到 t_employee 数据表,并不会破坏数据库范式。

通过分析活动获得的数据项模型,可以认为是数据分析模型,它确定了系统的主要数据表、关系及表的主要属性。

2、 数据设计模型

到了建模的设计活动,就可以继续细化数据项模型这个分析模型,例如丰富每个表的列属性,或者确定数据表的主键与外键,确定主键的唯一性策略,最后将数据表映射为类对象。

2.1 丰富数据分析模型

  1. 继续挖掘业务需求,分析还有哪些遗漏的属性
  2. 考虑数据的类型/长度,主键,或者联合主键
  3. 确定表的约束和索引, 这有利于保证一致性和正确性,提升查询性能。
  4. 是否需要为多个数据表建立视图,有利于保证查询性能和数据安全

2.2 数据设计模型的构成

建立数据设计模型,最主要的设计活动还是将数据表映射为类对象,以此来满足业务实现。这个过程称之为对象与数据的映射(Object-Relation Mapping,ORM)。

由于数据建模是自下而上的过程,首先确定了数据表以及之间的关系,然后再由此建立与之对应的对象,因此一种简单直接的方法是建立与数据表完全一一对应的类模型。对象的类型即为数据表名,对象的属性即为数据表的列。这样的类在数据设计模型中,目的在于数据的传输,相当于 J2EE 核心模式定义的传输对象(Transfer Object)。

当然从另一方面来看,由于它映射了数据库的数据表,因而又可以作为持久化的数据,即持久化对象(Persistence Object)。

显然,遵循职责分离的原则,数据设计模型主要包含三部分的职责:

  • 业务逻辑

  • 数据访问及数据

  • 映射为对象模型,就是与数据表一一对应并持有数据的持久化对象,封装了 SQL 数据访问逻辑的数据访问对象(Data Access Object,DAO),以及满足业务用例需求的服务对象。

    三者之间的关系如下图所示:

数据访问对象

数据访问对象属于 J2EE 核心模式中的一种,引入它的目的是封装数据访问及操作的逻辑,并分离持久化逻辑与业务逻辑,使得数据源可以独立于业务逻辑而变化。

《J2EE 核心模式》认为:“数据访问对象负责管理与数据源的连接,并通过此连接获取、存储数据。”一个典型的数据访问对象模式如下图所示:

图中的 Data 是一个传输对象,如果将该 Data 定义为表数据对象,它可以处理表中所有的行,如 RecordSet,或者由 ADO.NET 中的 IDataReader 提供类似数据库游标的访问能力,就相当于运用了《企业应用架构模式》中的表数据入口(Table Data Gateway)模式。如果 Data 是这里提及的代表领域概念的持久化对象,则需要引入 ResultSet 到 Data 之间的映射器,这时就可以运用数据映射器(Data Mapper)模式。

持久化对象

在数据设计模型中,持久化对象可以作为数据的持有者传递给服务、数据访问对象甚至是 UI 控件。

随着轻量级容器的流行,越来越多的开发人员认识到持久化对象强依赖于框架带来的危害,POJO(Plain Old Java Object)和 POCO(Plain Old CLR Object)得到了大家的认可和重视。Martin Fowler 甚至将其称之为持久化透明(Persistence Ignorance,PI)的对象,用以形容这样的持久化对象与具体的持久化实现机制之间的隔离。理想状态下的持久化对象,不应该依赖于除开发语言平台之外的任何框架。

在《领域驱动设计与模式实战》一书中,Jimmy Nilsson 总结了如下特征,他认为这些特征违背了持久化透明的原则:

  • 从特定的基类(Object 除外)进行继承
  • 只通过提供的工厂进行实例化
  • 使用专门提供的数据类型
  • 实现特定接口
  • 提供专门的构造方法
  • 提供必需的特定字段
  • 避免某些结构或强制使用某些结构

服务对象

由于持久化对象和数据访问对象都不包含业务逻辑,服务就成为了业务逻辑的唯一栖身之地。这时,持久化对象是数据的提供者,实现服务时就会非常自然地选择事务脚本(Transaction Script)模式。

《企业应用架构模式》对事务脚本的定义为:

使用过程来组织业务逻辑,每个过程处理来自表现层的单个请求。这是一种典型的过程式设计,每个服务功能都是一系列步骤的组合,从而形成一个完整的事务。注意,这里的事务代表一个完整的业务行为过程,而非保证数据一致性的事务概念。

例如为一个音乐网站提供添加好友功能,就可以分解为如下步骤:

  • 确定用户是否已经是朋友
  • 确定用户是否已被邀请
  • 若未邀请,发送邀请信息
  • 创建朋友邀请
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

public class FriendInvitationService {
public void inviteUserAsFriend(String ownerId, String friendId) {
try {
bool isFriend = friendshipDao.isExisted(ownerId, friendId);
if (isFriend) {
throw new FriendshipException(String.format("Friendship with user id %s is existed.", friendId));
}
bool beInvited = invitationDao.isExisted(ownerId, friendId);
if (beInvited) {
throw new FriendshipException(String.format("User with id %s had been invited.", friendId));
}

FriendInvitation invitation = new FriendInvitation();
invitation.setInviterId(ownerId);
invitation.setFriendId(friendId);
invitation.setInviteTime(DateTime.now());

User friend = userDao.findBy(friendId);
sendInvitation(invitation, friend.getEmail());

invitationDao.create(invitation);
} catch (SQLException ex) {
throw new ApplicationException(ex);
}
}
}

为了提升可读性,也可以对上面的事物脚本代码进行抽取,通过提取方法来改进代码的可读性。每个方法都提供了一定的抽象层次,通过方法的提取就可以在一定程度上隐藏细节,保持合理的抽象层次。这种方式被 Kent Beck 总结为组合方法(Composed Method)模式:

1
2
3
4
5
6
7
8
9
10
11
12
    public void inviteUserAsFriend(String ownerId, String friendId) {
try {
validateFriend(ownerId, friendId);
FriendInvitation invitation = createFriendInvitation(ownerId, friendId);
sendInvitation(invitation, friendId);
invitationDao.create(invitation);
} catch (SQLException ex) {
throw new ApplicationException(ex);
}
}
}

当然,无论对于事务脚本做出怎样的设计改进,只要不曾改变事务脚本的过程设计本质,一旦业务逻辑变得更加复杂时,就会变得捉襟见肘。Martin Fowler 就认为:

“当事物一旦变得那么复杂时,就很难使用事务脚本保持一个一致的设计。”

3、数据模型与对象模型

在建立数据设计模型时,我们还需要注意表设计与类设计之间的差别,这事实上是数据模型与对象模型之间的差别。

3.1数据模型与对象模型

数据分析模型建立的时候,需要考虑数据库范式,要避免数据的冗余。比如,一个员工表和客户表都有一个email的属性,这个业务含义在所有表中都是相同的,但是在设计的时候,只能给员工表设置一个email列,给客户表设置一个email列,且都是varchar类型。

如果是基于业务建立对象模型,需要遵循“高内聚低耦合”的设计原则,如果发现多个属性具有较强的相关性,需要将其整合起来共同定义一个类。例如国家、城市、街道和邮政编码等属性,它们都与地址相关,共同组成完整的地址概念,在对象模型中就可以定义 Address 类。

在数据模型中,关系数据表并不支持自定义类型,在设计时又需要支持一范式(1NF),即确保数据表的每一列保持原子性,就必须将这个内聚的组合概念进行拆分。即你不能自定义一个Address的类型。 这时,地址在数据模型中就成了一个分散的概念。若要保证其概念完整性,唯一的解决方案是将地址定义为一个独立的数据表;但这又会增加数据模型的复杂性,更会因为引入不必要的表关联而影响数据库的访问性能。

对比数据模型与对象模型之间的差异。例如,员工、客户与地址的数据模型如下图所示:

虽然员工与客户都定义了诸如 country、city 等地址信息,但它们是分散的,并被定义为数据表提供的基本类型,无法实现两个表对地址概念的重用。对象模型就完全不同了,它可以引入细粒度的类型定义来体现丰富的领域概念,封装归属于自己的业务逻辑,同时还提供了恰如其分的重用粒度:

组成数据模型的数据表是一个扁平的数据结构,数据表中的每一列都是数据库的基本类型,而组成领域模型的类则具有嵌套的层次结构。在设计时,更倾向于建立细粒度对象来表达一个高度内聚的

从设计模型看,构成数据模型主体的数据库与数据表,明显存在粒度和边界的局限性。这种局限性在一定程度上影响了数据建模的质量。关系数据库的设计范式并没有从类型复用的角度去规定数据表的设计,由于关系表不支持自定义类型。因此可以认为在数据模型中,数据表才是最小的复用单元。由于建立一个数据表存在 I/O 成本,会影响数据库的访问性能,因而在数据模型中,通常不建议为细粒度但又是高内聚的数据类型单独建立数据表,如前面给出的“地址”的例子。换言之,关系数据库的设计范式仅仅从数据冗余角度给予了设计约束,如果照搬数据模型去建立类模型,就有可能无法避免代码冗余。

在关系数据库的数据模型中,数据库是最大的复用单元。设计数据库时,往往是一个库对应一个子系统或者一个微服务,而在数据库和数据表之间,缺少合适粒度的概念去维护数据实体的边界。它缺少领域驱动设计引入的聚合(Aggregate)、模块(Module)等各种粒度的边界概念。显然,扁平的关系型数据结构无法体现领域概念中丰富的概念层次。

4. 数据实现模型

41. SQL和存储过程

SQL 语句可以很强大,例如它同样提供了数据类型、流程控制、变量与函数定义。同时,还可以使用 SQL 来编写存储过程。从某种程度讲,存储过程也可以认为是事务脚本。

存储过程的代码可读性/扩展性/可维护性很差。

SQL 和存储过程的自动化测试一直是一个难题。虽然有的数据库提供了 SQL 单元测试的开发工具,例如 Oracle SQL Developer;但是,单元测试的本质是不依赖于外部资源,SQL 的目的又是访问数据库,这就使得 SQL 单元测试本身就是一个悖论。

4.2 对象关系映射

我在介绍数据设计模型时提到数据表与对象之间的关系,认为:

一种简单直接的方法是建立与数据表完全一一对应的类模型。

这其实是数据模型驱动设计的核心原则,即它是按照数据表与关系来建模的,因此在数据模型驱动设计中,数据表与对象的映射非常简单:数据表即类型,一行数据就是一个对象。映射的持久化对象是一个典型的贫血模型。如果针对关系数据库建立了数据模型,却在定义对象类型时采用了面向对象的设计思想,那就不再是数据模型驱动,而是领域模型驱动

数据表与对象之间的映射,主要体现在概念上的一一对应,对于关系的处理则有不同之处。Jimmy Nilsson 认为:

“在关系数据库中,关系是通过重复的值形成的。父表的主键作为子表的外键重复,这就有效地使子表的行‘指向’它们的父亲。因此,关系模型中的一切事物都是数据,甚至关系也是数据。”

在类模型中,这种主外键关系往往通过类的组合或聚合来体现,即一个对象包含了另外一个对象或对象的集合。因此,当数据访问对象从数据表中查询获得返回结果时,需要通过映射器来实现从数据表的行到对象的转换。

整体而言,当我们面对关系数据库进行数据建模时,数据实现模型实际上得到了简单处理,所有业务都通过服务、数据访问对象与持久化对象三者之间的协作来完成,三者各司其职:

  • 持久化对象就是数据表的映射,并合理处理数据表之间的关系;
  • 数据访问对象负责访问数据库,并完成返回结果集如 ResultSet 或 DataSet 到持久化对象的映射;
  • 服务利用事务脚本或者存储过程来组织业务过程。

当然,持久化对象与数据访问对象的组合可以有多种变化,我在前面讲解数据设计模型时已经介绍,这些变化对应的恰好是 Martin Fowler 在《企业应用架构模式》中总结的四种数据源架构模式:

  • 表入口模式
  • 行入口模式
  • 活动记录
  • 数据映射器

这种简单的职责分配可以让一个不具备面向对象设计能力的开发人员快速上手,尤其是在 ORM 框架的支持下,框架帮助开发人员完成了大部分工作:生成持久化对象,封装通用的数据访问行为,利用元数据或配置完成表到类的映射。留给开发人员要做的工作很简单,就是编写 SQL,然后在服务中采用事务脚本的方式实现业务过程。如果业务过程也用存储过程或者 SQL 实现,则数据模型驱动设计的实现模型就只剩下编写 SQL 了。这些工作甚至 DBA 就可以胜任。

如果对比数据设计模型与数据实现模型,我们发现二者没有非常清晰的界限。设计过程实际上取决于对 ORM 框架的选择。

  • 如果选择 MyBatis,则遵循数据映射器模式,为持久化对象建立 Mapper,并通过 Java 注解或 XML,配置数据表与持久化对象之间的映射及对 SQL 的内嵌;
  • 如果选择 jOOQ,则遵循活动记录模式,利用代码生成器创建具有数据访问能力的持久化对象,采用类型安全的 SQL 编写访问数据的逻辑代码;
  • 如果选择 Spring Data JPA,则是数据映射器与资源库模式的一种结合,通过框架提供的Java注解确保实体(即这里的持久化对象)与数据表的映射,编写资源库(即数据访问对象)对象,并通过继承 CrudRepository 类实现数据库的访问。

一旦选定了框架,其实就开始了编码实现。只需要确定业务过程,就可以创建服务对象,并以过程式的方式实现业务逻辑,若需要访问数据库,就通过数据访问对象来完成查询与持久化的能力。

5、数据模型驱动设计的过程

在数据模型驱动设计的过程中,设计模型与实现模型皆以分析模型为重要的参考蓝本。整个数据模型驱动设计的重头戏都压在了分析模型上。通过对业务知识的提炼,识别出实体和数据表,并正确地建立数据表之间的关系成为了数据建模最重要的工作。它是数据模型驱动设计的起点。因此,一个典型的数据模型驱动设计过程如下图所示: