首页 新闻 论坛 群组 Blog 文档 下载 读书 Tag 网摘 搜索 开源 FAQ 第二书店 博文视点 程序员
频道: 研发 数据库 中间件 信息化 视频 .NET Java 游戏 移动 服务: 人才 外包 培训
    图书品种:235680
       
热门搜索: ASP.NET Ajax Spring Hibernate Java

1.2  范式不匹配

对象/关系范式不匹配可以分为几个部分,我们将依次进行分析。让我们从独立于问题的一个简单示例开始探讨。构建它时,就会开始看到不匹配问题的出现。

假设你必须设计和实现一个在线电子商务应用程序。在这个应用程序中,需要用一个类表示一个系统用户的信息,用另一个类表示用户的账单的明细,如图1-1所示。

图1-1  User和BillingDetails实体的一个简单的UML类图

在这幅图中,可以看到一个User有许多BillingDetails。可以从两个方向遍历类之间的关系。表示这些实体的类可能极为简单:

注意,我们只关注与持久化有关的实体状态,因此省略了属性访问方法和业务方法的实现(例如getUsername()或者billAuction())。

可以很容易地给这个例子想出一个好的SQL Schema设计:

这两个实体之间的关系表示为外键(foreign key),如BILLING_DETAILS中的USERNAME。对于这个简单的领域模型,对象/关系的不匹配几乎不明显;编写JDBC代码来插入、更新和删除关于用户和账单明细的信息很简明。

现在,看一下当我们考虑更现实一点的事情时会发生什么。当给应用程序添加更多的实体和实体关系时,范式不匹配的问题就显而易见了。

目前这个实现的最明显问题是把地址设计成了一个简单的String值。在大部分系统中,必需分别保存街道、城市、省/州、国家和邮政编码的信息。当然,可以直接给User类添加这些属性,但是由于系统中的其他类极有可能也含有地址信息,这使得创建一个单独的Address类变得更有意义了。更新过的模型如图1-2所示。

图1-2 User有一个Address

也应该添加一个ADDRESS表吗?没有必要。通常只需把地址信息保存在USERS表中单独的列里。这种设计可能执行得更好,因为如果要在单个查询中获取用户和地址,就不需要表联结。最好的解决方案可能是创建一个用户定义的SQL数据类型来表示地址,在USERS表中使用新类型的单个列,而不用几个新列。

基本上,可以选择添加(一种新SQL数据类型的)几个列或者单个列。这明显是个粒度(granularity)问题。

1.2.1  粒度问题

粒度是指你正在使用的类型的相对大小。

回到我们的示例上。给数据库目录添加一个新的数据类型,在单个列中保存Address的Java实例,听起来像是最好的办法。Java中的一个新Address类型(类)和一个新的ADDRESS的SQL数据类型应该保证互用性。然而,如果检查当今的SQL数据库管理系统对UDT(User-defined DataType,用户定义的数据类型)的支持,就会发现各种各样的问题。

对传统SQL而言,UDT支持是许多所谓的对象—关系扩展(object-relational extension)之一。单独这个术语很费解,因为它意味着数据库管理系统有(或者假定要支持)一个完善的数据类型系统——有点像如果有人告诉你一个系统能够以关系的方式处理数据,你就信以为真。不幸的是,UDT支持是大部分SQL数据库管理系统的一个有点模糊的特性,当然不能在不同的系统之间移植。此外,SQL标准支持用户定义的数据类型,但是少得可怜。

这种局限性不是关系型数据模型的错。你可以将没有把如此重要的一项功能标准化的这一失误,归咎于20世纪90年代中期供应商之间的对象—关系型数据库之战。如今,大部分开发人员都接受类型系统有限的SQL产品——没有要询问的问题。然而,即使SQL数据库管理系统中使用了完善的UDT系统,我们仍然可能重复类型声明,在Java中编写新类型,并在SQL中再次重复。试图给Java空间找到一种解决方案,例如SQLJ,不幸的是还没有成功。

基于这些或者任何其他原因,在SQL数据库内部使用UDT或者Java类型,都还不是目前行业的一项共通的实践,你不可能遇到一个广泛使用UDT的遗留Schema。因此我们不能并且也不会在单个有着与Java层相同的数据类型的新列中保存新Address类的实例。

