星期日, 7月 18, 2004

Object Mother: Automatic register Hibernate persistent object for tear down

寫過 Unit Test 的開發人員都曾經為了建置 fixture 而傷透腦筋吧?,尤其是準備資料庫的資料來滿足各式各樣的測試條件時更是頭痛。Object Mother Pattern 就是專為建立複雜的fixture而誕生的,先來看看一個簡單的 Object Mother 吧:

public class UserObjectMother {
public static User createNonActiveUser() throws Exception {
return new User("anonymous"); // default non-active
}
public static User createActiveUser() throws Exception {
User anonymous = createNonActiveUser();
anonymous.setActivate(true);
return anonymous ;
}
}

首先,由 createNonActiveUser() 這個 method 可以建立一個未啟用的使用者,接下來的 createActiveUser() 則是先呼叫 createNonAciveUser(),重新設定為啟用的使用者後傳出去。因此,我們可以用UserObjectMother 建立不同條件的使用者以供測試使用。而且我們可以發現 createActiveUser() 重用了 createNonActiveUser() 這個 method。 依這樣的設計延伸,我們可以再寫個 ProejctObjectMother:

public class ProjectObjectMother {
public static Project createProject(String projectName)
throws Exception {
Project prj = new Project(projectName);
prj.setOwner(UserObjectMother.createActiveUser()) ;
return prj ;
}
public static Project createProjectWithMembers()
throws Exception {
Project prj = createProject("anonymous prj");
User[] members = new User[] {
UserObjectMother.createActiveUser(),
UserObjectMother.createActiveUser(),
UserObjectMother.createActiveUser()
};
prj.addAllMembers(members);
return prj;
}
}

ProjectObjectMother (以下開始簡稱 ProjectOM, UserOM.. etc) 除了自己產生project物件外,也使用了 UserOM 來幫他建立 owner 和 members。眼尖的讀者應該看出這裡的所有 method 都是 static 的。這是 ObjectMother 的特性使然,我們需要可以隨時隨地的呼叫這些 Mother,並且任意的產生物件。想想看,當我們的 ProjectOM 呼叫 UserOM, 而 OrderOM 又呼叫 UserOM 和 ProjectOM,VendorOM 又要呼叫 OrderOM 和 UserOM... etc ,管理這麼多 OM 的 instance 也是個麻煩事啊!用 static 便能輕易的解決( 這是向 paper 裡學來的喔 )。第二個要注意的是,這些 ObjectMother 的 method 都是 throws Exception,這樣的話才不會因為 business exception 改了之後要修改這些 method,這個和 JUnit 的 TestCase 的 setup() method 一樣,它也是 throw 最大的 Exception (ObjectMother 最常在 setup() 裡被呼叫)。

OK, 接下來就是重頭戲了:tear down。為了保持 test isolation,我們必須 run 完一個 test case 就 清掉所有產生的物件。從上面的例子看來,我們不需要做任何的清理動作。程式結束後自然會被 garbage collect 掉。比較麻煩的是資料庫裡的持久化資料,因為不做清理的話,會一直影響其他的測試。很多做法是建立一個 insert 的 SQL script,配另一個 delete/drop 的 SQL script。這裡我們採用 Hibernate 的方式來產生資料庫的物件,一方面可重用原來的 createXxx 的 method,另一方面 refactoring 時才不用維護兩套系統。

public class UserObjectMother {

// other method like createNonActiveUser()
// and createActiveUser() ....

public static User savedActiveUser() throws Exception {
User activedUser = createActiveUser();
Session session = HibernateUtil.currentSession();
Transaction tx = session.beginTransaction;
session.save(activedUser);
tx.commit();
return activedUser;
}
}

這裡的 HibernateUtil 是 Thread local pattern 的產物,Hibernate 的網頁有介紹怎麼使用。如果你對這種寫法不熟,請先停止閱讀,先到 Hibernate 網頁一探究竟 (很值得學的) 。Anyway, savedActiveUser() 這個 method 重用了 createActiveUser(),並且開啟 transaction 將資料寫入資料庫裡,然後回傳一個 persistent object(PO)出去。(注意,本文中的 User, Project... 等等 都是 Hibernate 的 PO) 如此,test case 便能測試和資料庫相關的功能。好了,接下來我們要清理掉這個存過的物件。最簡單的做法就是自己寫:

public class UserTest extends TestCase {
User anonymous ;
public void setup() throws Exception {
anonymous = UserObjectMother.savedActiveUser();
}
public void tearDown() {
// begin transaction code.
session.delete(anonymous);
// commit transaction code.
}
public void testActiveUserChangeProject() {
// do some business test...
}
}

