企业级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()方法有关联是因为所有相等的对象都应该返回相同的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把这个对象持久存储到数据库的Hibernate映射文件:

PERSON_SEQ

        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将会执行乐观锁定方案,根据这个方案,Hibernate在保存对象之前会根据数据库版本号检查对象的版本号。 我们的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 idreturn 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里面,事实上第二个Person对象并不能被加入。这是因为Set会断定所有未保存的对象都是相同的。

       您可能会试图去实现一个使用id(只在已设置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 equalif (id == null || other.getId() == null)return false;return id.equals(other.getId());}

        这里有个隐含的问题。Java Collection框架在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丢失了这个对象!这是因为当对象在集合中时,我们改变了hashCode()的值。

        当您想要创建一个将其他域对象保存在Set、Map或是List中的域对象时,这是一个问题。为了解决这个问题,您必须为所有对象提供一种equals()和hashCode()的实现,这种实现能够保证在它们在对象保存前后正确工作并且当对象在内存中时(返回值)不可变。Hibernate Reference Documentation (v. 3)提供了以下的建议:

       “不要使用数据库标识符来实现相等性判断,而应该使用业务键(business key),这是一个唯一的、通常不改变的属性的组合体。当一个瞬态对象(transient object)被持久化的时候,数据库标识符会发生改变。当一个瞬态实例(常常与detached实例一起使用)保存在一个Set中时,哈希码的改变会破坏Set的约定。业务键的属性并不要求和数据库主键一样稳定,只要保证当对象在同一个Set中时它们的稳定性。”(Hibernate Reference Documentation v. 3.1.1)。

       “我们推荐通过判断业务键相等性来实现equals()和hashCode()。业务键相等性意味着equals()方法只比较能够区分现实世界中实例的业务键(普通候选键)的属性。”(Hibernate Reference Documentation v. 3.1.1)。

        换句话说,普通键用于equals()和hashCode(),而Hibernate生成的代理项键用于对象的id.这要求对于每个对象有一个相关的不可变的业务键。可是,并不是每个对象类型都有这样的一种键,这时候您可能会尝试使用会改变但不经常改变的字段。这和业务键不必与数据库主键一样稳定的思想相吻合。如果这种键在对象所在集合的生存期中不改变,那这就“足够好”了。这是一种危险的观点,因为这意味着您的应用程序可能不会崩溃,但是前提是没有人在特定的情况下更新了特定的字段。所以,应当有一种更好的解决方案,这种解决方案确实也存在。不要让Hibernate管理您的ID.

        试图创建和维护对象及数据库行的各自身份定义是目前为止所有讨论问题的根源。如果我们统一所有身份形式,这些问题都将不复存在。也就是说,作为以数据库为中心和以对象为中心的ID的替代品,我们应该创建一种通用的、特定于实体的ID来代表数据实体,这种ID应该在数据第一次输入的时候创建。无论这个唯一数据实体是保存在数据库中,是作为对象驻留在内存中,还是存储在其他格式的介质中,这个通用ID都应该可以识别它。通过使用数据实体第一次创建时指派的实体ID,我们可以安全地回到equals()和hashCode()的原始定义,它们只需使用这个id:

public class Person {// assign an id as soon as possibleprivate 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 herepublic 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字段是否为null来判断对象是否为新的。既然对象id永不为null,很显然这种方法不再有效。通过配置Hibernate,让它检查version字段,而不是id字段是否为null, 我们可以很容易地解决这个问题。version字段是一个更恰当的用来判断对象是否被保存过的指示符。