企业级Java应用程序常常把数据在Java对象和相关数据库之间来回移动从手工编写SQL代码到诸如Hibernate这样成熟的对象关系映射(ORM)解决方案有很多种方法可以实现这个过程无论采用什么样的技术一旦开始将Java对象持久存储到数据库中身份将成为一个复杂且难以管理的课题可能出现的情况是您实例化了两个不同的对象而它们却代表数据库中的同一行为了解决这个问题您可能采取的措施是在持久性对象中实现equals()和hashCode()可是要恰当地实现这两个方法比乍看之下要有技巧一些让问题更糟糕的是那些传统的思路(包括Hibernate官方文档所提倡的)对于新的项目并不一定能提出最实用的解决方案
对象身份在虚拟机(VM)中和在数据库中的差异是问题滋生的温床在虚拟机中您并不会得到对象的ID您只是简单地持有对象的直接引用而在幕后虚拟机确实给每个对象指派了一个字节大小的ID这个ID才是对象的真实引用当您将对象持久存储到数据库中的时候问题开始产生了假定您创建了一个Person对象并将它存入数据库(我们可以叫它person)而您的其他某段代码从数据库中读取了这个Person对象的数据并将它实例化为另一个新的Person对象(我们可以叫它Person)现在您的内存中有了两个映射到数据库中同一行的对象一个对象引用只能指向它们的其中一个可是我们需要一种方法来表示这两个对象实际上表示着同一个实体这就是(在虚拟机中)引入对象身份的原因
在Java语言中对象身份是由每个对象都持有的equals()方法(以及相关的hashCode()方法)来定义的无论两个对象是否为同一个实例equals()方法都应该能够判别出它们是否表示同一个实体hashCode()方法和equals()方法有关联是因为所有相等的对象都应该返回相同的hashCode默认情况下equals()方法仅仅比较对象引用一个对象和它自身是相等的而和其他任何实例都不相等对于持久性对象来说重写这两个方法让代表着数据库中同一行的两个对象被视为相等是很重要的而这对于Java中Collection(SetMap和List)的正确工作更是尤为重要
为了阐明实现equal()和hashCode()的不同途径让我们考虑一个准备持久存储到数据库中的简单对象Person
public class Person {
private Long id;
private Integer version;
public Long getId() {
return id;
}
public void setId(Long id) {
thisid = id;
}
public Integer getVersion() {
return version;
}
public void setVersion(Integer version) {
thisversion = version;
}
// personspecific properties and behavior
}
在这个例子中我们遵循了同时持有id字段和version字段的最佳实践Id字段保存了在数据库中作为主键使用的值而version字段则是一个从开始增长的增量随着对象的每次更新而变化(这帮助我们避免并发更新的问题)为了更清楚一些让我们看看允许Hibernate把这个对象持久存储到数据库的Hibernate映射文件
<?xml version=?>
<!DOCTYPE hibernatemapping SYSTEM
hibernatemappingdtd>
<hibernatemapping package=mypackage>
<class name=Person table=PERSON>
<id name=id column=ID
unsavedvalue=null>
<generator class=sequence>
<param name=sequence>PERSON_SEQ</param>
</generator>
</id>
<version name=version column=VERSION />
<! Map Personspecific properties here >
</class>
</hibernatemapping>
Hibernate映射文件指明了Person的id字段代表数据库中的ID列(也就是说它是PERSON表的主键)包含在id标签中的unsavedvalue=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 == othergetId()) return true;
if (id == null) return false;
// equivalence by id
return idequals(othergetId());
}
public int hashCode() {
if (id != null) {
return idhashCode();
} else {
return superhashCode();
}
}
不幸的是这个实现存在着问题当我们首次创建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 equal
if (id == null || othergetId() == null)
return false;
return idequals(othergetId());
}
这里有个隐含的问题Java Collection框架在Collection的生命周期中需要基于不变字段的equals()和hashCode()方法换句话来说当一个对象处在Collection中的时候不可以改变equals()和hashCode()的值举个例子下面这段程序
Person p = new Person();
Set set = new HashSet();
setadd(p);
Systemoutprintln(ntains(p));
psetId(new Long());
Systemoutprintln(ntains(p));
输出结果true false
对ntains(p)的第次调用返回false这是因为Set再也找不到p了用专业术语来讲就是Set丢失了这个对象!这是因为当对象在集合中时我们改变了hashCode()的值
当您想要创建一个将其他域对象保存在SetMap或是List中的域对象时这是一个问题为了解决这个问题您必须为所有对象提供一种equals()和hashCode()的实现这种实现能够保证在它们在对象保存前后正确工作并且当对象在内存中时(返回值)不可变Hibernate Reference Documentation (v )提供了以下的建议
不要使用数据库标识符来实现相等性判断而应该使用业务键(business key)这是一个唯一的通常不改变的属性的组合体当一个瞬态对象(transient object)被持久化的时候数据库标识符会发生改变当一个瞬态实例(常常与detached实例一起使用)保存在一个Set中时哈希码的改变会破坏Set的约定业务键的属性并不要求和数据库主键一样稳定只要保证当对象在同一个Set中时它们的稳定性(Hibernate Reference Documentation v )
我们推荐通过判断业务键相等性来实现equals()和hashCode()业务键相等性意味着equals()方法只比较能够区分现实世界中实例的业务键(普通候选键)的属性(Hibernate Reference Documentation v )
换句话说普通键用于equals()和hashCode()而Hibernate生成的代理项键用于对象的id这要求对于每个对象有一个相关的不可变的业务键可是并不是每个对象类型都有这样的一种键这时候您可能会尝试使用会改变但不经常改变的字段这和业务键不必与数据库主键一样稳定的思想相吻合如果这种键在对象所在集合的生存期中不改变那这就足够好了这是一种危险的观点因为这意味着您的应用程序可能不会崩溃但是前提是没有人在特定的情况下更新了特定的字段所以应当有一种更好的解决方案这种解决方案确实也存在
不要让Hibernate管理您的id
试图创建和维护对象及数据库行的各自身份定义是目前为止所有讨论问题的根源如果我们统一所有身份形式这些问题都将不复存在也就是说作为以数据库为中心和以对象为中心的ID的替代品我们应该创建一种通用的特定于实体的ID来代表数据实体这种ID应该在数据第一次输入的时候创建无论这个唯一数据实体是保存在数据库中是作为对象驻留在内存中还是存储在其他格式的介质中这个通用ID都应该可以识别它通过使用数据实体第一次创建时指派的实体ID我们可以安全地回到equals()和hashCode()的原始定义它们只需使用这个id
public class Person {
// assign an id as soon as possible
private String id = IdGeneratorcreateId();
private Integer version;
public String getId() {
return id;
}
public void setId(String id) {
thisid = id;
}
public Integer getVersion() {
return version;
}
public void setVersion(Integer version) {
thisversion = version;
}
// Personspecific 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 idequals(othergetId());
}
public int hashCode() {
if (id != null) {
return idhashCode();
} else {
return superhashCode();
}
}
}
这个例子使用对象id作为equals()方法判断相等的标准以及hashCode()返回哈希码的来源这就简单了许多但是要让它正常工作我们需要两样东西首先我们需要保证每个对象在被保存之前都有一个id值在这个例子里当id变量被声明的时候它就被指派了一个值其次我们需要一种判断这个对象是新生成的还是之前保存过的的手段在我们最早的例子中Hibernate通过检查id字段是否为null来判断对象是否为新的既然对象id永不为null很显然这种方法不再有效通过配置Hibernate让它检查version字段而不是id字段是否为null 我们可以很容易地解决这个问题version字段是一个更恰当的用来判断对象是否被保存过的指示符
下面是我们改进过的Person类的Hibernate映射文件
<?xml version=?>
<!DOCTYPE hibernatemapping SYSTEM
hibernatemappingdtd>
<hibernatemapping package=mypackage>
<class name=Person table=PERSON>
<id name=id column=ID>
<generator class=assigned />
</id>
<version name=version column=VERSION
unsavedvalue=null />
<! Map Personspecific properties here >
</class>
</hibernatemapping>
注意id下面的generator标签包含了属性class=assigned这个属性告诉Hibernate我们不是从数据库指派id值而是在代码中指派id值Hibernate会简单地认为即使是新的未保存的对象也有id值我们也给version标签新增了一个属性unsavedvalue=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 = IdGeneratorcreateId();
private Integer version;
public String getId() {
return id;
}
public void setId(String id) {
thisid = id;
}
public Integer getVersion() {
return version;
}
public void setVersion(Integer version) {
thisversion = 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 idequals(othergetId());
}
public int hashCode() {
if (id != null) {
return idhashCode();
} else {
return superhashCode();
}
}
public String toString() {
return thisgetClass()getName()
+ [id= + id + ];
}
}
现在我们有了一个简单而高效的方法来创建域对象它们扩展了AbstractPersistentObject该父类能在它们第一次创建时自动赋予一个id并且恰当地实现了equals()和hashCode()域对象也得到了一个toString()方法的合理默认实现这个方法可以有选择地被重写如果这是一个查询例子的测试对象或者示例对象id可以被修改或者被设为null否则它是不应当被改变的如果因为某些原因我们需要创建一个扩展其他类的域对象这个对象就应当实现PersistentObject接口而不是扩展抽象类
Person类现在就简单多了
public class Person
extends AbstractPersistentObject {
// Personspecific fields and behavior here
}
从上一个例子开始Hibernate映射文件就不会再改变了我们不想麻烦Hibernate去了解抽象父类我们只要保证每个PersistentObject映射文件包含一个id项(和一个被指派的生成器)和一个带有unsavedvalue=null属性的version标签机敏的读者可能已经注意到每当一个持久性对象被实例化的时候它的id得到了指派这意味着当Hibernate在内存中创建一个已保存对象的实例时虽然这个对象是已经存在并从数据库中读取的它也会得到一个新的id这说好了然后Hibernate会接着调用对象的setId()方法用保存的id来替换新分配的id额外的id生成并不是什么问题因为id生成算法是廉价的(也就是说它并不牵扯到数据库)
到现在为止一切都很好但是我们遗漏了一个重要的细节如何实现IdGeneratorcreateId()我们可以为理想中的键生成(keygeneration)算法定义一些标准
键可以不牵扯到数据库而很廉价地生成
即使跨越不同的虚拟机和不同机器键也要保证唯一性
如果可能键可以由其他程序编程语言和数据库生成但是至少要能与它们兼容
我们所需的是通用唯一标识符(universally unique identifierUUID)UUID由个字节(位)的数字组成遵守标准格式UUID的String版本看起来类似如下
cdbceefdacaec
里面的字符是简单的字节进制表示横线把数字的不同部分分隔开来这种格式简单而且易于处理只是个字符有点长了因为横线总是被安置在相同的位置所以可以把它们去掉从而把字符的数目减少到个为了更为简洁地表示可以创建一个byte[]的数组或是两个字节大小的long来保存这些数字如果您使用的是Java 或更高版本可以直接使用UUID类虽然这不是它在内存中最简洁的格式有关更多信息请参阅Wikipedia UUID条目和JavaDoc UUID类条目
UUID生成算法有多种实现既然最终UUID是一种标准格式我们在IdGenerator类中采用哪一种实现都没有关系既然无论采用什么算法每个id都会被保证唯一我们甚至可以在任何时候改变算法的实现或是混合匹配不同的实现如果您使用的是Java 或更高版本最方便的实现是javautilUUID类
public class IdGenerator {
public static String createId() {
UUID uuid = javautilUUIDrandomUUID();
return uuidtoString();
}
}
对不使用Java 或更高版本的人来说至少有两种扩展库实现了UUID并且与之前的Java版本兼容Apache Commons ID项目和Java UUID Generator (JUG)项目它们在Apache License之下都是可用的(在LGPL之下JUG也是可用的)
这是使用JUG库实现IdGenerator的例子
import orgsafehausuuidUUIDGenerator;
public class IdGenerator {
public static final UUIDGenerator uuidGen
= UUIDGeneratorgetInstance();
public static String createId() {
UUID uuid
= uuidGengenerateRandomBasedUUID();
return uuidtoString();
}
}
Hibernate中内置的UUID生成器算法又如何呢?这是获得对象身份的UUID的适当途径吗?如果您想让对象身份独立于对象持久性这就不是一个好方法虽然Hibernate确实提供了生成UUID的选项但这样的话我们又回到了最早的那个问题上对象ID的获得并不在它们被创建的时候而是在它们被保存的时候
使用UUID作为数据库主键的最大障碍是它们在数据库中(而不是在内存中)的大小在数据库中索引和外键的复合会促使主键大小的增加您必须在不同情况下使用不同的表示方法使用String表示数据库的主键大小将会是或字节数字也可以直接以字节存储这样大小就减少一半但是如果直接查询数据库标识符将变得难以理解这些方法对您的项目是否可行取决于您的需求
如果数据库不接受UUID作为主键您可以考虑使用数据库序列但总是应该在新对象创建的时候被指派一个ID而不是让Hibernate管理ID在这种情况下创建新域对象的业务对象可以调用一个使用数据访问对象(DAO)从数据库序列中检索id的服务如果使用一个Long数据类型来表示对象id一个单独的数据库序列(以及服务方法)对您的域对象来说就已经足够了
结束语
当对象持久存储到数据库中时对象身份总是很难被恰当地实现尽管如此问题其实完全在于对象在保存之前允许对象没有id就存在我们可以通过从诸如Hibernate这样的对象关系映射框架中获得指派对象ID的职责来解决这个问题一旦对象被实例化它就应该被指派一个ID这使对象身份变得简单而不易出错也减少了域模型中需要的代码量