10 December 2005

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


回響

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