22 October 2006

Java developer 都知道,物件預設的 equals() 跟 == 一樣,但 override equals() 之後就有差了,而且一定要同時也 override hashCode(),這些是老生長談了。如果我們再加上 Hibernate 這個 ORM 因素進來,事情又變得更複雜了。

先講講大前提:你要先考慮你的 hibernate entity 該不該 override equals()。Hibernate 會保證在同一個 session 裡,同一筆資料庫的資料只會有一個 instance。如果你的程式能夠確保 entity 的 equals() 操作都在同一個 session 裡。那麼你就不需要 override equals()。提醒 Java 新手:equals() 除了自己呼叫外,丟進 set 裡操作也會呼叫。

上面提到一個重點:程式能不能夠確保 entity 的 equals() 操作都在同一個 session 裡?關於這一點,就要看你的程式架構對 session 開關的設定了。這裡我們只提最常見的設定方式:OpenSessionInView pattern。這個 pattern 會讓你的 session 長度等同於一個 web request。換句話說,如果你在前一個 request 和下一個 request 裡分別使用相關 entity 的 equals(),那麼你就要考慮 override equals()。不然會發生明明兩個 entity 都指向同一筆資料庫資料,但是卻不 equals() 的怪事。

一般的情況下,如果不是很複雜的 use case,通常一個 web request 長度的 session 應該夠我們用了。下面我們討論真的需要 override equals 時該怎麼寫:

禁止使用 database 產生的 id (pk) 做為 equals() 的依據

這個條例是 Hibernate 與 Java 初學者最容易犯的錯誤。撰寫 equals() 的一個大前提是必須基於不會改變的參數來計算。在尚未 save() 的 transient entity 是沒有 id 的,而經過 save(),變成 persistent entity 之後,就取得 id 了。所以 id 在 entity 新增的過程中改變了。因此我們不能仰賴 db 產生的 id 做為 equals() 的依據。除非你的 pk 是用指定的,那就另當別論囉。

使用 business key

由於 equals() 要用不會改變的參數來計算,我們要在 entity 內選一些符合的參數來用。這些參數的組合必須是 unique,而且不會改變。注意這兩個特性是相對的 -- 參數只需要在操作 equals() 的這段 use case 其間 unique且不會改變 就夠了,沒有要求要永遠滿足。好,那麼怎麼找起呢?從 domain 下手 -- 思考 end user 是如何分辨不同的 entity。使用者是不會用 db 流水號記事情的,一定是一些有意義的參數。比方說身份證字號、名稱、日期時間... 等等,或是使用多個參數組合可以達到 equals() 的要求。Hibernate 書上稱這樣參數為 business key,因為它們在 business logic 上都有意義。而且 end user 不會挑會常常變來變去的參數來分辨資料的。

equals() 的寫法

假設我們有一 entity 叫 comment:

public class Comment {
    private Long id ;
    private String name ; //comment 人的名字
    private Date createDate ;
    private String content;
    //省略 getter/setter
}

我們選 name + createDate 來當做 equals() 的依據寫寫看,現在 IDE 很厲害,可以幫我們自己產生 equals() / hashCode(),下面是 eclipse 3.2 產生的:

@Override
public int hashCode() {
	final int PRIME = 31;
	int result = 1;
	result = PRIME * result
			+ ((createDate == null) ? 0 : createDate.hashCode());
	result = PRIME * result + ((name == null) ? 0 : name.hashCode());
	return result;
}

@Override
public boolean equals(Object obj) {
	if (this == obj) return true;
	if (obj == null) return false;
	if (getClass() != obj.getClass()) return false;
	final Comment other = (Comment) obj;
	if (createDate == null) {
		if (other.createDate != null) return false;
	} else if (!createDate.equals(other.createDate)) return false;
	if (name == null) {
		if (other.name != null) return false;
	} else if (!name.equals(other.name)) return false;
	return true;
}

哎呀,好大一沱的程式啊,好在有 IDE 幫我們產生啊......才怪,如果你用上面這個版本就掛了。切記 Hibernate 的 entity 有時候會是真正的物件,有時會是proxy (由CGLib 產生的,主要是為了達到 lazy 的功能)。遇到 proxy,比 class 這一行就破功了:

//錯誤:(Comment 的 proxy 其實是 Comment class 的子class )
if (getClass() != obj.getClass()) return false;

//正確:  
if(!(obj instanceof Comment)) return false;

還沒完喔... proxy 只套用在 method 上,所以不能直接存取 field,你必須改用 getter,下面只取一小段來示範:

//錯誤:
if (name == null) {
    if (other.name != null) return false;
} else if (!name.equals(other.name)) return false;


//正確:(透過 getter 讀所有的 field)
if (getName() == null) {
    if (other.getName() != null) return false;
} else if (!getName().equals(other.getName())) return false;

hashCode()和equals()都要用 getter(),確保在 proxy 的狀態下也能運作。總之,IDE 的 hashCode()/equals() 是針對一般 class 設計的,速度比較快,給 Hibernate 用的就還要再加工改一下。不要一口氣產生的太爽,樂極生悲....

Well, 使用像 Hibernate 這樣的 ORM 之後,條例變得超多,這並不是 Hibernate 的問題,而是 Object 和 RDBM 本身就有一段不小的差距,Hibernate 只是把這些問題浮現出來而已。其實原本 Hibernate 只是解決 ORM "一個問題" 的 solution,但反而帶出了更多的問題。有時候想想真是得不償失啊,何不乾脆回去用純 JDBC 自己手動控管算了 ?! 相信這也是為什麼有人較傾向使用 iBatis 的原因。但是 Hibernate 的種種好處又讓人割捨不下啊...

那麼自稱 Java killer 的 Rails 呢?Rails 當然不會有上面這種小問題囉,它們的是大問題 -- 同一筆資料庫的資料,在同一個 transaction 之下,Active Record 抽兩次會得到 兩個 instance。這個會有嚴重的 ACID 問題。小程式就算了,如果是銀行交易之類 mission critical 的程式,那... 你最好是超級高竿的 Rails Developer,能夠自己維護 ACID。


回響

可以用 Tag <I>、<B>,程式碼請用 <PRE>