在tearDown()裡,我們用 delete() 來清理PO。如果這麼簡單,就不用 pattern 來解決囉。當 UserOM, ProjectOM, OrderOM 不斷產生新的 PO 之後,tear down 將複雜到完全無法撰寫。這時我們要進入 Object Mother pattern 的核心 -- 註冊那些新儲存的 PO,然後到後面再一口氣清掉。我們先來看看在概念上是怎麼用的吧:


public class UserTest extends TestCase {
User anonymous ;
Project newProject ;
public void setup() throws Exception {
anonymous = UserObjectMother.savedActiveUser();

// savedProject() call createProject
// then save into database.
newProject
= ProjectObjectMother.savedProject("new proejct");
}
public void tearDown() {
// purge all saved PO, including "anonymous"
// and "newProject"
RegisteredObject.getInstance().purge();
}
public void testActiveUserChangeProject() {
anonymous.changeProejct(newProject);
// do some business test...
}
}

tearDown() 只變一行了,而且不論我們用多少種 UserOM, ProjectOM... ,都只要call purge() 一次就可以清掉。我們來看看怎麼實做吧,首先,要達到 call 一次 purge() 就將所有的 PO 都清除掉,我們需要一個 singleton:

public class RegisteredObject {

private static final RegisteredObject instance
= new RegisteredObject();

private RegisteredObject() {}
private static RegisteredObject getInstance() {
return instance;
}
private List allRegisteredPOs = new ArrayList();
public void add(Object entity) {
allRegisteredPOs.add(entity);
}
public void purge() throws Exception {
HibernateUtil.closeSession(); // this step is important.
if(allRegisteredPOs.isEmpty()) {
return ;
}
Transaction tx = null;
try {
Session session = HibernateUtil.currentSession();
tx = session.beginTransaction;

// FILO, first in last out to delete PO
for(int i = allRegisteredPOs.size()-1;i >= 0;i--) {
session.delete(allRegisteredPOs.get(i));
}

tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
allRegisteredPOs.clear();
HibernateUtil.closeSession();
}
}
}

我們可以看到 RegisteredObject 是個 singleton 而且裡面有個 List allRegisteredPOs 來儲存所有註冊的 PO。然後在 purge() 裡面將所有註冊的 PO 全部清掉。purge 很長,我們等一會再看,我們先看看在 Hibernate 中要如何註冊新儲存過的 PO:

public class RegisterInterceptor implements Interceptor {
private RegisteredObject registeredObject;

public RegisterInterceptor(RegisteredObject reg) {
this.registeredObject = reg ;
}
public boolean onSave(Object entity,
Serializable id,
Object[] state,
String[] propertyNames,
Type[] types)
throws CallbackException {

registeredObject.add(entity);
return false;
}
// rest of methods: like onLoad(), onDelete().... etc.
}

RegisterInterceptor,一個 Hibernate 的 Interceptor(攔截器)。Hibernate 的攔截器 可以對 persistent 的生命週期做一些特殊的處理,功能有點像是 database 的 trigger。以這裡的 RegisterInterceptor來說,在 PO save 前的一刻,它會呼叫 onSave()這個 method。我們利用這點將該 entity 註冊到 registeredObject 裡,如此一來就可以獲得所有新儲存的 PO 了。有了 Interceptor,下一步就是安裝在 Hibernate 的 session 裡,這樣子只要是透過 session 儲存的 PO 都會被我們攔截下來並註冊。

public class HibernateUtil {

private static Interceptor interceptor ;
public static setInterceptor(Interceptor i) {
interceptor = i;
}

private static final ThreadLocal threadLocalSession
= new ThreadLocal();

public static Session currentSession()
throws HibernateException {
try {
Session s = (Session) threadLocalSession.get();
// Open a new Session, if this Thread has none yet
if (s == null) {
// add interceptor if it has one.
if (interceptor == null) {
s = sessionFactory.openSession();
} else {
s = sessionFactory.openSession(interceptor);
}
threadLocalSession.set(s);
}
return s;
} catch (HibernateException e) {
logger.fatal("error in getting a session", e);
throw e;
}
}
}

上面是 HibernateUtil 的一小部份。我們加了 setInterceptor() 的 method,讓我們從外部設定 interceptor。而 currentSession() 裡,我們多了sessionFactory.openSession(interceptor); 這一行。所以只要我們設定 interceptor 之後,HibernateUitl.currentSession() 所回傳的 Hibernate session 都會加裝攔截器,在這裡我們當然會設定為 RegisterInterceptor 囉!我們在看看改寫過的 UserTest:

public class UserTest extends TestCase {
User anonymous ;
Project newProject ;
public void setup() throws Exception {
// create interceptor
RegisiterInterceptor interceptor
= new RegisiterInterceptor(
RegisteredObject.getInstance());

// setup interceptor for all created session
HibernateUtil.setInterceptor(interceptor);

anonymous = UserObjectMother.savedActiveUser();
newProject =
ProjectObjectMother.savedProject("new proejct");
}
public void tearDown() {
RegisteredObject.getInstance().purge();
}
}

setup 裡多了兩行,一行是 new 一個 interceptor (用 singleton RegisteredObject 當容器),第二行是將interceptor設在 HibernateUtil 裡。如此一來,所有經由 HibernateUtil 拿到的 session 都會對儲存的物件註冊;而到了 tearDown(),再執行 RegisteredObject 的 purge() 就可以全部清掉了。好,現在回過頭來看 purge() 是怎麼寫的。

首先 purge() 的 第一行就是 closeSession() ,這一行是必要的,因為在測試的 method 中,有可能會開第二個 session 去 load 第一個 session 所產生的 PO 。依我們之前的設計,第一個 session 所產生的 PO (session1PO) 通常都是由 ObjectMother save 的,同時也被攔截下來放到 RegisteredObject 裡。這時,如果第二個 session 如果又去 load 同一個 PO (session2PO),這樣執行 session.delete(session1PO) 時,Hibernate 會 throw exception 說不能刪除尚有 session 連結的 PO (session2PO 還在跟 session2 連著),因此我們要先將 session2PO "離線",將session close,使它也變成 detached 的物件。這樣我們才能將 session1PO (這個也是 detached) 順利的 delete() 掉。

第二個就是 delete 的順序。在這裡我們採用 FILO 先進後出的順序來 delete() PO。這個是與 Database 的 foriegn key (FK) 有關。當 insert 資料到 database 時,一定是depend 最多 FK 的物件先 insert ,才會輪到 FK 物件 insert,依此類推,最後 insert 的物件是完全不會有被 FK 指向到的,因此這樣的物件是可以先 delete 掉,而不會有 FK 衝突。因此我們清除的順序是先將後到的 PO delete,再漸漸的往前推,直到最早 save 的那一個物件為止。

第三個是 finally 裡面的 allRegisteredPOs.clear(); 因為 RegisteredObject 是 singleton ,我們每次執行 purge 之後就要將註冊的內容清除,不然一個個 test case 執行下去會讓 allRegisteredPOs 越來越大,也不合理。

最後,這裡還有一個隱含的功能:按 FILO 的順序是沒辦法正常清除掉可以 null 的 FK 關係的。因為他們兩者都可以先獨立儲存 (不分順序) ,然後再連成 FK 的關係,這樣一來按 FILO 刪除就沒意義了,因為可能要先移除的反而先儲存,順序就錯了。好在 Hibernate 的 "Transactional write behind" 可以輕鬆解決這個問題。只要在同一 transaction 裡,Hibernate 會知道誰要先清掉,然後重新安排 delete 的順序。也許你會說那不就不用 FILO 了嗎? FIFO 就可以了啊.... etc。這裡我只能說,經實驗證明,是不可行的。原因是什麼我也不清楚。也許是 Hibernate 內部的排序上有極限,又有可能是即使 PO 間有互相 FK 的資訊,理論上也是不可能完全到推出當初儲存的順序,後者比較有可能吧.... 我想。

Summary:
這篇我們實作了二個簡單的 ObjectMother: UserOM 和 ProjectOM,而在 UserTest 裡我們使用它們在 setup() 和 tearDown() 裡。以 static method 的方式來實作 ObjectMother 可以讓我們在 setup() 或者是其他建立 fixture 的地方任意產生物件。對於資料庫相關的物件我們使用 Hibernate PO 的方式來建立。為確保所有 ObjectMother 所產生的 PO 都能正確、完全的清理,我們建立了 RegisteredObject 這個 singleton 來存放所有欲清理的 PO。而RegisterInterceptor 這個攔截器則攔截所有要儲存的 PO,註冊至 RegisteredObject 裡。最後經由 FILO 及 transactional write behind 的方式在 tearDown 處 purge 掉所有新儲存的 PO。藉由 Hibernate,讓 database 資料的 setup tearDown 更為簡單,又完成了一項不可能的任務!Again, Hibernate Rocks !

note:
RegisteredObject, RegisterInterceptor 兩者是 ObjectMother 該有的功能,全部整合在一個 BaseObjectMother 裡,然後讓 UserObjectMother, ProjectObjectMother... etc 去繼承它,這樣才算是完成了 Object Mother Pattern,這個留給讀者回去自己實作吧。

0 Comments:

張貼留言

<< Home