对于这个问题,务实的解决方案是使用几列内建的供应商定义的SQL类型(例如布尔、数值和字符串数据类型)。USERS表通常定义如下:

领域模型中的类按照各种不同的粒度等级排列起来——从粗粒度实体类如User,到细粒度类如Address,直到简单String值的属性如zipcode。相比之下,在SQL数据库等级中只有两种粒度等级是可见的:表(如USERS)和列(如ADDRESS_ZIPCODE)。

许多简单的持久化机制无法识别这种不匹配,因此不再强制对象模型上更不灵活的SQL表示。我们已经看到无数User类中包含名为zipcode的属性!

这证实了粒度问题并非特别难以解决。如果不是因为它在许多现有系统中很明显的话,我们甚至可能不讨论它。4.4节描述了这个问题的解决方案。

当考虑依赖继承(inheritance)的领域模型时,一种更复杂和更值得关注的问题出现了。继承是面向对象设计的一个特性,可用来以一种更新更有趣的方式给电子商务应用程序中的用户开账单。

1.2.2  子类型问题

在Java中,用超类(superclass)和子类(subclass)实现类型继承。为了举例说明为什么这会出现不匹配的问题,我们来添加到电子商务应用程序中,以便现在不仅可以接受银行账户的账单,还可以接受信用卡和借记卡。在该模型中体现这种变化的最自然方式是给BillingDetails类使用继承。

我们可能有一个抽象的BillingDetails超类,以及几个具体的子类:CreditCard、BankAccount等。每个子类都定义了略微不同的数据(和处理这些数据的完全不同的功能)。图1-3中的UML类图说明了这个模型。

图1-3  为不同的记账策略使用继承

SQL或许应该包括对超表(supertable)和子表(subtable)的标准支持。这实际上支持我们创建从父表中继承某些列的表。然而,这样的一种特性备受置疑,因为它会在基表(base table)中引进一个新的概念:虚拟列(virtual column)。传统上,我们期待虚拟列仅存在于虚拟表(也称作视图,view)中。此外,理论上来说,应用在Java中的继承是类型继承(type inheritance)。表不是一种类型,因此超表和子表的概念令人置疑。无论是哪种情况,此处我们都可以走个捷径,并且发现SQL数据库产品一般不实现类型或者表继承,即使它们真的实现了,也不遵循标准的语法,并且通常会使你遭遇到数据完整性的问题(对可更新视图的有限完整性规则)。

5.1节将讨论ORM解决方案(例如Hibernate)如何解决把一个类层次结构持久化到一个或者多个数据库表的问题。这个问题现在社区中已经被很好地理解了,并且大部分解决方案支持几乎相同的功能。

但是我们还没有结束对继承的讨论。一旦把继承引进到模型中,就有了多态(polymorphism)的可能。

User类和BillingDetails超类有一个关联。这是个多态关联(polymorphic association)。运行时,User对象可以引用BillingDetails任何子类的一个实例。类似地,我们想要能够编写引用BillingDetails类的多态查询(polymorphic query),并让查询返回它子类的实例。

SQL数据库也缺乏一种明显的表示多态关联的方式(或者至少是一种标准化的方式)。一个外键约束精确地引用一张目标表(target table);定义一个引用多表的外键不容易。必须编写一个程序化的约束来加强这种完整性规则。

子类型的这种不匹配的结果是,模型中的继承结构必须在一个不提供继承策略的SQL数据库中被持久化。庆幸的是,第5章介绍的继承映射解决方案中,有3种被设计为适应多态关联的表示法和多态查询的有效执行。

对象/关系不匹配问题的另一个方面是对象同一性(object identity)。你可能注意到了我们把USERNAME定义为USERS表的主键。这是个好办法吗?我们如何处理Java中的同一对象?

1.2.3  同一性问题

虽然对象同一性问题一开始可能并不明显,但我们经常在日渐增长和扩展的电子商务系统中遇到,例如当需要检查两个对象是否为同一对象的时候。解决这个问题有3种方法:2种使用Java,1种使用SQL数据库。不出所料,它们只要一点点帮助就可以协同工作了。

Java对象定义两个不同的同一性(sameness)概念:

