08 February 2014

前些陣子都是用 NoSQL 在開發案子,最近才有機會又回到關聯式資料庫的懷抱,回到可以任意下各種查詢的日子。生產力變高了,不過寫著寫著,我又聞到一股熟悉的臭味 Bad Smell

入鮑魚之肆,久而不聞其臭。同理,每天接觸同樣程式,如果沒有靜下來重新思考、檢視一番,程式都發臭了也無自知。我也沒有特別的能力可以自覺,靠的是什麼?Unit Test -- 單元測試這把尺。

如果測試很難寫,那八成設計有問題。

案例

我們用部落格系統來討論好了,這個大家都很熟悉,我們有 AccountArticleComment 等等基本要素:

CREATE TABLE Account (
   id SERIAL PRIMARY KEY,
   name text
);
CREATE TABLE Article (
   id SERIAL PRIMARY KEY,
   author_id INT REFERENCES Account (id),
   content text
);
CREATE TABLE Comment (
   id SERIAL PRIMARY KEY,
   article_id INT REFERENCES Article (id),
   commenter_id INT REFERENCES Account (id),
   content text
);

--- ps. NOT NULL 的宣告省略

上面是個簡易的正規化設計,先有 Account,然後由他 (作者) 再建立 Article,最後其他用戶能再上面留言 Comment。我們來看看簡單的新增文章的測試:

//我們要測試的 service
class ArticleService {
  Article create(Account author, String content) {
    //...
  }
}

//Unit Test
class ArticleServiceTest {
  @Test
  public void createArticle() {

    //準備作者
    Account author = createAccount("Alice");

    //測試主程式
    Article article = articleService.create(
        author, "My first post!");
    assertEquals("Alice", article.getAuthorName());
    //....
  }
}

OK,沒什麼特別的,就測試建立文章之前,先用一個工具 method 產生一筆作者,然後再建立新文章,最後驗證作者是同一人... 等等。

接著就是寫建立 Comment 的功能啦,我就直接列測試了:

class CommentServiceTest {
  @Test
  public void leaveComment() {

    //準備 Fixture
    Account author = createAccount("Alice");
    Article article = articleService.create(
        author, "My great post!");
    Account commenter = createAccount("Coby");

    //測試主程式
    Comment comment = commentService.leaveComment(
        commenter, article, "頭香");
    assertEquals("Coby", comment.getCommenterName());
    //....
  }
}

喔,越來越長了,真正跑的是 commentService.leaveComment() 這一行,不過它前面已經有三行的準備工作。準備測試用的輔助物件,術語上稱做 Fixture。到此為止還好,不過後來需求增加了,客戶想在 Comment 上要可以按讚,於是乎又加了一個新的 table,追加了一些測試:

CREATE TABLE CommentLike (
   id SERIAL PRIMARY KEY,
   comment_id INT REFERENCES Comment (id),
   liker_id INT REFERENCES Account (id)
);
class LikeServiceTest {
  @Test
  public void likeComment() {

    //準備 Fixture
    Account author = createAccount("Alice");
    Article article = articleService.create(
        author, "My great post!");
    Account commenter = createAccount("Coby");
    Comment comment = commentService.leaveComment(
        commenter, article, "頭香");
    Account liker = createAccount("Luffy");

    //測試主程式
    likeService.like(comment, liker);
    int likeCount = likeService.getLikeCount(comment);
    assertEquals(1, likeCount);
    //....
  }
}

新的 table CommentLike 儲存用戶對 Comment 的按讚。而測試呢? LikeServiceTest為了測試一個按讚,足足寫了五行的 fixture,呼~~ 好不容易!

有好習慣的程式設計師,大概開始重構,像是建立 Comment 的那幾行程式,通通包成一個 createComment() 的工具 method 重覆使用。然後可以用來測更多的案例,像是查出一天內最多留言也最多讚的文章等等。

又臭又長的測試程式重構的乾乾淨淨,下班收工。


直到一個月後...

新需求來了,Article 在新增前要先有類別 Category,於是乎工程師開始加 table,加功能、修改 Article 相關的程式。本來嘛,理論上只有 Article 的相關測試要變紅燈,但實際上所有直接關聯到 Article,或是間接關聯的程式都受影響了,CommentServiceTestLikeServiceTest 都掛了,只因為它們的 fixture 裡都有 Article。

多了一個需求,一大堆的測試都受影響... 如果你有寫過測試,這件事是你我每天都會碰到的,讓人苦皺著眉頭維護。沒有寫過測試的,相信我,你會痛恨這玩意。

Break Deep Associations

軟體因為變動一部份的功能,造成連珠炮的效應,我們知道這是程式結構不夠模組化,元件間相依性過深造成的,物件導向給了我們工具解決,那麼關聯式資料庫該怎麼辦呢?下面介紹一個技巧:

將 Comment 與 CommentLike 拆成三個 table,它多一層叫 Likeable

CREATE TABLE Likeable (
   id SERIAL PRIMARY KEY
);

CREATE TABLE Comment (
   --- comment_id 來自 Likeable 的 PK,而不是自動產生
   comment_id INT PRIMARY KEY,
   article_id INT REFERENCES Article (id),
   commenter_id INT REFERENCES Account (id),
   content text,
   --- 與 Likeable PK 關聯 
   FOREIGN KEY (comment_id) REFERENCES Likeable(id)
);

