先來看看一段 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,如果設定不會很複雜的話,將可一舉解決這個問題,讓我們拭目以待。