程式先锋Java技术维客

别让Hibernate偷走了你的标识符

十一月 04, 2008 by czl

       当对象持久化到数据库中时,对象的标识符总时很难被恰当的实现。尽管如此,问题其实完全是由存在着在保存之前不持有ID的对象的现象衍生而来的。我们可以通过从诸如Hibernate这样的对象—关系映像框架手中取走指派对象ID的职责来解决这个问题。相对的,一旦对象被实例化,它就应该被指派一个ID.这使对象标识符变成简单而不易出错,也减少了领域模型中需要的代码量。
       企业级Java应用程序常常把数据在java对象和关系型数据库之间来回移动。从手动编写SQL代码到使用诸如hibernate这样的成熟的对象——关系映像(ORM)解决方案,有很多种方法可以实现这个过程。无论你采用什么样的技术,一旦你开始将java对象持久化到数据库中,对象标识符都将成为一个复杂而且难以管理的课题。可能出现的情况是:你实例化了两个不同的对象,而它们却代表了数据库中的同一行。为了解决这个问题,你可能采取的措施是在你的持久化对象中实现equals() 和hashCode()这两个方法,可是要恰当的实现这两个方法比乍看之下要有技巧一些。让问题更糟糕的是,那些传统的思路(包括hibernate官方文档所提倡的那些)对于新的工程并不一定能提出最实用的解决方案。
        对象标识在虚拟机(VM)中和在数据库中的差异是问题滋生的温床。在虚拟机中,你并不会得到对象的id,你只是简单的持有对象的直接引用。而在幕后,虚拟机确实给每个对象指派了一个8字节大小的id,这个id才是对象的真实引用。当你将对象持久化到数据库中的时候,问题开始产生了。假定你创建了一个Person对象并将它存入数据库(我们可以叫它person1)。而你的其它某段代码从数据库中读取了这个Person对象的数据并将它实例化为另一个新的Person对象(我们可以叫它Person2)。现在你的内存中有了两个映像到数据库中同一行的对象。一个对象引用只能指向它们俩的其中一个,可是我们需要一种方法来表示这两个对象实际上表示着同一个实体。这就是(在虚拟机中)引入对象标识符的原因。
        在java语言中,对象标识符是由每个对象都持有的equals()方法(以及相关的hashCode()方法)来定义的。无论两个对象(引用)是否为同一个实例,equals()方法都应该能够判别出它们是否表示同一个实体。hashCode()方法和equals()方法有关联是因为所有被判断等价(equal)的对象都应该返回相同的哈希值(hashCode)。在缺省实现中,equals()方法仅仅比较对象的引用,一个对象和它自身是等价的,而和其它任何实例都不等价。对于持久化对象来说,重写这两个方法,让代表着数据库中同一行的两个对象被判为等价是很重要的。而这对于java中的Collection数据结构(Set,Map和List)的正确工作更是尤为重要。
        为了阐明实现equal()和hashCode()的不同途径,让我们一起考虑一个准备持久化到数据库中的简单对象Person.

public class Person {
 private Long id;
 private Integer version;
 public Long getId() { return id; }
 public void setId(Long id) {
this.id = id;
 }
 public Integer getVersion() {
return version;
 }
 public void setVersion(Integer version) {
this.version = version;
 }
 // person-specific properties and behavior
}
      
        在这个例子中,我们遵循了同时持有id字段和version字段的最佳实践。Id字段保存了在数据库中作为主键使用的值,而version字段则是一个从0开始增长的增量,随着对象的每次更新而变化(它帮助我们避免并发更新的问题)。为了看的更清楚,我们也一起看一下Hibernate把这个对象持久化到数据库的映像文件。
