別讓Hibernate偷走了您的身份
作者: lanxincao 發表日期: 2006-11-07 16:06 文章屬性: 轉載 複製鏈結
http://big5.ccidnet.com:89/gate/big5/lanxincao.blog...._showone/tid_102990.html別讓Hibernate偷走了您的身份(轉自dev2dev.bea.com.cn) 作者:James Brundege
企業級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映射文件:
<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping SYSTEM
"
http://hibernate.so...ge.net/ hibernate-mapping-3.0.dtd">
<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將會執行樂觀鎖定方案,根據這個方案,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 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裏面,事實上第二個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 || 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 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字段是否為null來判斷對像是否為新的。既然對象id永不為null,很顯然這種方法不再有效。通過配置Hibernate,讓它檢查version字段,而不是id字段是否為null, 我們可以很容易地解決這個問題。version字段是一個更恰當的用來判斷對像是否被保存過的指示符。
下面是我們改進過的Person類的Hibernate映射文件。
<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping SYSTEM
"
http://hibernate.so...ge.net/hibernate-mapping-3.0.dtd">
<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去了解抽象父類,我們只要保證每個 PersistentObject映射文件包含一個id項(和一個“被指派的”生成器)和一個帶有unsaved-value="null"屬性的 version標簽。機敏的讀者可能已經注意到,每當一個持久性對象被實例化的時候,它的id得到了指派。這意味著當Hibernate在記憶體中創建一個已保存對象的實例時,雖然這個對像是已經存在並從數據庫中讀取的,它也會得到一個新的id。這說好了。然後Hibernate會接著調用對象的setId ()方法,用保存的id來替換新分配的id。額外的id生成並不是什麼問題,因為id生成演算法是廉價的(也就是說,它並不牽扯到數據庫)。
到現在為止一切都很好,但是我們遺漏了一個重要的細節:如何實現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類:
public class IdGenerator {
public static String createId() {
UUID uuid = java.util.UUID.randomUUID();
return uuid.toString();
}
}
對不使用Java 1.5或更高版本的人來說,至少有兩種擴展庫實現了UUID並且與1.5之前的Java版本相容:Apache Commons ID項目和Java UUID Generator (JUG)項目。它們在Apache License之下都是可用的(在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字節。數字也可以直接以字節存儲,這樣大小就減少一半,但是如果直接查詢數據庫,標識符將變得難以理解。這些方法對您的項目是否可行取決於您的需求。
如果數據庫不接受UUID作為主鍵,您可以考慮使用數據庫序列。但總是應該在新對象創建的時候被指派一個ID而不是讓Hibernate管理ID。在這種情況下,創建新域對象的業務對象可以調用一個使用數據訪問對象(DAO)從數據庫序列中檢索id的服務。如果使用一個Long數據類型來表示對象id,一個單獨的數據庫序列(以及服務方法)對您的域對象來說就已經足夠了。
結束語
當對象持久存儲到數據庫中時,對象身份總是很難被恰當地實現。儘管如此,問題其實完全在於,對象在保存之前允許對象沒有id就存在。我們可以通過從諸如Hibernate這樣的對象關係映射框架中獲得指派對象ID的職責來解決這個問題。一旦對象被實例化,它就應該被指派一個ID。這使對象身份變得簡單而不易出錯,也減少了域模型中需要的代碼量。