前些陣子都是用 NoSQL 在開發案子,最近才有機會又回到關聯式資料庫的懷抱,回到可以任意下各種查詢的日子。生產力變高了,不過寫著寫著,我又聞到一股熟悉的臭味 Bad Smell。
入鮑魚之肆,久而不聞其臭。同理,每天接觸同樣程式,如果沒有靜下來重新思考、檢視一番,程式都發臭了也無自知。我也沒有特別的能力可以自覺,靠的是什麼?Unit Test -- 單元測試這把尺。
如果測試很難寫,那八成設計有問題。
案例
我們用部落格系統來討論好了,這個大家都很熟悉,我們有 Account
、Article
、Comment
等等基本要素:
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,或是間接關聯的程式都受影響了,CommentServiceTest
和 LikeServiceTest
都掛了,只因為它們的 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。想了解更多的可以去看看。