--- CommentLike 變成 ItemLike,它不關聯 Comment 了,
--- 只關聯 Likeable
CREATE TABLE ItemLike (
   id SERIAL PRIMARY KEY,
   likeable_id INT REFERENCES Likeable (id),
   liker_id INT REFERENCES Account (id)
);

接著,我們將 CommentLike 改成 ItemLike,它現在只關聯 Likeable,換句話說,任何 Likeable 的資料都可以被按讚,不限於 Comment。不過現在新增 Comment 的流程變了,原本它是直接 insert ,靠著 SERIAL 這個型別自動產生 Primary Key (PK) 。要改成先新增一筆 Likeable,然後 Comment 的 PK 要借用 Likeable 的 PK,並且要建立關聯。這麼一來,Comment 就是一種 Likeable,它可以被按讚。下面是建立 Comment 的範例 Dao:

class CommentDao {
  Comment create(Account author, String content) {
    long likeableId = likeableDao.createLikeableId();
    Comment comment = new Comment(
        likeableId, author, content);
    insertIntoComment(comment);
    return comment;
  }
}
class LikeableDao {
  long createLikeableId() {
    return executeSql(
        " INSERT INTO Likeable (id) "
      + "      VALUES (DEFAULT) "
      + "   RETURNING id " );
  }
}

改變不多,只是多一步產生 likeableId 的步驟,其他的 SQL 我就不列了,只是單純的 insert。

經過這一番重構,LikeServiceTest 測試現在變成什麼樣了?

class LikeServiceTest {
  @Test
  public void likeSingleItem() {

    //準備 Fixture
    Likeable likeable = likeableDao.createLikeable();
    Account liker = createAccount("Luffy");

    //測試主程式
    likeService.like(likeable, liker);
    int likeCount = likeService.getLikeCount(likeable);
    assertEquals(1, likeCount);
    //....
  }
}

喔!測試變超短,不用說自然寫的輕鬆,而且以後動到 Article、Comment、Category 什麼的都沒有影響了!程式更加模組化。

與物件導向的相似性

難寫的測試、難維護的測試,都會降低寫測試的意願,到最後就乾脆不寫了,或是放給它爛。

常寫單元測試的開發者,多半已經養成遇到難測的元件時,就替該元件抽一個 interface 隔離,比方說用 Amazon 送 Email 這樣的外部服務,就多抽一個 MailSender 的 interface 將它隔開。測試的時候就可以 mock 這個 MailSender,讓測試好寫。而到了部署的時候再藉由 Dependency Injection 注入真正的實作,例如 AmazonEmailSender

而我們上面討論的部落格例子,Article、Comment... 等不是元件,它是資料、它是 entity,但如果它的關聯很長很深,也是一種難測的因子。我們透過 Comment 和 CommentLike 之間多抽一層 table Likeable ,將它們隔離。這跟 class 抽 interface 其實很像的,都讓耦合下降,提高模組化。

Polymorphic associations

Likeable 這個中介 table 通常不會有人在設計之初就這樣做的。不過,如果需求一開始就是 Article 和 Comment 兩者都要可以按讚,那麼這個 Likeable 就會很自然的浮現 -- 在新增兩者之前,它們都會先新增一筆 Likeable ,沿用它的 PK,並且都建立 foreign key。這叫做 polymorphic associations:

--- 'item_type' can be 'article' or 'comment' ...etc
CREATE TABLE Likeable (
   id UUID PRIMARY KEY,
   item_type text
);
CREATE TABLE Article (
   article_id UUID PRIMARY KEY, --- from Likeable
   FOREIGN KEY (article_id) REFERENCES Likeable(id)
);
CREATE TABLE Comment (
   comment_id UUID PRIMARY KEY, --- from Likeable
   article_id UUID REFERENCES Article (article_id),
   FOREIGN KEY (comment_id) REFERENCES Likeable(id)
);
CREATE TABLE ItemLike (
   id UUID PRIMARY KEY,
   likeable_id UUID REFERENCES Likeable (id)
);

我大概簡單的列一下,詳細我就不說了,看著上面的 table 應該可以理解多型的關聯是怎麼設計的,你也可以參考一下 StackOverflow 上的討論。 (PK 我換用 UUID 了,知道為什麼嗎?)

不過,我的想法是,不見得要等到有兩類或三類以上可以按讚的東西 ,Likeable 才有存在的價值。如果關聯過深,或是盤根錯結,你在寫測試時很痛苦, 聞到了這樣的臭味,那麼就應該停下來,想想看怎麼樣改進。而本文討論的用中介 table 切斷關聯不失為一個好方法。

小結

All problems in computer science can be solved by another level of indirection.
--- David Wheeler

大師有說,所有資訊科學的問題都可以靠多一間接層來解決。這一篇討論也出不了這個範疇。關聯過深可以拆個中介 table 來解決,那麼什麼是關聯過深?三層算嗎?還是要五層?簡單的判斷法是靠檢視測試好不好寫,不好寫就是改良設計的時機,而這就是所謂的 Test Driven Design -- 讓測試來指引我們的設計。

02/09 Update

這次的程式碼有點多,我做了一份 範例程式 在 github 上 。裡面有完整的測試和資料庫的 schema,並且 demo 怎麼做 polymophic associations 和 multiple associations。想了解更多的可以去看看。


回響

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