在Java语言中,对象身份是由每个对象都持有的equals()方法(以及相关的hashCode()方法)来定义的。无论两个对象是否为同一个实例, equals()方法都应该能够判别出它们是否表示同一个实体。hashCode()方法和equals()方法有关联是因为所有相等的对象都应该返回相 同的hashCode。默认情况下,equals()方法仅仅比较对象引用。一个对象和它自身是相等的,而和其他任何实例都不相等。对于持久性对象来说, 重写这两个方法,让代表着数据库中同一行的两个对象被视为相等是很重要的。而这对于Java中Collection(Set、Map和List)的正确工 作更是尤为重要。
为了阐明实现equal()和hashCode()的不同途径,让我们考虑一个准备持久存储到数据库中的简单对象Person。











































我们的Person对象还缺少的是equals()方法和hashCode()方法的实现。既然这是一个持久性对象,我们并不想依赖于这两个方法的默认实 现,因为默认实现并不能分辨代表数据库中同一行的两个不同实例。一种简单而又显然的实现方法是利用id字段来进行equal()方法的比较以及生成 hashCode()方法的结果。





















您可能会试图去实现一个使用id(只在已设置id的情况下)的equals()方法。毕竟,如果两个对象都没有被保存过,我们可以假定它们是不同的对象。这是因为在它们被保存到数据库的时候,它们会被赋予不同的主键。



















对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:








































下面是我们改进过的Person类的Hibernate映射文件。





















我们已经从转移到纯对象id中获取了不少好处。我们对equals()和hashCode()方法的实现更加简单而且更易阅读。这些方法再也不易出错而且 无论在保存对象之前还是之后,它们都能与Collection一起正常工作。Hibernate也变得更快一些,这是因为在保存新的对象之前它再也不需要 从数据库读取一个序列值。此外,新定义的equals()和hashCode()对于所有包含id对象的对象来说是通用的。这意味着我们可以把这些方法移 至一个抽象父类。我们不再需要为每个域对象重新实现equals()和hashCode(),而且我们也不再需要考虑对于每个类来说哪些字段组合是唯一且 不变的。我们只要简单地扩展这个抽象父类。当然,我们没必要强迫域对象从父类中扩展出来,所以我们定义了一个接口来保证设计的灵活性。



























































Person类现在就简单多了:






到现在为止一切都很好,但是我们遗漏了一个重要的细节:如何实现IdGenerator.createId()。我们可以为理想中的键生成(key-generation)算法定义一些标准:
- 键可以不牵扯到数据库而很廉价地生成。
- 即使跨越不同的虚拟机和不同机器,键也要保证唯一性。
- 如果可能,键可以由其他程序、编程语言和数据库生成,但是至少要能与它们兼容。
我们所需的是通用唯一标识符(universally unique identifier,UUID)。UUID由16个字节(128位)的数字组成,遵守标准格式。UUID的String版本看起来类似如下:
2cdb8cee-9134-453f-9d7a-14c0ae8184c6
里面的字符是简单的字节16进制表示,横线把数字的不同部分分隔开来。这种格式简单而且易于处理,只是36个字符有点长了。因为横线总是被安置在相同的位 置,所以可以把它们去掉,从而把字符的数目减少到32个。为了更为简洁地表示,可以创建一个byte[16]的数组或是两个8字节大小的long来保存这 些数字。如果您使用的是Java 1.5或更高版本,可以直接使用UUID类,虽然这不是它在内存中最简洁的格式。有关更多信息,请参阅Wikipedia UUID条目和JavaDoc UUID类条目。
UUID生成算法有多种实现。既然最终UUID是一种标准格式,我们在IdGenerator类中采用哪一种实现都没有关系。既然无论采用什么算法每个 id都会被保证唯一,我们甚至可以在任何时候改变算法的实现或是混合匹配不同的实现。如果您使用的是Java 1.5或更高版本,最方便的实现是java.util.UUID类:






这是使用JUG库实现IdGenerator的例子:












使用UUID作为数据库主键的最大障碍是它们在数据库中(而不是在内存中)的大小,在数据库中索引和外键的复合会促使主键大小的增加。您必须在不同情况下 使用不同的表示方法。使用String表示,数据库的主键大小将会是32或36字节。数字也可以直接以字节存储,这样大小就减少一半,但是如果直接查询数 据库,标识符将变得难以理解。这些方法对您的项目是否可行取决于您的需求。
如果数据库不接受UUID作为主键,您可以考虑使用数据库序列。但总是应该在新对象创建的时候被指派一个ID而不是让Hibernate管理ID。在这种 情况下,创建新域对象的业务对象可以调用一个使用数据访问对象(DAO)从数据库序列中检索id的服务。如果使用一个Long数据类型来表示对象id,一 个单独的数据库序列(以及服务方法)对您的域对象来说就已经足够了。
结束语
当对象持久存储到数据库中时,对象身份总是很难被恰当地实现。尽管如此,问题其实完全在于,对象在保存之前允许对象没有id就存在。我们可以通 过从诸如Hibernate这样的对象关系映射框架中获得指派对象ID的职责来解决这个问题。一旦对象被实例化,它就应该被指派一个ID。这使对象身份变 得简单而不易出错,也减少了域模型中需要的代码量。

作者简介 | |
James Brundege 目前是一位独立承包商,并在其拥有的公司Synaptocode Software LLC.中担当顾问。 |