先來看看一段 code 吧:
//verison 1
public class MovieServiceImpl implements MovieService {
public Movie createMovie(String title, Media rawContent) throws InValidMediaException {
Movie movie = new Movie(title, rawContent) ;
movieDAO.save(movie);
return movie ;
}
private MovieDAO movieDAO ;
public void setMovieDAO(MovieDAO movieDAO){
this.movieDAO = movieDAO;
}
}
public class Movie {
//omit fields....
public Movie(String title, Media rawContent) throws InValidMediaException{
this.title = title;
this.content = convert(rawContent) ;
}
private Media convert(Media rawContent) throws InValidMediaException {
//a very heavy, complex calculation...
return convertedContent ;
}
}
OK, 就兩個 class,MovieService 是 service facade,而 Movie 則是 Domain 物件。一般狀況下,我們會對 Movie 的 constrcut 作完整的 Unit Test,包含各種格式,錯誤處理...等等。Movie 只是個 POJO,所以測試上並不困難:
public class MovieTest extends TestCase {
public void testConstructMovie_avi() {
Media aviRawContent = prepareRawContent(true) ;
Movie created = new Movie("Star Trek",aviRawContent) ;
assertEquals("Star Trek", create.getTitle());
assertEquals(15603, created.getConvertedContent().size());
//... some more complex assertion for convertedContent.
assertMedia(.....);
}
public void testConstructMovie_fail() {
Media failRawContent = prepareRawContent(false) ;
try {
new Movie("Star Trek",failRawContent) ;
fail("InvalidMediaException expected") ;
} catch(InvalidMediaException expected) {}
}
public void testConstructMovie_divxFormat() {
//......
}
}
好了,接下來該測試 MovieServiceImpl 的 createMovie()。這個 method 雖然短短兩三行,可是測試的難度卻很高,第一是它需要動用到 DAO 來處理資料庫,第二是為了讓 new Movie(...) 這個 constructor 正常運作,我們要準備正確的 rawContent -- 一個很複雜的資料。對於 DAO,我們已經用 IoC 的方式來處理了,所以在測試期間,可以造一個假的 mockMovieDAO 來幫助測式的進行:
// version 1 test
public class MovieServiceImplTest extends TestCase {
public void testCreateMovie() {
//建立假的 movieDAO, 並且在 save() 裡作 assertion:
MovieDAO mockMovieDAO = new MovieDAO() {
@Override
public void save(Movie movie) {
//assert 接到的 movie 是否正確
assertEquals("Star Trek", movie.getTitle());
assertEquals(15603, movie.getConvertedContent().size());
//... some more complex assertion for convertedContent.
assertMedia(.....);
}
};
MovieServiceImpl service = new MovieServiceImpl() ;
//替換掉真正的 MovieDAO
service.setMoviceDAO(mockMovieDAO);
//準備 rawContent 資料
Media aviRawContent = prepareRawContent(true) ;
//開始測試:
Movie created = service.createMovie("Star Trek", aviRawContent) ;
//assert 結果:
assertEquals("Star Trek", create.getTitle());
}
}
對於只有兩三行的程式碼,這樣的測試未免太大了點... 而且,你會發現有一些測試程式碼跟 Movie 的 TestCase 幾乎一模一樣。換句話說,同一個邏輯重覆測了兩遍 -- code duplication -- 這是最臭的 smell !
那怎麼辦呢?
code duplication 的原因是程式碼中執行了 new Movie(...) 這一行,而 'new' operator 我們沒辦法置換為假的 construct (除非你用 AOP)。那麼接下來就有人這麼做:
//verison 2
public class MovieServiceImpl implements MovieService {
public Movie createMovie(String title, Media rawContent) throws InValidMediaException {
Movie movie = movieFactory.create(title, rawContent) ;
movieDAO.save(movie);
return movie ;
}
private MovieFactory movieFactory ;
public void setMovieFactory(MovieFactory movieFactory) {
this.movieFactory = movieFactory;
}
//omit movieDAO setter/field....
}
這樣一來,我們就可以在測試期間置換掉 movieFactory,然後假造 create(title, rawContent) 這個 method:
// version 2 test
public class MovieServiceImplTest extends TestCase {
public void setup() {
//prepare EasyMock for mock:
mockMovieFactory = EasyMock.create(....);
mockMovieDAO = EasyMock.create(....);
}
public void testCreateMovie() {
//prepare....
MovieServiceImpl service = new MovieServiceImpl() ;
service.setMovieFactory(mockMovieFactory) ;
service.setMovieDAO(mockMovieDAO) ;
//使用 EasyMock 錄製即將發生的事:
Media simpleMedia = prepareMedia() ;
Movie expectCreatedMovie = new Movie(....) ;
EasyMock.expect(mockMovieFactory.create("title",simpleMedia)
.andReturn(expectCreatedMovie) ;
mockMovieDAO.save(expectCreatedMovie);
//錄製完成,開始測試:
control.replay();
Movie created = service.createMovie("title",simpleMedia);
control.verify();
assertSame(expectedCreatedMovie, created);
}
}
version 2 我們借助 EasyMock 來建立 mock,這樣就不用自己寫 subclass 了。雖然看起來還是很長... 不過最少不再有重覆的邏輯,以後就算 new Movie() 的 construct 內容改變了,也不用花心思再去改 service facade 的 test。這個做法最大的缺點是你必須替像 Movie 這種 Domain class 另外再做一個 Factory class。Domain class 通常很多個,每個都加還得了?而且像這種 Factory 其實沒其他的用途 (不像 design pattern 裡的 Factory 是有意義的),多加只會讓系統更複雜、更難管理。
如果 Spring/IoC 用的很熟練的話,那麼很自然的想到:即然 Movie 的 construct 這麼複雜,何不把他分離出來呢?convert 的邏輯自成一個 class-- MovieConverter
//verison 3
public class MovieServiceImpl implements MovieService {
public Movie createMovie(String title, Media rawContent) throws InValidMediaException {
Media converted = movieConverter.convert(rawContent) ;
Movie movie = new Movie(title, converted) ;
movieDAO.save(movie);
return movie ;
}
private MovieConverter movieConverter ;
public void setMovieConverter(MovieConverter movieConverter) {
this.movieConverter = movieConverter;
}
//omit movieDAO setter/field....
}
version 3 的測試寫法,基本上和 version 2 差不多,也是將 movieConverter 換成 mock 造假,如此也可去除重覆的測試碼,詳細的內容我就不再寫了。而且 version 3 的程式看起來更具備 IoC 的特色 -- 將 converter 丟到外面,讓外面來決定 convert 該怎麼做,看起來似乎很棒... 然而我們回頭看看version 3 的 Movie class 變成什麼樣子:
//Movie class for MovieServieImpl ver.3
public class Movie {
//omit fields....
public Movie(String title, Media convertedContent) throws
this.title = title;
this.content = convertedContent ;
}
}
只剩下純資料.... 一個沒有行為的簡單 java bean...
這就是 Martin Fowler 所說的貧血Domain模型:Anemic Domain Model
貧血Domain模型通常會有幾個特徵:
- Domain Entity 只有資料,沒有行為。大多數的狀況下,甚至和 DB table 是 1:1 的對應
- 會有很多 "工具類" 的物件,像 version 3 中的 converter 就是一例,這類的物件通常沒有 state (沒有 instance variable),然後名稱多半以 "er" 結尾,像是 builder, converter, encoder, processor, parser, manager, formatter, producer, executor, handler, calculator... 族繁不及備載
- Service Facade 裡面的邏輯越來越多。像是 version 3 中,原本該封裝在 Movie 物件裡的邏輯,暴露在 servie 裡了。雖然 servie 管不到 convert 的細節,但是 service 已經需要知道 "rawContent 要經過 xxx 來轉換" 這件事 (version 1 則完全不用知道)
- 開發者渾然不自覺,還以為自己寫的是物件導向的程式。有一個方法可以檢驗:將程式碼的 method 改成 static 的。如果大部份的程式碼都還是正常的,那麼程式的架構其實比較徧向 procedure,而不是 OO
在 Spring、IoC和易於測試 等三者的引導下,漸漸地,程式的架構變成 Anemic Domain Model。這似乎是必要之惡,Spring 的開發人員或是使用者,或多或少都了解這個事情的嚴重性... 但也只能接受。不過,接下來的 Spring 2.0 將會有新的發展,目前得知的消息是會使用 AOP (AspectJ) 替 Domain 物件做 Dependency Injection,如果設定不會很複雜的話,將可一舉解決這個問題,讓我們拭目以待。