<?XML version="1.0"?>
<hibernate-mapping package="my.package">
<class name="Person" table="PERSON">
<id name="id" column="ID" unsaved-value="null">
<generator class="sequence">
<param name="sequence">PERSON_SEQ</param>
</generator>
</id>
<version name="version" column="VERSION" />
<!-- Map Person-specific properties here. -->
</class>
      </hibernate-mapping>
        Hibernate映像文件指明了Person的id字段代表了数据库中的ID列(也就是说,它是PERSON表的主键)。包含在id标签中的unsaved-value="null"属性告诉Hibernate使用id字段来判断一个Person对象之前是否被保存过。ORM框架必须依靠这个来判断保存一个对象的时候应该使用SQL的INSERT字句还是UPDATE字句。在这个例子中,Hibernate假定一个新对象的id字段一开始为null值,当它第一次被保存时才id才被赋予一个值。generator标签告诉Hibernate当对象第一次保存时,应该从哪里获得指派的id。在这个例子中,Hibernate使用数据库序列作为产生唯一id的来源。最后,version标签告诉Hibernate使用Person对象的version字段进行并发控制。Hibernate将会执行乐观锁方案(optimistic locking scheme),根据这个方案,Hibernate在保存对象之前会检查对比对象的version值和数据库中相应数据的version值。
        我们的Person对象还缺少的是equals()方法和hashCode()方法的实现。既然这是一个持久化对象,我们并不想依赖于这两个方法的缺省实现,因为缺省实现并不能分辨代表数据库中同一实体的不同实例。一种简单而又显然的实现方法是利用id字段来进行equal()方法的比较以及生成hashCode()方法的结果。
public boolean equals(Object o) {
 if (this == o) return true;
 if (o == null || !(o instanceof Person))
return false;
 Person other = (Person)o;
 if (id == other.getId()) return true;
 if (id == null) return false;
 // equivalence by id
 return id.equals(other.getId());
}
public int hashCode() {
 if (id != null) {
return id.hashCode();
 }
 else {
return super.hashCode();
 }
}
      
        不走运的是,这个实现存在着问题。当我们首次创建Person对象的时候id的值是null,这意味着任何两个没有被保存的Person对象都将被认为是等价的。如果我们想创建一个Person对象并把它放到Set数据结构中,再创建了一个完全不同的Person对象也把它放到同一个Set里面,事实上第2个Person对象并不能被加入。这是因为Set会断定所有未经保存的对象都是相同的。
        你可能会试探着去实现一个只使用被设置过的id的equals()方法。毕竟,如果两个对象都没有被保存过,我们可以假定它们是不同的对象。这是因为在它们被保存到数据库的时候,它们会被赋予不同的主键。
public boolean equals(Object o) {
 if (this == o) return true;
 if (o == null || !(o instanceof Person)) return false;
Person other = (Person)o;
 // unsaved objects are never equal
 if (id == null || other.getId() == null) return false;
 return id.equals(other.getId());
}
      
        这里有个隐藏的问题。Java的Collection框架在它的生命周期中需要基于不变字段的equals()和hashCode()方法。换句话来说,当一个对象处在Collection中的时候,你不可以改变equals()和hashCode()的返回值。举个例子,下面这段程序:
Person p = new Person();
Set set = new HashSet();
set.add(p);
System.out.println(set.contains(p));
p.setId(new Long(5));
System.out.println(set.contains(p));
      
        打印结果: true false
        对set.contains(p)的第2次调用返回了false是因为Set再也找不到p了。用书面化的语言讲,Set丢失了这个对象!这是因为当对象在Set中时,我们改变了hashCode()的返回值。
        当你想要创建一个将其它域对象保存在Set,Map或是List里面的域对象时,这是一个问题。为了解决这个问题,你必须为你的所有对象提供一种equals()和hashCode()的实现,这种实现能够保证在它们在对象保存前后正确工作并且当对象在内存中时(返回值)不会改变。Hibernate参考文档提供了以下的建议:
        “不要使用数据库标识符来实现等价的判断,而应该使用商业键值(business key),一种唯一的,通常不改变的属性的结合体。当一个buk不可序列化对象(transient object)被持久化的时候,数据库标识符会发生改变。当一个不可序列化实例(常常和detached instances在一起)被包含在一个Set里面时,哈希值的改变会破坏Set的从属关系。商业键值的属性并不要求和数据库主键一样稳定,你只要保证当对象在某个Set中时它们的稳定性。
        “我们推荐判断商业键值的等价性来实现equals()和hashCode()两个方法。这意味着equals()方法只比较能够区分现实世界中的实例的商业键值(某个候选码)的属性。“(Hibernate 参考文档 v. 3.1.1).
        换句话说,equals()和hashCode()使用商业键值进行处理,而对象使用Hibernate生成的键值作为id值。这要求对于每个对象有一个相关的不会改变的商业键值。可是,并不是每个对象类型都有这样的一种键,这时候你可能会尝试使用会改变但不时常改变的字段。这和商业键值不必和数据库主键一样稳定的思想相吻合。当对象在Collection中时候如果这种键不改变,那它们似乎就“足够好”了。这是一种危险的主张,这意味着你的应用程序可能不会崩溃,但是前提是没有人在特定的情况下更新了特定的字段。所以,应当有一种更好的解决方案,而它确实也存在。 试图创建和维护在对象和数据库行两者间有着分离的定义的标识符是目前为止讨论的所有问题的根源。如果我们统一所有标识符的形式,这些问题都将不复存在。也就时说,作为以数据库为中心和以对象为中心的标识符的替代品,我们应该创建一种通用的,特定于实体的ID来代表数据实体,这种ID应该在数据第一次输入的时候产生。无论一个唯一数据实体是保存在数据库,是作为对象驻留在内存,还时存贮在其它格式的介质中,这个通用ID都应该可以识别它。通过使用数据实体第一次创建时指派的ID,我们可以安全的回到我们对equals()和hashCode()的原始定义。它们只是简单地使用了这个id:

public class Person {
 // assign an id as soon as possible
 private String id = IdGenerator.createId();
 private Integer version;
 public String getId() { return id; }
 public void setId(String id) {
this.id = id;
 }
 public Integer getVersion() {
return version;
 }
 public void setVersion(Integer version) {
this.version = version;
 }
 // Person-specific fields and behavior here
 public boolean equals(Object o) {
if (this == o) return true;
if (o == null || !(o instanceof Person)) return false;
Person other = (Person)o;
if (id == null) return false;
return id.equals(other.getId());
 }
 public int hashCode() {
if (id != null) {
 return id.hashCode();
} else {
 return super.hashCode();
}
 }
}
      
        这个例子使用id作为equals()方法判断等价的标准以及hashCode()返回哈希值的来源。这就简单了许多。但是,要让它正常工作,我们需要两样东西。首先,我们需要保证每个对象在被保存之前都有一个id值。在这个例子里,当id变量被声明的时候,它就被指派了一个值。其次,我们需要一种判断这个对象是新生成的还是之前保存过的的手段。在我们最早的例子中,Hibernate检查id字段是否为空来判断对象是否时新生成的。既然我们的对象id永远不为空,这个方法显然不再有效。为了解决这个问题,我们可以很容易的配置Hibernate,让它检查version字段,而不是id字段是否为空。version字段是一个更为恰当的用来判断你的对象是否被保存过的指示器。
        下面是我们改进过的Person类的Hibernate映射文件。