l    对象同一性(粗略等同于内存位置,用a == b检查)

l    等同性,通过equals()方法(也称作值等同,equality by value)的实现来确定。

另一方面,数据库行的同一性用主键值表达。如9.2节所述,equals()和==都不会必然等于主键值。几个不恒等的对象同时表示数据库的同一行很常见,例如,在并发运行的应用程序线程中。此外,给持久化类正确实现equals()包含了一些微妙的困难。

让我们用一个示例来讨论另一个有关数据库同一性的问题。在USERS表的定义中,我们使用了USERNAME作为主键。不幸的是,这个决定使得用户名变得很难改变;我们不仅要更新USERS中的USERNAME列,还要更新BILLING_DETAILS中的外键列。为解决这个问题,本书后面推荐使用代理键(surrogate key),每当你无法找到一个好的自然键(natural key)时(我们也将讨论使得一个键成为好键的因素)。代理键列是个对用户没有意义的主键列;换句话说,它是个不呈现给用户的键,而只用作软件系统内部的数据识别。例如,可以把表定义变成这样:

USER_ID和BILLING_DETAILS_ID列包含系统生成的值。这些列是纯粹为了方便数据模型而引入的,因此它们在领域模型中该如何(如果要)表示?4.2节将讨论这个问题,并且找到一种使用ORM的解决方案。

在持久化的上下文中,同一性与系统如何处理高速缓存和事务密切相关。不同的持久化方案选择不同的策略,这已经成了混乱的一个方面。我们将在第10章和第13章涵盖所有这些令人关注的话题以及它们是如何相关的。

目前为止,我们设计的构架电子商务应用程序已经识别了映射粒度、子类型和对象同一性的不匹配问题。我们差不多准备转向应用程序的其他部分了,但是首先需要讨论一下关联(association)这个重要的概念:类之间的关系如何被映射和处理。数据库中的外键就是你所需要的一切吗?

1.2.4  与关联相关的问题

在领域模型中,关联表示实体之间的关系。User、Address和BillingDetails类都是相互关联的;但是不像Address、BillingDetails那么独立。BillingDetails实例则保存在它们自己的表中。关联映射和实体关联的管理在任何对象持久化解决方案中都是重要概念。

面向对象的语言利用对象引用(object reference)表示关联;但是在关系领域中,关联则被表示为外键(foreign key)列,带有几个键值的复本(和一个保证完整性的约束)。这两种表示法之间有着本质的区别。

对象引用具有固有的方向性;关联是从一个对象到另一个对象。它们是指针。如果对象之间的关联应该在两个方向导航,就必须定义两次关联,在每个关联的类中定义一次。你已经在领域模型类中见过:

另一方面,外键关联不是生来就有方向性。导航(navigation)对于关系型数据模型没有任何意义,因为可以用表联结(table join)和投影创建任意的数据关联。具有挑战性的是,把一个完全开放的数据模型——独立于使用数据的应用程序——桥接到一个应用程序依赖的导航模型,这个特定的应用程序需要关联的一个约束视图。

不可能只看Java类就确定一个单向关联的多样性。Java关联可以有多对多(many-to-many)的多样性。例如,类可以看起来像下面这样:

另一方面,表关联始终是一对多(one-to-many)或者一对一(one-to-one)。通过查看外键的定义,你立即就会知道多样性。以下是给一对多关联(或者,如果从另一个方向看,是多对一的)在BILLING_DETAILS表中的一个外键声明:

这些是一对一的关联:

如果你想在一个关系数据库中表示一个多对多的关联,就必须引入一张新表,称作链接表(link table)。这个表不会出现在领域模型中。对于我们的例子来说,如果认为用户和账单信息之间的关系为多对多,链接表就可以定义如下:

第6章和第7章将详细讨论关联和集合映射。

目前为止,我们考虑的主要是结构性的问题。通过考虑系统的一个纯静态视图就会明白。也许对象持久化中最困难的问题就是动态的(dynamic)问题。它涉及关联,在1.1.4节中介绍对象网络导航(object network navigation)和表联结之间的区别时,就已经提示了这一点。我们来更深入地探讨这个重要的不匹配问题。

1.2.5  数据导航的问题

