摘要:
當對象持久化到數據庫中時,對象的標識符總時很難被恰當的實現。盡管如此,問題其實完全是由存在著在保存之前不持有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。這使對象標識符變成簡單而不易出錯,也減少了領域模型中需要的代碼量。