现代应用程序构建于两种截然不同的技术之上:用于业务逻辑的面向对象编程;以及用于数据存储的关系数据库。面向对象编程是实现复杂系统的关键技术,它提供了可重用性、健壮性和可维护性的优势。关系数据库是持久数据的仓库。ORM(对象关系映射)是两者之间的桥梁,它允许应用程序以面向对象的方式访问关系数据。
ORM 是对象持久化这一通用概念的特化。IBM 院士格雷迪·布奇在他的著作《面向对象分析与设计及应用》中声称,持久性,定义为“数据比程序寿命更长”,是对象模型中的一个次要元素。¹ 然而,在现代应用程序中,投入到持久化的精力可能占据项目成本的大部分,而使用 ORM 工具可以显著降低这一成本。
其他技术也可以用于从面向对象程序访问关系数据,但这些技术通常不利用编程语言的对象行为。为了充分利用对象行为,数据库访问技术应支持关注点分离、信息隐藏、继承、变更检测、唯一化和数据库独立性。
关注点分离是将程序分解为几乎或完全没有重叠的逻辑部分的过程。在数据库编程中,应分离几个部分:查找领域对象并对其进行操作的业务方法;这些领域对象中可能导致内部状态更改并调用关联对象中的方法的方法;生成数据库命令以实现数据库行和列的插入、查询、更新和删除;以及用于数据库事务划分的方法。
信息隐藏是一种关注点分离的实现策略,它通过定义由特定类实现的接口的行为来降低复杂性和成本。这些类实现的行为与调用者的行为分离。更改一方不一定需要在另一方进行更改。
继承允许代码重用,其中为多个相关类定义一次通用行为,而仅在行为不同的类中实现独特行为。因此,一个类的行为可能与子类或父类/超类的行为相同或不同,并且独立于调用者的行为。
变更检测跟踪在数据库事务中对领域对象所做的更改,以便在事务结束时,将更改应用到数据库。
唯一化是数据库交互的一个属性,其中单个领域实例对应于一个数据库行,无论用户如何获取该对象:通过查询数据库、从一个实例导航到另一个实例的引用,还是通过提供其主标识来查找不同的领域实例。如果没有唯一化,对一个领域实例所做的更改将不会被代表同一数据库行的其他领域实例看到;这可能会导致数据库损坏。
数据库独立性允许使用通用的 API 和领域模型来操作各种数据库,而无需更改应用程序对数据库的视图。
最早且仍然非常流行的访问关系数据库的技术使用 API 将 SQL 语句传输到服务器,并将执行语句的结果返回给应用程序。这留给应用程序直接使用结果,或者创建表示查询结果的数据结构,并将查询结果复制到这些数据结构中。
直接模拟查询结果的数据结构无法模拟数据库中存在的关系;因此,数据结构实例之间的关联表示效果不佳。这种访问风格的示例包括 ODBC(开放数据库互连)和 JDBC(Java 数据库连接)接口。这些接口允许有限的关注点分离,但大多数情况下,业务逻辑与数据库编程混合在一起。它们不支持信息隐藏、继承、变更检测、唯一化或数据库独立性。
更强大的技术在接口中提供方法,将查询结果复制到用户指定的数据结构中。用户提供所有 SQL 语句,并对其进行注释,以便它们与数据结构相对应。用户创建的 SQL 语句包括所有查询,以及插入、更新和删除。这些技术处理了分析查询结果的大部分容易出错且耗时的任务,但仍然将许多困难的任务留给用户。这种访问风格的一个示例是 iBATIS,它支持信息隐藏和有限的关注点分离,但不包括继承、变更检测、唯一化或数据库独立性。
使用 ORM,存储在关系数据库中的数据在应用程序中表示为本机对象编程语言中的对象。程序员将领域对象模型类映射到关系表,并使用持久化提供程序实现的 API 来访问数据库。针对数据库的查询以领域对象模型的方式表达。提供程序直接从领域模型生成 SQL 语句。马丁·福勒将这种方法称为数据映射器。²
ORM 技术和产品适用于许多面向对象语言,包括 Java、C++、C#、Python、Smalltalk、Ruby 和 Groovy。ORM 的技术和编程范式适用于许多支持这些语言的产品。(在本文中,示例以 Java 给出。)
应用程序对 ORM 的视图由两个主要部分组成:持久化 API 和领域类。在 Java 中,API 通常是 Java 社区进程标准之一——Java 持久化 API、企业级 JavaBean 或 Java 数据对象——或非标准但流行的 API,如 TopLink 或 Hibernate。
使用标准持久化 API 的一个优势是,它允许项目在部署后期决定数据库和持久化提供程序。这在许多项目中可能是一个重要因素,在这些项目中,区分持久化提供程序的功能需求在项目开始时并不明显。
持久化 API 允许应用程序程序员执行数据库的所有标准 CRUD(创建、读取、更新、删除)操作。由于应用程序程序员不直接访问数据库的行和列,因此使用简写符号来描述行为。例如,“使映射领域的实例持久化”是“在数据库中创建一行或多行,这些行与映射领域类的实例完全对应”的简写。“删除领域类的实例”同样意味着“删除与领域类的实例完全对应的数据库行或多行”。
API 包括以下方法:使映射领域类的实例持久化;通过其主标识检索领域类的实例;通过以领域类值表示的查询查找领域类和子类的所有实例;以及从数据库中删除领域类的实例。更新在事务的上下文中完成,通过检索领域类的实例并使用领域方法来修改实例的值。
表示应用程序对关系数据库中存储的数据的视图的领域类通常是手工编写的,或者使用工具从数据库模式生成的。映射通常是声明式的,它将领域对象模型与关系模式模型相关联,并在应用程序运行之前定义。由于领域类不需要包含特定的持久化行为,因此这些类通常被称为 POJO(简单 Java 对象)。
缺少持久化代码允许持久类独立于持久化方面进行操作。因此,领域类的大部分行为都可以在不访问数据库或持久化环境的情况下进行测试。这种编程风格鼓励关注点分离和封装。
许多表直接映射到领域类,例如 Employee、Department、Customer、Order、LineItem、Contract、Claim、Product 等等。这些表的行映射到领域类的实例。表的列映射到领域类的字段。
对象和数据库模式之间所谓的阻抗失配引起了广泛的讨论,这是有充分理由的。斯科特·安布勒认为,这两种技术之间存在“迷惑性的相似性”。³ 不理解这两种技术之间的差异可能会导致糟糕的设计选择和项目失败。
Java 等对象语言中的数据模型与关系数据库中的数据模型不完全相同,因此必须特别注意防止出现问题。例如,必须在关系模式中指定字符列的最大长度,但 Java 字符串本质上是无界的。
浮点数也可能引起问题。Java 实现 IEEE 浮点数;关系数据库通常具有不同的表示形式。其结果是,并非 Java 中所有浮点数值都可以存储在数据库中,反之亦然。
即使是定点精度十进制数,在映射到关系模式时也可能构成问题。Java 支持自描述的定点精度十进制数——也就是说,每个值都有特定的精度(位数)和刻度/标度(小数点右侧的位数)。然而,在数据库中,同一列中的所有数字都具有相同的精度和刻度/标度。
应用程序领域类和数据库模式之间的映射,按设计而言,不是同构的。在领域模型方面,对象模型的某些方面未映射到数据库,包括类的行为和类变量。在关系数据库方面,数据库中每个模式中的并非所有表和列都在领域模型中表示,存储过程也不例外。此外,领域模型可能具有多个有效映射。
使用领域对象模型表示数据库模式通常很简单,尤其是在模式规范化良好的情况下。使用模式的实体-关系模型,每个实体都映射到一个领域类。每个简单关系(在模式中实现为外键)都映射到一侧的引用类型字段和另一侧的多值类型字段。
实体-关系模型中的许多复杂构造都可以很好地映射到对象模型构造。例如,一个连接表(其中列是两个不同表的外键)可以映射到两个类,其中每个类都包含一个字段,其类型是另一个类类型的对象集合。
另一方面,将任意领域对象模型存储在关系数据库中可能具有挑战性。使用抽象类、深层继承或接口表示关系的模型的存储和检索通常更困难,并且性能合理。
继承。在领域对象模型中,继承是两个类之间的关系,其中一个类是另一个类的特化。图 1 显示了一个人力资源对象模型,其中 FullTimeEmployee 和 PartTimeEmployee 是 Employee 的特化,而 Employee 反过来又是 Person 的特化。
有几种方法可以将此领域对象模型映射到关系模式。单表继承策略将继承层次结构中的所有类映射到单个表,该表包含任何类中每个字段的列。在这种映射中,当行表示不包含该列字段的类的实例时,表的槽位包含空值或默认值。
为了使单表策略有效,数据库必须包含允许映射确定任何特定行映射到哪个类的信息。映射通常包含鉴别器列的名称以及映射到每个可能类的该列的值。
例如,PERSON 表(如图 2 所示)可能包含一个 DISCRIMINATOR 列,其值分别为 S、E、F 和 P,具体取决于行是否映射到 Person、Employee、FullTimeEmployee 或 PartTimeEmployee 的实例。当从 PERSON 表检索行时,提供程序将始终检索 DISCRIMINATOR 列的值,以便实例化正确的类。
每类表继承策略(图 3)将每个类映射到其自己的表,并将每个字段映射到映射表中的列。单个实例的所有数据在每个表中都具有相同的主键列值,并且模式将在表之间声明外键关系。当查询这些表时,提供程序构造连接查询以确定行映射到哪个类。例如,要查找在特定日期之后雇用的所有员工,查询将连接 PERSON、EMPLOYEE、PART_TIME_EMPLOYEE 和 FULL_TIME_EMPLOYEE 表,以确定行是否映射到 Person、PartTimeEmployee 或 FullTimeEmployee。
每具体类表继承策略(图 4)将每个非抽象类映射到其自己的表,并将每个字段映射到映射表中的列。此策略减少了表示领域类所需的表数量。
混合继承策略可能使用策略的组合。例如,它可能将 Person 和 Employee 的字段映射到一个包含鉴别器列的表;将 FullTimeEmployee 的字段映射到第二个表;并将 PartTimeEmployee 的字段映射到第三个表。
单表继承映射的优点是简单性和性能。要查询层次结构中任何类的实例,只需要使用单个表。单表继承的缺点是它可能需要许多仅由少数类映射使用的列。这种低效性在非常宽的继承层次结构中可能是一个问题。
其他继承映射策略的优点是模式可以完全规范化。不包含鉴别器列的继承映射策略的主要缺点是性能。大多数子类实例的查询都需要层次结构中表的外部连接,而这些查询众所周知难以优化。
关系。在关系模式中,关系通过外键建模,外键定义为对列允许包含的值的约束,基于其他表行中包含的值。此约束由数据库强制执行。如图 5 所示,EMPLOYEE 表可能包含一个 DEPT 列,其中包含员工所属部门的部门编号。EMPLOYEE 表中的 DEPT 列对其声明了约束,因此 EMPLOYEE 表任何行的 DEPT 列的值必须也是 DEPARTMENT 表的主键列中包含的值。
外键约束可以映射到领域对象模型中的多个关系字段。例如,DEPT 列映射到对象模型中的两个关系字段:Employee 类包含对 Department 的引用;Department 类包含对 Employee 的引用集合。
如果外键列是唯一的,则表中只有一行与另一个表中的同一行相关。在图 6 中,INSURANCE 表有一个 EMPLOYEE 列,该列将 INSURANCE 表中的行与 EMPLOYEE 表中的行相关联,并且对于 EMPLOYEE 表中的任何行,INSURANCE 表中只有一行。此外键列通过每个领域类中引用另一个类的引用映射到对象模型。
在规范化的关系模式中,一个表的许多行与另一个表的许多行相关的关系由连接表表示,其中连接表的每一行都包含一个指向每个相关表的外键。如果 EMPLOYEE 表的多行与 PROJECT 表的多行相关,则需要第三个表来维护规范化的模式(图 7)。EMPLOYEE_PROJECT 表包含两列:一列包含指向 EMPLOYEE 表的外键,另一列包含指向 PROJECT 表的外键。因此,具有两个外键的连接表映射到领域类的关系,这些领域类在两侧都具有多值字段。
嵌入式。为了提高封装性,将领域类中的字段集合建模为单独的类可能很有用。此映射概念称为嵌入式,因为嵌入式类的列与映射领域类在同一表中嵌入。
如图 8A 所示,Employee 类可能包含表示家庭地址的字段。这些字段映射到 EMPLOYEE 表中的列。图 8B 显示了使用嵌入式 Address 类对同一模式的不同映射,该类映射到同一表中的相同列。
依赖。领域对象模型中的一个常见模式是组合,其中一组实例的生命周期依赖于另一组实例。在数据库中,存储依赖实例的表与其他表没有区别。然而,领域模型的行为要求,如果从拥有实例中移除关系,则还应移除与依赖实例对应的行。
在图 9 中,技能关系被标记为依赖关系,如果从拥有 Employee 的技能集合中移除 Skill,则持久化提供程序将从数据库的 SKILL 表中删除相应的行。
前面的描述涵盖了领域类和规范化数据库模式之间映射的许多常见情况。更复杂的领域类也可以映射到更精细的数据库模式。
ORM API 提供了表示运行时环境的接口。API 的主要接口封装了到数据库的连接、查询生成器以及应用程序引用的领域实例缓存。对于本文,此接口称为 Session。(在不同的 API 中,此接口称为 EntityManager 或 PersistenceManager,但无论名称如何,它都服务于相同的目的。)
在多用户环境中,使用多个 Session 实例来隔离用户。每个 Session 都有其自己的数据库连接和其自己的领域实例缓存。会话缓存实现唯一化。如果 API 支持在同一 Session 上运行的多个线程,则所有此类线程共享对 Session 管理的领域对象的更改。
应用程序从 SessionFactory 获取 Session,SessionFactory 封装了数据源(连接工厂)和 ORM 模型。SessionFactory 通常还包括连接池和二级或全局领域对象缓存,并且它处理与容器的交互。SessionFactory 是引导实例,通常从系统服务(如 JNDI(Java 命名和目录接口))查找或通过 XML 或属性配置。
数据库事务由 Transaction 类表示,应用程序从 Session 实例获取该类的实例。一个 Session 只有一个活动的 Transaction 实例(一个 Session 可以串行地开始和完成多个事务),并且不同的接口表示关注点分离——也就是说,开始和完成事务的应用程序组件与访问领域实例的应用程序组件是分开的。
数据库查询由 Query 类表示,应用程序从 Session 获取该类的实例。Query 实例封装了特定的查询,并实现了一个 execute 方法,该方法允许应用程序根据不同的参数从数据库获取不同的数据集。
在一个工作单元期间,应用程序可能对领域实例进行了许多更改,并且在工作单元结束时,持久化提供程序根据对映射领域实例所做的更改将更改应用到数据库。
由于数据库模式中的外键约束,与领域模型中的更改对应的操作顺序非常重要。例如,必须先将新行插入到数据库中,然后才能插入另一个新行或更新现有行以引用新行。同样,在删除行之前,必须将现有行中引用要删除的行的列设置为 null。
与其他技术相比,基于数据库约束自动对数据库中的操作进行排序是 ORM 技术的一个重要的可用性特性,在其他技术中,程序员必须以正确的顺序执行操作。
查询。ORM 的主要优点之一是查询以领域对象模型而不是关系模式的方式表达。如果领域模型或查询很复杂,这一点就变得非常重要。
为 ORM 设计查询语言的挑战之一是使其足够丰富,以允许在查询语言中实现大多数应用程序查询,但又限制为允许将所有对象领域查询直接映射到 SQL。
例如,要查找每周工资大于某个参数的所有 Employee 实例,SQL 查询是
SELECT * FROM EMPLOYEE WHERE WEEKLY_SALARY > ?
The corresponding query using the domain object model would be:
SELECT FROM FullTimeEmployee
WHERE weeklySalary > :salary
提供程序从领域模型和映射生成 SQL。“:”是一个参数标记,允许应用程序通过名称将参数绑定到查询。
在 SQL 中,查询中涉及的所有表都在 FROM 子句中声明,连接条件明确包含在 WHERE 子句中。要查找每周工资大于某个参数且在具有某个名称的 Department 工作的 Employee 实例,SQL 会稍微复杂一些
SELECT E.* FROM EMPLOYEE E, DEPARTMENT D
WHERE E.WEEKLY_SALARY > ? AND D.NAME = ?
AND E.DEPARTMENT = D.ID
The corresponding query using the domain object model would be:
SELECT FROM FullTimeEmployee
WHERE weeklySalary > :salary && dept.name = :dept
ORM 实现之间最大的差异之一是如何表达查询中的导航。在 JDOQL(Java 数据对象查询语言)中,每个查询过滤器都是一个 Java 布尔表达式。布尔集合方法(如 contains()、containsKey() 和 containsValue())用于导航多值关系字段。
其他领域查询语言使用更类似于 SQL 的构造来导航关系。例如,在 Java 持久化 API 中,查询语言使用特殊关键字(如 JOIN、IN 和 OUTER)来导航关系。
要查找每周工资大于某个参数、在具有特定名称的 Department 工作以及在具有特定名称的 Project 上工作的 Employee 实例,SQL 会更加复杂
SELECT E.* FROM EMPLOYEE E, DEPARTMENT D, PROJECT P, EMPLOYEE_PROJECT EP
WHERE E.WEEKLY_SALARY > ? AND D.NAME = ?
AND E.DEPARTMENT = D.ID AND E.ID = EP.EMPID
AND P.ID = EP.PROJID AND P.NAME = ?
JDOQL 中对应的领域模型查询是
SELECT FROM Employee WHERE weeklySalary > :salary
&& dept.name == :dptname && projects.contains(p)
&& p.name == :prjname
此示例查询使用 Set<Project> projects 字段的 Set.contains(Object) 方法来映射到关系模型中的连接表。两个标记 :dptname 和 :prjname 是在运行时提供给查询的变量。
在 Java 持久化 API 中,查询将使用显式连接子句来导航员工-项目关系
SELECT e FROM Employee e, JOIN e.projs as p
WHERE e.weeklySalary > :salary
AND e.dept.name = :dptname AND p.name = :prjname
领域对象模型、映射和数据库模式会影响应用程序使用 ORM 工具可以获得的性能。此外,提供程序实现的质量与应用程序的质量同等重要。
影响性能的一个因素是持久化提供程序如何执行变更检测。当应用程序使用 API 提交事务时,提供程序会为每个已更改的实例向数据库发送更新请求。
某些提供程序迭代实例缓存,并将当前值与从数据库检索的值进行比较。其他提供程序在应用程序修改实例时动态跟踪更改。检索大量实例但仅更改少量实例的应用程序可能会看到显着的性能差异。
影响性能的另一个因素是如何检索应用程序所需的精确数据,不多也不少。JDBC 等底层 API 与 ORM 使用的高级 API 之间的一个显着区别是,当应用程序执行时,对从数据库检索的实际数据的控制。当使用领域模型时,相同的类可以在多个应用程序中重用,不同的用例可能需要检索不同的列。
例如,如果应用程序用例检查特定部门中的所有员工,则单个查询可以从 EMPLOYEE 表和 DEPARTMENT 表中检索列的子集。挑战在于应用程序如何通知持久化提供程序要检索哪些列以及为此用例实例化对象模型的哪一部分。
一种常见的技术是将领域模型中的某些字段静态声明为预先抓取/急加载,以便每当检索实例时,也会抓取相关实例。高度优化的持久化提供程序可以确定完成检索所需的精确 SQL,并获得最佳性能。
一种稍微更动态的替代方法是让领域查询本身指定实例的完整集合的检索。在这种情况下,应用程序将为每个用例编写单独的查询,指定查询要遵循的导航路径。
更通用的方法是定义访问模式,该模式指定字段集合和导航路径,并让应用程序选择特定用例所需的访问模式。例如,应用程序可以在 Employee 类中定义字段,并在 Department 类中定义字段,使其成为名为 empdept 的抓取组的一部分,并在运行时告知持久化提供程序使用抓取组 empdept。每当对 Department 或 Employee 执行查询时,empdept 抓取组都会控制提供程序执行的确切列和连接条件。
除了自制框架之外,程序员很可能在两个领域遇到多线程:GUI 应用程序和 Ajax(异步 JavaScript 和 XML)应用程序。
为了保持活跃性,GUI 应用程序在主线程中处理琐碎的请求,并将运行时间较长的请求委托给一个或多个后台线程。如果使用 MVC(模型-视图-控制器)方法,那么为了与视图对象交互而读取模型对象字段的值似乎是一个琐碎的请求。但是,如果该字段是持久的且当前未加载,则它将变为数据库请求。其他请求(例如通过对话框查询数据库)显然运行时间较长,应由后台线程执行。如果单个 Session 用于这两个任务,则线程处理将成为一个问题。
同样,当使用大多数 Java Web 框架运行 Ajax 应用程序时,相同的服务器会话状态用于来自同一网页的所有请求。如果运行时间较长的请求仍在执行,而来自同一页面的另一个请求到达,则将使用多个线程来服务这些请求。如果 Web 会话有一个单持久化 Session 实例,则可能会发生多线程冲突。
在应用程序中处理多线程并非易事。一些考虑因素包括是否允许所有线程查看领域模型的相同视图,以及是立即提交所有更改还是将提交推迟到发生某些用户操作(例如,按下“保存”按钮)为止。这些决策会影响应用程序的事务模型。
为了允许应用程序所需的灵活性,某些持久化提供程序允许程序员指定 Session 实例的多线程级别。其他提供程序则要求应用程序显式地为每个线程获取一个 Session。
使用 JDBC 或 ODBC 等底层 API 使应用程序程序员可以直接控制事务。使用这些 API,事务通过 API 启动,然后提交或回滚。没有乐观事务的概念。
使用 ORM,事务的粒度更粗,并且由与 Session 关联的 Transaction 实例上的 API 控制。乐观事务是模型的一部分,并且在领域模型中包含版本字段,这些字段对应于数据库中的版本列。应用程序指定乐观事务或悲观事务,持久化提供程序会自动管理乐观事务的版本列。
一些持久化标准仅支持乐观并发,并为锁定定义了最少量的 API;其他标准同时支持乐观和悲观并发模型;还有一些产品支持更细粒度的策略,以便在检索时预先锁定类的实例。对于某些高并发应用程序,需要这些更细粒度的并发模型,在这些应用程序中,对持久化实例的任何访问都需要激进的锁定才能实现高吞吐量。
当两个或多个事务持有其他事务需要的资源上的锁时,就会发生死锁,并且在不取消其他事务持有的锁的情况下,任何事务都无法完成。
诸如 JDBC 和 ODBC 之类的底层 API 使程序员能够完全控制锁请求,但 ORM 持久化提供程序会基于更粗粒度的操作(例如提交或回滚)自动生成和执行数据库请求。数据库请求的操作顺序可以适应外键约束和 SQL 命令的批处理。
如果应用程序容易发生死锁,持久化提供程序通常可以通过让应用程序控制不受外键约束的数据库操作的执行顺序来避免死锁。 这要求持久化提供程序能够根据对领域对象模型所做的更改顺序,确定应该应用数据库操作的顺序。
ORM 是一种使用面向对象范例提供对关系数据访问的技术。ORM 实现包括一种用于在对象和关系域之间进行映射的语言,以及用于存储、查询和修改应用程序对象的 API。有几种标准具有商业和开源实现,以及一些非标准产品。
定义映射的难易程度取决于应用程序的需求、其领域模型、遗留关系模型或对象模型的存在以及这些模型的复杂性。ORM 可以显著提高程序员的生产力、应用程序质量和可维护性。实现这一目标的最重要方法之一是通过关注点分离:将领域对象模型的行为与从数据库访问数据分开。使用标准 API 可以使实现的选择成为一个较晚的决定,从而为评估替代映射和数据库技术提供更多时间。
CRAIG RUSSELL 是 Sun Microsystems 的高级工程师。他是 Apache 软件基金会的成员、Apache OpenJPA 项目管理委员会的主席,以及负责将项目引入 Apache 的 Apache Incubator 项目的成员。他是 Java 数据对象 (JSR 12 和 243) 的规范负责人,并领导其 API 和技术兼容性工具包的实施团队。
最初发表于 Queue 杂志第 6 卷,第 3 期—
在 数字图书馆 中评论这篇文章
Oren Eini - 实现 LINQ 提供程序的痛苦
我记得坐在座位边缘观看 2005 年 PDC(专业开发者大会)的视频,这些视频首次展示了 LINQ(语言集成查询)。我想要 LINQ:它几乎提供了我可以期望的一切,使数据处理变得容易。将查询构建到语言中的动力非常简单;它是一种一直都在使用的东西;统一查询模型的承诺已经足够好了,即使在您添加所有添加到我们的语言特性之前也是如此。能够用 C# 编写代码并让数据库神奇地理解我在做什么?