在Java和在关系数据库中访问数据的方式有着根本的区别。在Java中,当你访问用户的账单信息时,调用aUser.getBillingDetails().getAccountNumber()或者类似的东西。这是访问面向对象的数据最自然的方式,通常称作遍历对象网络。跟着实例之间的指针,从一个对象导航到另一个对象。不幸的是,这并不是从SQL数据库中获取对象的有效方法。

为了提高数据访问代码性能,你能做的最重要的事情就是将请求数据库的次数减到最少。最明显的做法是将SQL查询的次数减到最少。(当然,第二步还有其他更复杂的方法。)

因此,使用SQL有效地访问关系型数据通常需要在有关的表之间使用联结。获取数据时联结中包括的表数目决定了能够在内存中遍历对象网络的深度。例如,如果需要获取一个User,而对用户的账单明细不感兴趣,可以编写这个简单的查询:

另一方面,如果需要获取一个User,随后访问每个关联的BillingDetails实例(比如,列出用户的所有信用卡),可以编写一个不同的查询:

如你所见,获取初始User时,要有效使用联结就要知道你计划访问对象网络的哪个部分——在你开始遍历对象网络之前!

另一方面,只有当对象被初次访问时,所有对象持久化解决方案才提供抓取关联对象的数据的功能。然而,这种渐进风格(piecemeal)的数据访问在关系数据库的上下文中效率很低,因为它要给每个节点或者每个被访问的对象网络的集合执行一条语句。这就是可怕的n+1查询问题(n+1 select problem)。

在Java和关系数据库中访问对象的这种不匹配,可能是Java应用程序中性能问题的一个最普遍的根源。在太多的选择和太大的选择之间有个自然压力,它获取不需要的信息到内存中。然而,虽然已经有无数的图书和杂志文章建议我们对字符串拼接使用StringBuffer,但是似乎不可能找到任何有关避免n+1查询问题的建议。幸运的是,Hibernate提供了从数据库中有效地、透明地抓取对象网络到访问它们的应用程序中的成熟特性。第13章~第15章将讨论这些特性。

1.2.6  不匹配的代价

现在已经有了相当一些对象/关系不匹配问题,你可能凭经验就知道,找到这些解决方案的代价不菲(时间和精力)。这种代价通常被低估了,这是许多软件项目失败的主要原因。依据我们的经验(也得到受访开发人员的一致确认),有30%的Java应用程序代码编写是用来处理乏味的SQL/JDBC和手工桥接对象/关系范式的不匹配。尽管付出了这么多努力,最终的结果仍然不尽如人意。我们曾经见过由于数据库抽象层的复杂性和低灵活性而几乎垮掉的项目。我们也见到Java开发人员(和DBA)在必须给项目做出有关持久化策略的设计决定时很快失去了信心。

主要的成本之一在于模型化方面。关系和领域模型都必须包含相同的业务实体,但是一位面向对象的纯化论者给这些实体建模的方法,与一位经验丰富的关系型数据建模者给出的不同。这个问题通常的解决方案是扭曲领域模型和被实现的类,直到它们与SQL数据库Schema相匹配。(遵循数据独立的原则,必定是个安全的长久之计。)

这可能会成功,但要以失去面向对象的一些优势为代价。记住,关系模型以关系理论为基础。面向对象则没有这样严格的数学定义或者理论主体,因此我们无法用数学来解释应该如何为这两种范式建立起某种关系——没有优雅的转化等着被发现。(放弃Java和SQL从头开始的做法并不算优雅。)

领域模型不匹配并不是低灵活性和导致更高成本的低生产力的唯一根源。更深层次的原因是JDBC API本身。JDBC和SQL提供了一个面向语句(statement-oriented,即面向命令,command- oriented)的方法,把数据从SQL数据库移进移出。如果要查询或者操作数据,涉及的表和列至少必须被指定3次(插入,更新,选择),这增加了设计和实现所需要的时间。每个SQL数据库管理系统的独特方言并没有改善这种情形。

为了巩固你对对象持久化的理解,在探讨可能的解决方案之前,需要讨论应用程序架构(application architecture)和持久层(persistence layer)在典型应用程序设计中的作用。

查看所有评论(0)条】

最近评论



正在载入评论列表...
热点评论