尽管 RDBMS 使用 SQL 作为其查询和检索数据的主要机制,但是 OODBMS 可以使用一些不同的机制。在本系列的第二期文章中,Ted Neward 将介绍一些新方法,包括 Query by Example 以及定制只有 OODBMS 才具有的机制。正如他解释的一样,有些替代方法比 SQL 本身更易于使用。
在 本系列的第一篇文章 中,我讨论了 RDBMS 作为 Java™ 对象存储解决方案的不足之处。正如我所说的,在当今的面向对象世界里,与关系数据库相比,db4o 这样的对象数据库可以为面向对象开发人员提供更多的功能。
|
在本文 及以后的文章中,我将继续介绍对象数据库。我将使用示例来演示这种存储系统的强大之处,它尽可能实现与面向对象编程语言中(本例中为 Java 编程语言)使用的实体形式相同。特别是,我将介绍用于检索、修改并将对象重新存储到 db4o 的各种可用机制。正如您将了解的一样,当您从 SQL 的限制解脱出来后,会对自己能够完成这么多的事情而感到吃惊。
如果您还没有下载 db4o,可能希望 立即下载 。您需要使用它来编译示例。
Query by Example(QBE)是一种数据库查询语言,它允许您通过设计模板(对其进行比较)来创建查询,而不是通过使用谓词条件的语言(如 SQL)。上一次我使用了 db4o 的 QBE 引擎演示了数据检索,这里将快速回顾一下。首先看一下这个绝对简单的数据库。它由一种类型组成,清单 1 列出了其定义:
package com.tedneward.model; public class Person { public Person() { } public Person(String firstName, String lastName, int age) { this.firstName = firstName; this.lastName = lastName; this.age = age; } public String getFirstName() { return firstName; } public void setFirstName(String value) { firstName = value; } public String getLastName() { return lastName; } public void setLastName(String value) { lastName = value; } public int getAge() { return age; } public void setAge(int value) { age = value; } public String toString() { return "[Person: " + "firstName = " + firstName + " " + "lastName = " + lastName + " " + "age = " + age + "]"; } public boolean equals(Object rhs) { if (rhs == this) return true; if (!(rhs instanceof Person)) return false; Person other = (Person)rhs; return (this.firstName.equals(other.firstName) && this.lastName.equals(other.lastName) && this.age == other.age); } private String firstName; private String lastName; private int age; } |
与 POJO 类似,Person
并不是一个复杂的类。它由三个字段和一些基本的支持类似 POJO 行为的方法组成,即 toString()
和 equals()
。(阅读过 Joshua Bloch 的 Effective Java 的读者将注意到我忽略了 hashCode()
实现,很明显这违背了 Rule 8。作者经常使用的典型说法就是,我将 hashCode()
留给 “读者进行练习”,这通常意味着作者不想解释或认为没有必要提供手头的示例。我同样将它留给读者作为练习,请自行判断我们这里的练习属于哪种情况。
在清单 2 中,我创建了 6 个对象,将它们放入了一个文件中,然后使用 QBE 调用名字匹配 “Brian” 模式的两个对象。这种查询使用原型对象(被传入到 get()
调用的对象)来确定对象是否匹配数据库查询,并返回匹配条件的对象的 ObjectSet
(实际上是一个集合)。
import com.db4o.*;
import com.tedneward.model.*;
public class Hellodb4o
{
public static void main(String[] args)
throws Exception
{
ObjectContainer db = null;
try
{
db = Db4o.openFile("persons.data");
Person brian = new Person("Brian", "Goetz", 39);
Person jason = new Person("Jason", "Hunter", 35);
Person clinton = new Person("Brian", "Sletten", 38);
Person david = new Person("David", "Geary", 55);
Person glenn = new Person("Glenn", "Vanderberg", 40);
Person neal = new Person("Neal", "Ford", 39);
db.set(brian);
db.set(jason);
db.set(clinton);
db.set(david);
db.set(glenn);
db.set(neal);
db.commit();
// Find all the Brians
ObjectSet brians = db.get(new Person("Brian", null, 0));
while (brians.hasNext())
System.out.println(brians.next());
}
finally
{
if (db != null)
db.close();
}
}
}
|
由于 QBE 使用原型对象作为其模板来搜索数据,关于其用法有一些简单的规则。当 db4o 针对给定的目标(概念正确但实际实现进行了简化)搜索所有 Person
类型的对象时,要确定数据存储中的某个对象是否满足条件,需要逐个比较字段值。如果原型中的字段值为 “null”,则该值匹配数据存储中的任何值;否则的话,必须精确地匹配值。对于原语类型,由于它不能真正具有 “null” 值,所以使用 0 作为通配符值。(这同样指出了 QBE 方法的一个缺点 —— 不能够有效地使用 0 作为搜索值)。应该指定多个字段值,所有字段的值都应该被数据库中的对象满足,从而使候选对象满足查询条件;实际上,这意味着将字段使用 “AND” 连接起来形成查询谓词。
在前面的示例中,查询所有 firstName
字段等于 “Brian” 的 Person
类型,并且有效地忽略 lastName
和 age
字段。在表中,这个调用基本上相当于 SQL 查询的 SELECT * FROM Person WHERE firstName = "Brian"
。(虽然如此,在尝试将 OODBMS 查询映射到 SQL 时还是要谨慎一些:这种类比并不完善,并且会对特定查询的性质和性能产生误解)。
查询返回的对象是一个 ObjectSet
,它类似于一个 JDBC ResultSet
(一个简单的对象容器)。使用由 ObjectSet
实现的 Iterator
接口遍历结果非常简单。使用 Person
的特定方法需要对 next()
返回的对象进行向下转换。
|
|
虽然简单的显示数据只和数据本身有关,大多数对象需要进行修改并重新存入数据库中。这可能是使用 OODBMS 最棘手的部分,因为对象数据库使用与关系数据库不同的一致性概念。实际上,这意味着在使用对象数据库时,必须更加谨慎地比较内存中的对象和存储中的对象。
清单 3 所示的简单示例演示了这种不同的一致性概念:
import com.db4o.*; import com.tedneward.model.*; public class Hellodb4o { public static void main(String[] args) throws Exception { ObjectContainer db = null; try { db = Db4o.openFile("persons.data"); Person brian = new Person("Brian", "Goetz", 39); Person jason = new Person("Jason", "Hunter", 35); Person clinton = new Person("Brian", "Sletten", 38); Person david = new Person("David", "Geary", 55); Person glenn = new Person("Glenn", "Vanderberg", 40); Person neal = new Person("Neal", "Ford", 39); db.set(brian); db.set(jason); db.set(clinton); db.set(david); db.set(glenn); db.set(neal); db.commit(); // Find all the Brians ObjectSet brians = db.get(new Person("Brian", null, 0)); while (brians.hasNext()) System.out.println(brians.next()); Person brian2 = new Person("Brian", "Goetz", 39); db.set(brian2); db.commit(); // Find all the Brians ObjectSet brians = db.get(new Person("Brian", null, 0)); while (brians.hasNext()) System.out.println(brians.next()); } finally { if (db != null) db.close(); } } } |
当运行清单 3 中的查询时,数据库将报告三个 Brian
,其中两个是 Brian Goetz。(如果 persons.data 文件已经存在于当前的目录,则会出现类似的结果 —— 创建的所有 Person
将被存储到 persons.data 文件,而查询将返回存储在其中的所有 Brian
)。
|
很明显,这里并不强制使用关于主键的旧规则,那么对象数据库如何处理惟一性概念?
当对象被存储到对象数据库中,将创建一个惟一键,称为 Object identifier 或 OID (其发音类似于 avoid 的最后一个音节),它惟一地标识对象。OID,和 C# 和 Java 编程中的 this 指针/引用类似,除非显式指定,否则则是隐式的。在 db4o 中,可以通过调用 db.ext().getID()
查找给定对象的 OID。(还可以使用 db.ext().getByID()
方法按照 OID 检索对象。调用该方法具有一些非常复杂的含义,不便在这里讨论,但是它仍然是一种方法)。
在实践中,所有这些意味着由开发人员判断是否一个对象曾经存在于系统中,通常在插入对象前通过查询该对象的容器实现,如清单 4 所示:
// ... as before ObjectContainer db = null; try { db = Db4o.openFile("persons.data"); ... // We want to add Brian Goetz to the database; is he already there? if (db.get(new Person("Brian", "Goetz", 0).hasNext() == false) { // Nope, no Brian Goetz here, go ahead and add him db.set(new Person("Brian", "Goetz", 39)); db.commit(); } } |
在这个特定例子中,假设系统中 Person
的惟一性是其姓名的组合。因此,当在数据库中搜索 Brian
时,只需要对 Person
实例查找这些属性。(或许几年前已经添加过 Brain —— 当时他还不到 39 岁。)
如果希望修改数据库中的对象,那么从容器中检索对象,使用某种方式进行修改,然后将其存储回数据库即可,如图 5 所示:
// ... as before ObjectContainer db = null; try { db = Db4o.openFile("persons.data"); ... // Happy Birthday, David Geary! if ((ObjectSet set = db.get(new Person("David", "Geary", 0))).hasNext()) { Person davidG = (Person)set.next(); davidG.setAge(davidG.getAge() + 1); db.set(davidG); db.commit(); } else throw new MissingPersonsException( "David Geary doesn't seem to be in the database"); } |
db4o 容器在这里并没有出现一致性问题,这是因为有问题的对象已经被标识为来自数据库的对象,即它的 OID 已经被存储在 db4o bookkeeping 基础设施中。相应地,当调用 set
时,db4o 将会更新现有的对象而不是插入新对象。
|
|
特 定于应用程序主键的概念值得经常注意,即使它没有继承 QBE 的概念。您所需要的是使用一种实用方法简化基于标识的搜索。这一节将展示基于 Reflection API 用法的解决方案,我们会将正确的值放在正确的字段,此外,还介绍了针对不同选择和外观对解决方案进行调优的方法。
让我们从一个基本前提开始:我具有一个数据库,其中包含我希望根据一组具有特定值的字段查询的类型(Person
)。在这种方法中,我对 Class
使用了 Reflection API,创建了该类型的新实例(调用其默认构造方法)。然后遍历具有这些字段的 String 数组,取回 Class
中的每个 Field
对象。随后,遍历对应于每个字段值的对象数组,然后调用 Field.set()
将该值放入我的模板对象中。
完成这些操作后,对 db4o 数据库调用 get()
并查看返回的 ObjectSet
是否包含任何对象。这给出了一个基本方法的大概轮廓,如清单 6 所示:
import java.lang.reflect.*; import com.db4o.*; public class Util { public static boolean identitySearch(ObjectContainer db, Class type, String[] fields, Object[] values) throws InstantiationException, IllegalAccessException, NoSuchFieldException { // Create an instance of our type Object template = type.newInstance(); // Populate its fields with the passed-in template values for (int i=0; i<fields.length; i++) { Field f = type.getDeclaredField(fields[i]); if (f == null) throw new IllegalArgumentException("Field " + fields[i] + " not found on type " + type); if (Modifier.isStatic(f.getModifiers())) throw new IllegalArgumentException("Field " + fields[i] + " is a static field and cannot be used in a QBE query"); f.setAccessible(true); f.set(template, values[i]); } // Do the query ObjectSet set = db.get(template); if (set.hasNext()) return true; else return false; } } |
很明显,要对这种方法进行大量的调优以进行尝试,例如捕获所有的异常类型并将它们作为运行时异常重新抛出,或者返回 ObjectSet
本身(而非 true /false ),甚至返回包含 ObjectSet
内容的数组对象(ObjectSet
的内容使得查看返回数组的长度非常简单)。然而,可以从清单 7 中很明显地看到,这种用法并没有比基本的 QBE 版本简单多少。
// Is Brian already in the database? if (Util.identitySearch( db, Person.class, {"firstName", "lastName"}, {"Brian", "Goetz"}) == false) { db.set(new Person("Brian", "Goetz", 39)); db.commit(); } |
事实上,对于存储的类本身,这种实用方法的实用性 开始变得明显,如清单 8 所示:
public class Person { // ... as before public static boolean exists(ObjectContainer db, Person instance) { return (Util.identitySearch(db, Person.class, {"firstName", "lastName"}, {instance.getFirstName(), instance.getLastName()}); } } |
或者,您可以调整该方法来返回找到的实例,这样 Person
实例使它的 OID 正确地关联,等等。关键要记住可以在 db4o 基础架构之上构建方便的方法,从而使 db4o 更加易于使用。
注意:使用 db4o SODA 查询 API 对存储在磁盘的底层对象执行这类查询是一种更有效的方法,但这稍微超出了本文讨论的范围,所以我将在以后讨论这些内容。
|
|
目前为止,您已经了解了如何查询单个的或者满足特定条件的对象。尽管这使得查询非常简单,但同时也有一些限制:比如,如果需要检索所有姓氏以 G 开头的 Person
,或者所有年龄大于 21 的 Person
,该怎么办?QBE 方法对于这类查询无能为力,因为 QBE 只能执行相等匹配,而无法进行比较查询。
通常,即使是中等复杂程度的比较对于 OODBMS 也是一种弱点,而这正是关系模型和 SQL 的长处。在 SQL 中执行比较查询非常简单,但是在 OODBMS 中执行同样的查询却需要一些不是很吸引人的方法:
很 明显,上面所述的第一种方法只能用于最普通的数据库,因为它对能够在实际中使用的数据库的规模有很明显的上限。取回一百万个对象不成问题,甚至可以很轻松 地处理最困难的硬件,尤其是当跨越网络连接时。(这不是对 OODBMS 的控告,顺便提一下,通过网络连接获取一百万行可能仍在 RDBMS 服务器能力之内,但是这将摧毁它所在的网络。)
第二种方法破坏了 QBE 方法的简单性,并且导致了如清单 9 所示的糟糕代码:
Query q = new Query(); q.setClass(Person.class); q.setPredicate(new Predicate( new And( new Equals(new Field("firstName"), "David"), new GreaterThan(new Field("age"), 21) ))); q.Execute(); |
很容易看出,使用这种技术使得中等复杂的查询很快就变得不能工作,尤其是与 SQL 这类查询语言的简单性相比。
第三种方法是创建能够用来查询数据库对象模型的查询语言。过去,OODBMS 开发人员创建了一种标准的查询语言,对象查询语言(Object Query Language),或 OQL,这种语言类似于清单 10 显示的内容:
SELECT p FROM Person WHERE p.firstName = "David" AND p.age > 21 |
表面上看,OQL 非常类似于 SQL,因此它应该和 SQL 一样强大并且易于使用。OQL 的缺点就是它要求返回……什么?类似于 SQL 的语言要求返回列集(元组),与 SQL 相同,但是对象数据库不会以这种方式工作 —— 它希望返回对象,而不是随机集。尤其是在强类型语言中,如 C# 或 Java 编程语言,这些对象类型必须是先验 的,而与 SQL 那种基于集合的概念不同。
|
|
db4o 没有强制开发人员使用复杂的查询 API,也没有引入新的 “-QL” 之类的东西,它提供了一个名为原生查询 的工具,该工具功能强大且易用,如清单 11 所示。(db4o 的查询 API 可以 使用 SODA 形式,这种形式主要用于细粒度查询控制。然而,正如在第二篇看到的一样,SODA 通常只用于手动优化查询。
// ... as before ObjectContainer db = null; try { db = Db4o.openFile("persons.data"); ... // Who wants to get a beer? List<Person> drinkers = db.query(new Predicate<Person>() { public boolean match(Person candidate) { return person.getAge() > 21; } } for (Person drinker : drinkers) System.out.println("Here's your beer, " + person.getFirstName()); } |
之所以说查询是 “原生” 的,是因为它是使用编程语言本身编写的(本例为 Java 语言),而不是必须要转换为其他内容的任意语言。(Predicate API 的非通用版本可用于 Java 5 之前的版本,尽管它使用起来不是很简便。)
考虑一下这一点,您很可能想知道如何精确地实现这种特殊的方法。必须使用源预处理程序将源文件及其包含的查询转换为数据库引擎能够理解的内容(a la SQL/J 或其他嵌入的预处理程序),或者数据库将所有的 Person
对象发回给对全部集合执行谓词的客户机(换言之,正是早先拒绝使用的方法)
结果证明,db4o 并没有执行任何这些操作;相反,db4o 的基本原理采用有趣并创新的方法进行原生查询。db4o 系统将谓词发送到数据库,在运行时对 match()
的字节码执行字节码分析。如果字节码简单到可以理解的话,db4o 将该查询转换为 SODA 查询以提高效率,这种情况下不需要对传送到 match()
方法的所有对象进行实例化。使用这种方法,程序员可以继续使用他们觉得方便的语言编写查询,而查询本身可以被转换为数据库能够理解并有效执行的内容。(如 果愿意的话,可以称之为 “JQL”—— Java Query Language,不过请不要向 db4o 开发人员转述这个名字,这会给我带来麻烦)。
|
原生查询方法并不是完美的。比如,编写一个足够复杂的原生查询来超越字节码分析器是完全不可能的,因此需要最坏情况的执行模型。在这种最坏情况的场景中,db4o 必须实例化数据库中查询类型的每一个对象,并通过 match()
实现传送每个对象。可以预料到,这将有损查询性能,不过可以在需要的位置安装监听器来解决这一问题。
预见错误并进行优化,直觉并不总是够用,因为代码暗示的原因完全不同。比如,包含一个控制台打印语句(Java 代码中的 System.out.println
,或者 C# 中的 System.Console.WriteLine
)将使 db4o 的 .NET 版本中的优化器发生错误,而 Java 版本则能够对该语句优化。您不能够真正预见这种类型的变化(尽管可以通过经验了解这种变化),所以,最好让系统告诉您发生的错误,正如在极限编程中一样。
简单地对 ObjectContainer
本身注册一个监听器(Db4oQueryExecutionListener
),如果原生查询不能进行优化时将通知您,如清单 12 所示:
// ... as before ObjectContainer db = null; try { db = Db4o.openFile("persons.data"); db.ext().configure().diagnostic().addListener(new DiagnosticListener() { public void onDiagnostic(Diagnostic d) { if (d instanceof NativeQueryNotOptimized) { // could display information here, but for simplicity // let's just fail loudly throw new RuntimeException("Native query failed optimization!"); } } }); } |
很明显,只有在开发过程中这样做才是理想的 —— 在运行时最好将这个错误记录到 log4j 错误流中,或者记录到不会影响用户的类似内容中。
|
|
在 面向 Java 开发人员的 db4o 指南 的第二篇文章中,我使用 OODBMS 的一致性概念作为起点,解释了 db4o 如何存储和检索对象,并简单介绍了它的原生查询工具。
QBE 是进行简单查询的首选机制,因为它是一种更加易于使用的 API,但是它要求您的域对象允许任何或所有包含数据的字段被设置为 null,这将有悖于一些域规则。比如说,如果能够对 Person
对象执行姓和名字的查询将非常好。然而,在 QBE 查询中使用 Person
查询名字,要求姓氏可以是 null,这实际上意味着我们必须选择域约束或者查询能力,而这两者都不能被完全接受。
原生查询为执行复杂查询提供了一种功能强大的方法,而且不需要学习新的查询语言或使用复杂对象结构对谓词建 模。对于 db4o 原生查询不能够满足需要的情况,SODA API(对于任何对象系统,最初以独立的查询系统出现,而且仍然存在于 SourceForge 中)允许对查询进行最细致的调优,其代价就是破坏了简单性。
这种查询数据库方法的多面性可能让您备受挫折,它非常复杂,容易造成混淆,并且与 RDBMS 工作方式完全不同。事实上,这并不是问题:多数大型数据库将 SQL 文本转换为字节码格式(对这种格式进行分析和优化),执行存储在磁盘的数据,汇编回文本,然后返回。db4o 原生查询方法将编译放到了字节码之后,由 Java(或 C#)编译器来处理,因此保证了类型安全并对错误查询语法进行早期检测。(很不幸的是,JDBC 的访问 SQL 的方法丢失了类型安全,因为这是一个调用级别的接口,因此它限制只有在运行时才能检查字符串。对于任何 CLI 都是一样的,而不仅仅是 JDBC;ODBC 和 .NET 的 ADO.NET 也同样受此限制)。在数据库内部仍然执行了优化,但是并不是返回文本,而是返回真正的对象,以供使用。这与 SQL/Hibernate 或其他 ORM 方法形成了显著对比,Esther Dyson 对此做了很好的描述,如下所示:
利用表格存储对象,就像是将汽车开回家,然后拆成零件放进车库里,早晨可以再把汽车装配起来。但是人们不禁要问,这是不是泊车的最有效的方法呢。