重构 Java™ 代码远远比重构关系数据库简单,但幸运的是,对于对象数据库却并非如此。在本期的 面向 Java 开发人员的 db4o 指南 中,Ted Neward 介绍他喜欢的对象数据库的另一个优点:db4o 简化了重构,使之变得非常容易。
在 本系列的上一篇文章 中,我谈到了查询 RDBMS 与查询像 db4o 这样的对象数据库的不同之处。正如我所说的那样,与通常的关系数据库相比, db4o 可以提供更多的方法来进行查询,为您处理不同应用程序场景提供了更多选择。
这一次,我将继续这一主题 —— db4o 的众多选项 —— 看看 db4o 如何处理重构。自 6.1 版开始,db4o 能自动识别和处理三种不同类型的重构:添加字段、删除字段和添加一个类的新接口。我不会讨论所有这三种重构(我将着重介绍添加字段和更改类名),但我将介绍 db4o 中的重构最令人兴奋的内容 —— 将向后兼容和向前兼容引入到数据库变更管理中。
您将看到,db4o 能够静默地处理更新,并确保代码与磁盘的一致性,这大大减轻了重构系统中持久性存储的压力。这样的灵活性也使得 db4o 非常适合于测试驱动开发过程。
|
上个月 ,我谈到了使用原生和 QBE 样式的查询来查询 db4o。在上次讨论中,我建议运行示例代码的读者删除包含之前运行结果的已有数据库文件。这是为了避免由于一致性概念在 OODBMS 中与在关系理论中的不同而导致的 “怪异” 结果。
这 种变通办法对于我的例子是适用的,但是也提出了现实中存在的一个有趣的问题。当定义其中所存储的对象的代码发生改变时,OODBMS 会怎样?在一个 RDBMS 中,“存储” 与 “对象” 之间的联系很清晰:RDBMS 遵从在使用数据库之前执行的 DDL 语句所定义的一种关系模式。然后,Java 代码要么使用手写的 JDBC 处理代码将查询结果映射到 Java 对象,要么通过 Hibernate 之类的库或新的 Java Persistence API (JPA) “自动” 完成映射。不管通过何种方式,这种映射是显式的,每当发生重构时,都必须作出修改。
从理论上讲,理论与实践之间没有不同。但也只是从理论上才能这么讲。重构关系数据库和对象/关系映射文件应该 是简单的。但在实际中,只有当重构仅仅与 Java 代码有关时,RDBMS 重构才比较简单。在这种情况下,只需更改映射就可以完成重构。但是,如果更改发生在数据的关系存储本身上,那么就突然进入一个全新的、复杂的领域,这个专 题复杂到足够写一本书。(我的一个同事曾经描述到 “500 页数据库表、触发器和视图” 这样的一本书。) 可以说,由于现实中的 RDBMS 常常包含需要保留的数据,仅仅删除模式然后通过 DDL 语句重新构建模式不是 正确的选择。
现在我们知道,当定义其中的对象的 Java 代码发生改变时 RDBMS 会发生什么样的变化。(或者,至少我们知道 RDBMS 管理器会怎样,这是一个很头痛的问题。)现在我们来看看当代码发生改变时 db4o 数据库的反应。
|
|
如果您已经阅读了本系列中的前两篇文章,那么应该熟悉我的非常简单的数据库。目前,它由一种类型组成,即 Person
类型,该类型的定义包含在清单 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; } |
接下来,我填充数据库,如清单 2 所示:
import java.io.*; import java.lang.reflect.*; import com.db4o.*; import com.tedneward.model.*; // Version 1 public class BuildV1 { public static void main(String[] args) throws Exception { new File(".", "persons.data").delete(); ObjectContainer db = null; try { db = Db4o.openFile("persons.data"); Person brianG = new Person("Brian", "Goetz", 39); Person jason = new Person("Jason", "Hunter", 35); Person brianS = 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); Person clinton = new Person("Clinton", "Begin", 19); db.set(brianG); db.set(jason); db.set(brianS); db.set(david); db.set(glenn); db.set(neal); db.set(clinton); 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(); } } } |
注意,在清单 2 中开始的代码片段中,我显式地删除了文件 “persons.data”。这样做可以保证一开始就有整洁的记录。在 Build 应用程序将来的版本中,为了演示重构过程,我将保持 persons.data 文件不动。还需注意,Person
类型将来要发生改变(这将是我的重构的重点),所以请务必熟悉为每个例子存储和/或取出的版本。(请在 本文的源代码 中查看每个版本的 Person
中的注释,以及源代码树中的 Person.java.svn 文件。这些内容有助于理解例子。)
|
|
到目前为止,公司一直运营良好。公司数据库填满了 Person
,可以随时查询、存储和使用,基本上可以满足每个人的需求。但公司高层读了一本最近非常畅销的叫做 People have feelings too! 的管理书籍之后,决定修改该数据库,以包括 Person
的情绪(mood)。
在传统的对象/关系场景中,这意味着两个主要任务:一是重构代码(下面我会对此进行讨论),二是重构数据库模式,以包括反映 Person
情绪的新数据。现在,Scott Ambler 已经有了一些 RDBMS 重构方面的很好的资源(见 参考资料 ),但重构关系数据库远比重构 Java 代码复杂这一事实丝毫没有改变,而在必须保留已有生产数据的情况下,这一点特别明显。
然而,在 OODBMS 中,事情就变得简单多了,因为重构完全发生在代码(在这里就是 Java 代码)中。需要记住的是,在 OODBMS 中,代码就是 模式。因此可以说, OODBMS 提供一个 “单一数据源(single source of truth)”,而不是 O/R 世界,即数据被编码在两个不同的位置:数据库模式和对象模型。(两者发生冲突时哪一方 “胜出”,这是 Java 开发人员中争论得很多的一个话题。
我的第一步是创建一个新类型,用于定义要跟踪的所有情绪。使用一个 Java 5 枚举类型就很容易做到这一点,如清单 3 所示:
package com.tedneward.model; public enum Mood { HAPPY, CONTENT, BLAH, CRANKY, DEPRESSED, PSYCHOTIC, WRITING_AN_ARTICLE } |
第二步,我需要更改 Person
代码,添加一个字段和一些用于跟踪情绪的属性方法,如清单 4 所示:
清单 4. No, howYOUdoin'?(不,你好吗?)
package com.tedneward.model; // Person v2 public class Person { // ... as before, with appropriate modifications to public constructor and // toString() method public Mood getMood() { return mood; } public void setMood(Mood value) { mood = value; } private Mood mood; } |
在做其它事情之前,我们先来看看 db4o 对查找数据库中所有 Brian
的查询如何作出响应。换句话说,当数据库中没有存储 Mood
实例时,如果在数据库上运行一个基于已有的 Person
的查询,db4o 将如何作出响应(见清单 5)?
import com.db4o.*;
import com.tedneward.model.*;
// Version 2
public class ReadV2
{
public static void main(String[] args)
throws Exception
{
// Note the absence of the File.delete() call
ObjectContainer db = null;
try
{
db = Db4o.openFile("persons.data");
// Find all the Brians
ObjectSet brians = db.get(new Person("Brian", null, 0, null));
while (brians.hasNext())
System.out.println(brians.next());
}
finally
{
if (db != null)
db.close();
}
}
}
|
结果有些令人吃惊,如清单 6 所示:
[Person: firstName = Brian lastName = Sletten age = 38 mood = null] [Person: firstName = Brian lastName = Goetz age = 39 mood = null] |
在 Person
的两个定义(一个在磁盘上,另一个在代码中)并不一致的事实面前,db4o 不但没有 卡壳,而且更进了一步:它查看磁盘上的数据,确定那里的 Person
实例没有 mood 字段,并静默地用默认值 null 替代。(顺便说一句,在这种情况下,Java Object Serialization API 也是这样做的。)
这 里最重要的一点是,db4o 静默地处理它看到的磁盘上的数据与类型定义之间的不匹配。这成为贯穿 db4o 重构行为的永恒主题:db4o 尽可能静默地处理版本失配。它或者扩展磁盘上的元素以包括添加的字段,或者,如果给定 JVM 中使用的类定义中不存在这些字段,则忽略它们。
|
|
db4o 对磁盘上缺失的或不必要的字段进行某种调整,这一思想需要解释一下,所以让我们看看当更新磁盘上的数据以包括情绪时会出现什么情况,如清单 7 所示:
import com.db4o.*; import com.tedneward.model.*; // Version 2 public class BuildV2 { public static void main(String[] args) throws Exception { ObjectContainer db = null; try { db = Db4o.openFile("persons.data"); // Find all the Persons, and give them moods ObjectSet people = db.get(Person.class); while (people.hasNext()) { Person person = (Person)people.next(); System.out.print("Setting " + person.getFirstName() + "'s mood "); int moodVal = (int)(Math.random() * Mood.values().length); person.setMood(Mood.values()[moodVal]); System.out.println("to " + person.getMood()); db.set(person); } db.commit(); } finally { if (db != null) db.close(); } } } |
在清单 7 中,我发现数据库中的所有 Person
,并随机地为他们赋予 Mood
。在更现实的应用程序中,我会使用一组基准数据,而不是随即选择数据,但对于这个例子而言这样做也行。运行上述代码后产生清单 8 所示的输出:
Setting Brian's mood to BLAH Setting David's mood to WRITING_AN_ARTICLE Setting Brian's mood to CONTENT Setting Jason's mood to PSYCHOTIC Setting Glenn's mood to BLAH Setting Neal's mood to HAPPY Setting Clinton's mood to DEPRESSED |
可以通过再次运行 ReadV2 验证该输出。最好是再运行一下初始的查询版本 ReadV1 (该版本看上去很像 ReadV2,只是它是在 V1 版本的 Person
的基础上编译的)。 运行之后,产生如下输出:
[Person: firstName = Brian lastName = Sletten age = 38] [Person: firstName = Brian lastName = Goetz age = 39] |
对于清单 9 中的输出,值得注意的是,与我将 Mood
扩展添加到 Person
类(在 清单 6 中)之前 db4o 的输出相比,这里的输出并无不同 —— 这意味着 db4o 是同时向后兼容和向前兼容的。
|
|
假设要更改已有类中一个字段的类型,例如将 Person
的 age 从整型改为短整型。(毕竟,通常没有人能活过 32,000 岁 —— 而且我相信,即使真的 有那么长寿的人,仍然可以重构代码,将该字段改回整型。)假定两种类型在本质上是类似的,就像 int 与 short,或 float 与 double,db4o 只是静默地处理更改 —— 同样是或多或少仿效 Java Object Serialization API。这种操作的缺点是,db4o 可能会意外地将一个值截尾。只有当一个值 “转换为范围更小的类型” 时,即这个值超出了新类型允许的范围,例如试图从 long 类型转换为 int 类型,才会发生这样的情况。货物出门概不退货,买主需自行小心 —— 在开发期间或原型开发期间,务必进行彻底的单元测试。
|
实际 上,db4o 向后兼容的妙法值得解释一下。基本上,当 db4o 看到新类型的字段时,就会在磁盘上创建一个新字段,该字段有相同的名称,但是具有新的类型,就好像它是添加到类中的另一个新字段一样。这还意味着,旧的值 仍然保留在旧类型的字段中。因此,通过将字段重构回初始值,总可以 “回调” 旧值,取决于观察问题的角度,这可以说是一个特性,也可以说是一个 bug。
注意,对类中方法的更改与 db4o 无关,因为它不将方法或方法实现作为存储的对象数据的一部分,对于构造函数的重构也是如此。只有字段和类名本身(接下来会进行讨论)对于 db4o 才是重要的。
|
|
在某些情况下,需要发生的重构可能更剧烈一些,例如整个更改一个类的名称(可以是类名,也可以是类所在的包的名称)。像这样的更改对于 db4o 是比较大的更改,因为它需要根据 classname
来存储对象。例如,当 db4o 查找 Person
实例时,它在标有名称 com.tedneward.model.Person
的块的特定区域中进行查找。因此,改变名称会使 db4o 不知所措:它不能魔术般地推断 com.tedneward.model.Person
现在就是 com.tedneward.persons.model.Individual
。幸运的是,有两种方法可以教会 db4o 如何管理这样的转换。
使 db4o 适应这样剧烈的更改的一种方法是编写自己的重构工具,使用 db4o Refactoring API 打开已有的数据文件,并更改在磁盘上的名称。可以通过一组简单的调用做到这一点,如清单 10 所示:
清单 10. 从 Person 重构为 Individual
import com.db4o.*; import com.db4o.config.*; // ... Db4o.configure().objectClass("com.tedneward.model.Person") .rename("com.tedneward.persons.model.Individual"); |
注意,清单 10 中的代码使用 db4o Configuration API 获得一个配置对象,该配置对象被用作对 db4o 的大多数选项的 “元控制(meta-control)” —— 在运行时,您将使用这个 API 而不是命令行标志或配置文件来设置特定的设置(虽然您完全可以创建自己的命令行标志或配置文件来驱动 Configuration API 调用)。然后,使用 Configuration
对象获得 Person
类的 ObjectClass
实例……或者更确切地说,是表示磁盘上存储的 Person
实例的 ObjectClass
实例。ObjectClass
还包含很多其它选项,在本系列的后面我会展示其中的一些选项。
在某些情况下,磁盘上的数据必须存在,以支持由于技术或策略上的某种原因而不能重新编译的早期应用程序。在这些情况下,V2 应用程序必须能够提取 V1 实例,并在内存中将它们转换成 V2 实例。 幸运的是,在向磁盘存储并从中检索对象时,可以依靠 db4o 的别名 特性创建一个 shuffle 步骤。这样便可以区别内存中使用的类型和存储的类型。
db4o 支持三种类型的别名,其中一种类型只有当 .NET 和 Java 风格的 db4o 之间共享数据文件时才有用。 清单 11 中出现的别名是 TypeAlias
,它有效地告诉 db4o 用内存中的 “A” 类型(运行时名称 )替换磁盘上的 “B” 类型(存储的名称 )。启用这种别名是一种双线操作。
import com.db4o.config.*; // ... TypeAlias fromPersonToIndividual = new TypeAlias("com.tedneward.model.Person", "com.tedneward.persons.model.Individual"); Db4o.configure().addAlias(fromPersonToIndividual); |
当运行时,db4o 现在将查询数据库中的 Individual
对象的任何调用识别为一个请求,而不会查找存储的 Person
实例;这意味着,Individual
类中的名称和类型应该和 Person
中存储的名称和类型类似,db4o 将适当地处理它们之间的映射。然后,Individual
实例将被存储在 Person
名称之下。
|
由 于对象数据库中的模式就是类定义本身,而不是采用不同语言的单独的 DDL 定义,因此本文中的每个重构例子都显得简单很多。db4o 中的重构是使用代码完成的,常常可以通过一个配置调用来确定,最坏情况也只不过是编写和运行一个转换实用程序,以将已有实例从旧的类型更新为新的类型。而 且这种类型的转换对于几乎所有生产中的 RDBMS 重构都是必需的。
db4o 强大的重构能力使之在开发期间非常有用,因为在开发期间,正在设计的很多对象仍然是变化无常的,即使不需要每个小时都重构,至少也需要每天都重构。如果将 db4o 用于单元测试和测试驱动开发,则可以节省大量更改数据库的时间,如果重构只是简单的字段添加/删除或类型/名称更改,这一点就更加明显了。
这就是本文讨论的内容,但是请记住:如果要用对象编写应用程序,并且持久性存储实际上 “只是和实现有关”,那么为什么非得把很好的对象限制成规规矩矩、四四方方的样子呢?
以下是db4o 中的数据库重构