<?XML version="1.0"?>
<hibernate-mapping package="my.package">
<class name="Person" table="PERSON">
<id name="id" column="ID">
<generator class="assigned" />
</id>
<version name="version" column="VERSION" unsaved-value="null" />
<!-- Map Person-specific properties here. -->
</class>
</hibernate-mapping>
      
        注意,id下面的generator标签包含了属性class="assigned".这个属性告诉Hibernate我们不是让数据库指派id值而是在我们的代码里面指派id值。Hibernate会简单地认为即使是新的,没有经过保存的对象也有id值。我们也给version标签新增了一个unsaved-value="null"的属性。这个属性告诉Hibernate应该把version值而不是id值为null作为对象是新创建而成的指示器。我们也可以简单的告诉Hibernate把负值作为对象未经保存的指示器,如果你喜欢把version字段的类型设置为int而不是Integer,这将是很有用的。
        我们已经从改用这样的纯净的对象id中获取了不少好处。我们对equals()和hashCode()方法的实现更加简单而且容易阅读。这些方法再也不易出错而且无论在保存对象之前还是之后,它们都能和Collection一起正常工作。Hibernate也能够变的更快一些,这是因为在保存新的对象之前它再也不需要从数据库读取一个序列值。此外,新定义的equals()和hashCode()对于一个包含id对象的对象来说是具有通用性的。这意味着我们可以把这些方法移动到一个抽象的父类当中去。我们不再需要为每一个域对象重新实现equals()和hashCode(),而且我们也不再需要考虑对于一个类来说哪些字段的组合是唯一且不变的。我们只要简单地继承这个抽象类。当然,我们没必要强迫我们的域对象继承一个父类,所以我们定义了一个接口来保证设计的灵活性。

public interface PersistentObject {
 public String getId();
 public void setId(String id);
 public Integer getVersion();
 public void setVersion(Integer version);
}
public abstract class AbstractPersistentObject implements PersistentObject {
 private String id = IdGenerator.createId();
 private Integer version;
 public String getId() { return id;
}
public void setId(String id) { this.id = id; }
public Integer getVersion() { return version; }
public void setVersion(Integer version) { this.version = version; }
public boolean equals(Object o) {
 if (this == o) return true;
 if (o == null || !(o instanceof PersistentObject)) { return false; }
 PersistentObject other = (PersistentObject)o;
 // if the id is missing,
 return false
 if (id == null) return false;
 // equivalence by id
 return id.equals(other.getId());
}
public int hashCode() {
 if (id != null) {
return id.hashCode();
 } else {
return super.hashCode();
 }
}
public String toString() {
 return this.getClass().getName() + "[id=" + id + "]";
}
}
      
        现在我们有了一个简单而高效的方法来创建域对象。它们继承了AbstractPersistentObject,这个父类能在它们第一次被创建时自动赋予它们一个id并且恰当的实现了equals()和hashCode()这两个方法。域对象也得到了一个对toString()方法的合理的缺省实现,这个方法可以有选择地被重写。如果这是一个查询例子的测试对象或者例子对象,id值时可以被改变或者被设为null。否则它是不应当被改变的。如果因为某些原因我们需要创建一个继承自其它类的域对象,这个对象就应当实现PersistentObject接口而不是继承抽象类。
        Person类现在就简单多了:

public class Person extends AbstractPersistentObject { // Person-specific fields and behavior here}
        从上一个例子开始Hibernate映像文件就不会再改变了。我们不想麻烦Hibernate去了解抽象父类,相对的,我们只要保证每个持久化对象的映射文件包含一个id项(和一个被指派的生成器)和一个带有unsaved-value="null"属性的version标签。机敏的读者可能已经注意到,每当一个持久化对象被实例化的时候,它的id值得到了指派。这意味着当Hibernate在内存中创建一个已经保存过的对象时,虽然这个对象是已经存在并从数据库中读取的,它也会得到一个新的id。这不会产生问题,因为Hibernate会接着调用对象的setId()方法,用保存的真实id来替换新分配的id。剩下的id生成器并不是问题,因为实现它的算法是轻量级的(也就是说,它并不牵扯到数据库)。
        到现在为止一切都很好,但是我们遗漏了一个重要的细节:如何实现IdGenerator.createId().我们可以为我们理想中的键值生成器(key-generation)算法定义一些标准。
        ● 键值可以不牵扯到数据库而很轻量级的产生
        ● 即使跨越不同的虚拟机和不同机器,键值也要保证唯一性。
        ● 如果可能键值可以由其它程序,编程语言和数据库生成,至少要能和它们兼容
        我们需要的是通用唯一标识符(UUID)。UUID是由标准格式化的16个字节大小的(128位)数字组成的。UUID的字符串版本是像这样的:2cdb8cee-9134-453f-9d7a-14c0ae8184c6(大家应该可以注意到, Jmatrix目前就是使用的UUID)
        里面的字符是数字简单的按字节的16进制表示,横线把数字的不同部分分割开来。这种格式简单而且易于处理,只是36个字符有点儿太长了。因为横线总是被安置在相同的位置,所以可以把它们去掉而把字符的数目减少到32个。用一种更为简洁的表示方法,你可以创建一个byte[16]的数组或是两个8字节大小的长整型(long)来保存这些数字。如果你使用的是Java1.5或更高版本,你可以直接使用UUID类,虽然这不是它在内存中最简洁的格式。如果你要获得更多的信息,请参阅Wikipedia 的UUID条目 或 Java UUID参考文档。
        对UUID的产生算法有多种实现。既然最终UUID是一种标���格式,我们在IdGenerator类中采用哪一种实现都没有关系。既然无论采用什么算法每个id都会被保证唯一,我们甚至可以在任何时候改变算法的实现或是混合匹配不同的实现。如果你使用的是java1.5或更高版本,最方便的实现是java.util.UUID类。

public class IdGenerator {
 public static String createId() {
UUID uuid = java.util.UUID.randomUUID();
return uuid.toString();
 }
}
      
        对不使用java1.5或更高版本的人来说,至少有两种扩展库实现了UUID并且和1.5之前的java版本兼容: Apache Commons ID project 和 Java UUID Generator(JUG) project.它们都在Apache的旗下。(在LGPL之下JUG也是可用的)        这是使用JUG库实现IdGenerator的例子。

import org.safehaus.uuid.UUIDGenerator;
public class IdGenerator {
 public static final UUIDGenerator uuidGen = UUIDGenerator.getInstance();
 public static String createId() {
UUID uuid = uuidGen.generateRandomBasedUUID();
return uuid.toString();
 }
}
      
        Hibernate内置的UUID生成器算法又如何呢?这是一个得到验证对象标识用的UUID的适当途径吗?如果你想让对象标识符独立于对象的持久化,这就不是一个好方法。虽然Hibernate确实提供有让它为你生成UUID的选项,但这样的话我们又回到了那个最早的问题上:对象ID的获得并不在它们被创建的时候,而在它们被保存的时候。
        使用UUID作为数据库主键的最大障碍是它们在数据库中(而不是在内存中)的大小,在数据库中索引和外键的复合会促使主键大小的增加。你必须在不同的情况下使用不同的表示方法。使用String表示,数据库的主键大小将会是32或36字节。Id也可以直接使用位存储,这样将减少一半的占用空间,但是如果你直接查询数据库,id将变得难以理解。这些方法对你的工程是否可行取决于你的需求。 如果你的数据库不接受UUID作为主键,你可以考虑使用数据库序列。但总是应该让新对象创建的时候被指派一个ID而不是让Hibernate管理你的ID。在这种情况下,创建新的域对象的商业对象可以调用一个使用data Access object(DAO)从数据库序列中获取数据库id的服务。如果你使用一个长整型来表示你的对象id,一个单独的数据库序列(以及服务方法)对你的域对象来说已经足够了。
        小结
        当对象持久化到数据库中时,对象的标识符总时很难被恰当的实现。尽管如此,问题其实完全是由存在着在保存之前不持有ID的对象的现象衍生而来的。我们可以通过从诸如Hibernate这样的对象—关系映像框架手中取走指派对象ID的职责来解决这个问题。相对的,一旦对象被实例化,它就应该被指派一个ID。这使对象标识符变成简单而不易出错,也减少了领域模型中需要的代码量。



发表一条评论:
  • HTML语法: 启用

Search

 

« 九月 2010
星期日星期一星期二星期三星期四星期五星期六
   
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  
       
今天

Feeds

Navigation