第三节 O/R阻抗失配
在使用面向对象的编程语言编写的应用程序中,对象是其基础构建块。由于前文表述的种种原因,我们需要将领域对象的状态持久化到某种存储媒体中,以便在需要时能够重建这些对象。通常而言,是将领域对象状态(值和关联)持久化到关系数据库中。
在以关系数据库为目标的持久化中,我们会尽量:
- 将领域对象中的实体类与数据库中的表一一对应
- 将实体实例与表中的记录(行)一一对应
- 将实体实例属性与表中的字段(列)一一对应
- 将实体类的标识属性对应到表中的主键字段
但是,对象模型(O
)和关系模型(R
)是基于不同的理论,针对不同的目标建立的,两者之间并不能那么完美匹配。实际上,两者在很多方面存在“阻抗失配(Impedance Mismatch
)”。主要体现在以下的方面。
1. 粒度问题
我们知道,在领域模型中,有些实体持有值对象,这些值对象本身可能有多个属性。有时候,值对象内部甚至还包含下一级的值对象。类的粒度从粗到细:实体-值对象-简单属性,可以有3个甚至3个以上的粒度层次。
例如在电商网站中,顾客类Customer
持有配送地址shippingAddress
这样一个Address
类型的值对象属性。
而关系数据库中只有两种粒度:由你创建的关系类型(表),以及内置的数据类型,如VARCHAR
、BIGINT
和TIMESTAMP
等。它不能直接对应到对象模型中的多级粒度。尽管各种数据库系统往往允许用户创建自定义类型,但是操作起来很复杂,更关键的是:不同数据库系统的自定义数据类型不可移植。
2. 标识问题
在应用程序和数据库中,常常需要判断两个对象或者两行数据是不是相同的(代表相同的对象或数据行),就是通过什么样的标识来识别两个事物是不是相同的事物。
在Java
中,有两种不同的相等性概念:
- 实例标识:大致等同于对象的内存位置,使用
a == b
进行检查。 - 实例等价性:通过
equals()
方法的实现进行判断。
另一方面,数据行的标识会体现为主键值的比较。同一个表中主键值相同两个数据行代表同一行数据。
Java
中有几个不相等的对象实例同时对应数据库同一行的情况很常见,例如在并发的应用程序线程中就是如此。
3. 继承与多态问题
面向对象有三大特征:封装、继承和多态。Java
作为面向对象的编程语言,可以使用基类和子类来实现类型继承。在领域模型中,类型继承是非常常见的。例如银行系统的领域模型:
银行账户Account
这个超类有储蓄账户SavingsAccount
和信用账户CreditAccount
两种子类型。在超类Account
中可以定义所有子类的共同属性(锁定状态locked
、当前余额balance
等),同时每个子类还可以分别定义自己特有的属性(例如信用账户CreditAccount
子类拥有一个特有属性:信用额度creditLimit
)。
Account
抽象超类有一个owner
(账户持有人)属性,指向另一个抽象基类Customer
(客户)。Customer
有两个具体子类:公司客户CompanyCustomer
和个人客户PersonalCustomer
。两个子类分别拥有自己的一批专有属性。
子类除了可以添加自己特有的属性之外,还可以添加方法、重写或实现超类的方法。例如超类Account
上定义了抽象方法credit()
,两个子类分别为这个方法提供自己的实现。
基于上面的分析,持久化必须:
允许进行多态关联。
例如上面的例子,某个
Account
实体实例account1
的owner
属性可能关联的是一个CompanyCustomer
,而另一个Account
实体实例account2
的owner
属性可能关联的是一个PeronalCustomer
。持久化必须能够记录关联的具体子类型信息,以便在检索数据库重建Account
实例的时候恢复正确的子类型关联。允许进行多态查询。
例如上面的例子。当我们以具体子类为目标进行查询的时候,期望返回的是符合条件的某个子类的实例的集合。而当我们以基类为目标进行查询的时候,期望返回的是符合条件的所有子类型的实例的集合。
关系数据库中没有任何与数据继承和多态相关的概念。表之间没有继承关系,SQL
也不提供任何多态查询的功能。
4. 关联问题
在领域模型中,关联表示了实体之间的关系。面向对象的语言如Java
使用对象引用来表示关联,而在关系模型中,外键约束列表示了一个关联,它带有一些键值的副本。
4.1 关联的方向性
关联是有方向性的,通过对象引用的方式从一个实体(或值对象)指向另一个实体。它们都是指针。
在Java
代码中,体现为一个实体类拥有一个类型为另一个实体类(或其集合)的属性:
public class Account {
private Customer owner;
...
}
public class Customer {
...
}
如果要实现双向关联,需要在两端分别定义关联:
public class Account {
private Customer owner;
...
}
public class Customer {
private Set<Account> accounts;
...
}
关系模型采用外键的方式记录表之间的关联关系。在特定的方向上导航对于关系模型来说没有意义,因为可以使用join
和投影操作创建任意的数据关联。其挑战在于将一个完全开放、独立于数据使用应用程序的数据模型影射到一个依赖应用程序的导航模型——这个特定应用程序所需的关联约束视图。
4.2 多对多关联
在领域模型中常常存在多对多关联。例如在选课系统中,一个学生可以选择多门课程,一门课程也可以被多个学生选中。
Java
代码如下:
public class Student {
private Set<Course> selectedCourses;
...
}
public class Course {
private Set<Student> selectedBy;
...
}
而关系模型中,只能通过外键表示“多对一”或者"一对多"关联,没有对“多对多”关联的直接支持。
5. 数据导航问题
在Java
中,可以跟随关联的方向遍历对象网络,例如:
aUser.getBillingDetails().iterator().next();
但是这样的访问会导致著名的n + 1
问题:需要为每一个访问节点或者对象网络的集合执行一条语句,导致性能低下。
在数据库中,可以根据外键关联用join
的方式一次性获取数据,因而可以大大提高性能:
select * from USERS u
left outer join BILLING_DETAILS bd on bd.USER_ID = u.ID
where u.ID = 123;
6. 值对象的生命周期问题
前文说过,实体的状态分为“值”和“关联”两种,值包括简单值(数字、字符串、布尔值、日期等等)和值对象(Email
、Money
、Address
等等)。值位于实体的边界之内,是实体的内在组成部分,而关联指向一个外部实体,不是实体的内在组成部分。因此,作为实体属性的简单值和值对象,不论是单值的还是多值的(数组、列表或集合等形式),都是实体的固有组成部分,从属于实体,不具备独立的生命周期。它们会随着实体创建而创建,随着实体的更新而更新,也会随着实体的删除而删除。不需要任何额外的步骤去处理值的生命周期。
举个例子,在电商软件中有三个领域类,包括一个实体类Customer
和两个值对象Address
和CustomerName
。Customer
类拥有一个类型为PersonName
的单值属性name
以及一个类型为Address
的多值属性shippingAddresses
(一个顾客拥有一个名称和多个送货地址)。下面是Java
代码:
public class Customer {
private long id;
private CustomerName name;
private Set<Address> shippingAddresses = new HashSet();
...
//getters and setters
}
public class CustomerName {
private String firstName;
private String lastName;
...
//getters and setters
}
public class Address {
private String postalCode;
private String province;
private String city;
private String street;
...
//getters and setters
}
根据领域模型的含义,值对象PersonName
和Address
都不需要单独持久化,体现在:
- 当保存新建的
Customer
实体对象时,单值属性name
持有的值对象CustomerName
和多值属性shippingAddresses
指向的值对象Address
的集合都会自动保存,而不需要分别保存Customer
、CustomerName
和Address
。 - 当对
shippingAddresses
这个集合增减条目或修改其中部分地址信息时,只须用新的地址集合整体替换掉现有的地址集合,不用担心其中哪些是新的,哪些是旧的;既不必担心造成重复,也不必担心出现“孤儿对象”——在这里指不关联到任何Person
的Address
实例。 - 当删除
Person
实体对象时,它持有的CustomerName
实例和Address
集合会一并删除。
而关系模型没有实体和值对象之别。它只认知两个表之间通过外键存在的关联关系。必须分别管理关联的两端的表,而不能做到级联持久化或者级联删除。