最近因為又開始搞新的 pet project,所以又開始了設置專案 -> 開發 -> 初次部署這樣的循環。光是設置專案,也就是基礎建設像是設定檔、gradle build scripts、資料庫之類的設定,就搞了我快一個禮拜。其實 java 建構基礎設置,現在有個工具叫 jhipster,幾個指令就可搞定了。不過我這人偏偏沒辦法接受 jhipster 產生的空白專案,因為它產生的設定和我想要的有一段距離。只好苦哈哈的自己慢慢刻...
好了,設置專案這檔事未來有機會再談。今天談的是第二步驟,開發需求時,邏輯該擺在哪一層。每個物件的責任歸屬,隨著需求明確之後,才能夠 modeling、以及分派職責。不過在設置開發的初期,大方向及階層是可以先規劃好的。一個傳統的 Java server 應用程式,我們通常會這樣分層:
- Controller
- Service
- DAO (Data access object)
- Model (POJO)
當然這是大方向區隔。除了這四層,通常還會有像是 Task 類 (專做 batch job),專門處理 security 的工具等等。Front end js 的部分,現在百花齊放,不過大致上也脫離不了 MVC 的準則,這裡就先不提它們。
回到剛才的四層,這我已經寫了很多年了,大概也知道誰該負責什麼,相信有些經驗的人對分辨層級沒有困難。不過,企業邏輯該擺哪困擾我很久了,看個例子:
// Type 1: 在 service 層產生
public class ArticleService {
public Article create(String author, String content) {
User user = userDao.findByName(author);
checkPermissionForCreatingArticle(user);
Article article = new Article();
article.setAuthor(user);
article.setContent(HtmlUtil.escape(content));
article.setCreateTime(Instant.now());
articleDao.save(article);
return article;
}
}
這是一個典型的 service method,功能是建立文章,這 method 的實作有善盡各個層級的職責,像是這個 service 就管了權限、資料的保護、生成與儲存,想必使用它的 Controller 不需要煩惱這些邏輯。而這個 method,我們也看不到任何資料庫的操作,所以 Dao 也是有藏好它的實作。
這些是家常便飯,沒什麼。那麼看看下面的:
// Type 2: 在 Dao 層產生
public class ArticleService {
public Article create(String author, String content) {
User user = userDao.findByName(author);
checkPermissionForCreatingArticle(user);
return articleDao.insertArticle(user, content);
}
}
class ArticleDao {
public Article insertArticle(User author, String content) {
Article article = new Article();
article.setAuthor(user);
article.setContent(HtmlUtil.escape(content));
article.setCreateTime(Instant.now());
long generateId = jdbcHelper.insertObject(article);
article.setId(generatedId);
return article;
}
}
這是另一種變型,你可以看到我把 entity 物件 Article 的產生放到 Dao 層去了。Dao 裡實際的資料庫操作,我們略過不提,暫時用個 jdbcHelper 代表。不過,不管底層換什麼,大致上會產生個 unique ID,然後再設回給 entity。再來看看別的寫法:
// Type 3: Model 層發生
public class ArticleService {
public Article create(String author, String content) {
User user = userDao.findByName(author);
checkPermissionForCreatingArticle(user);
Article article = Article.create(user, content);
return articleDao.insert(article);
}
}
public class ArticleDao {
public Article insertArticle(Article article) {
long generateId = jdbcHelper.insertObject(article);
article.setId(generatedId);
return article;
}
}
public class Article {
public static Article create(User author, String content) {
return new Article(
author,
HtmlUtil.escape(content),
Instant.now());
}
private Article(User author,
String content,
Instant createTime) {
//略過 constructor fields...
}
}
又換另外一種寫法了,這次是新增的邏輯歸在 entity 本身,讓 Service 層使用。當然也可以將呼叫 Article.create() 這個 method 從 Service 層搬到 Dao 層去,這又是第四種寫法了。
好了,困擾我的問題是,哪個做法才是對的?上面三種做法,我都在各個專案裡使用過,而且是混合使用,一下用 type1,一下用 type3... 甚至更多種的小變型。雖然是混著寫,不過到是沒有因為用了哪一種,而出過大錯。這有可能是因為我寫的是 Java,它的 IDE 和 compiler 太強大,小問題不容易發生;或者是我們團隊在 service 層都有完整的 Unit Test 保護;又或者是因為有 pair programming,小錯誤不容易犯,才能安然渡過。
沒出包過不代表未來不會出包,而且開發時沒有固定的規則容易卡住,卡在到底放哪邊才好這種問題上。另外,程式碼其實還蠻難追的,尤其是專案變大後,混著多個風格的程式,讀起來也累,維護的人也是無所適從,這可不是長遠之計。
趁著這一次的新專案開發,我有機會再重新思考一次這個問題:邏輯歸何處?
邏輯歸何處
我的結論是都可以。
What ?
都可以不是代表可以隨便寫,只是代表沒有所謂的 唯一的位置 。你可以根據邏輯的適用範圍調整,或是根據使用的 framework 調整。像上面的 type 2 寫法,就適合純 JDBC 的 Dao,而 type 3 則是合適 hibernate/JPA 這類的 DAO。
但是,有一個最大的前題:
不論你的邏輯放在何處,在任何時候,你的物件都要是完整的
這意思是,當有別的開發者,使用你設計的 Model,不論他怎麼呼叫,他都不會拿到一個破損的物件。比方說上面的 Article,你不能設計成建構物件後,但是卻沒有作者這種違反事實的邏輯發生。按這個規則,其實上面的 type 1 範例是錯的。因為它開放 Article default constructor,以及一堆 setter 給別層級的程式呼叫。
所以理想的 Model 物件,應該類似 type 3 的 Article 那樣。把 constructor 關起來,提供安全的 static factory method 給外面呼叫。如果能進一步將 Model 物件做成 immutable,那是更好的了,因為怎麼樣使用都不會壞。
接下來輪到 Dao。Dao 的真正職責是物件的儲藏,這也是為什麼也叫 Repository。我們從倉庫取出物品,使用後,再放回,等著下次使用,物件的生命週期有很大一部份都跟 Dao 習習相關。Dao 這層要確保經過 CRUD的操作後,你拿到的 Entity 也是完整的,合乎邏輯的。例如 Article 上的 ID。ID 在大部份應用裡都是由資料庫產生,所以 Dao 經手過的 Model 物件,要保證 ID 不能是 null,要保證 ID 在整個系統裡是唯一。
在 完整物件 這個規則下,我們可以這樣設計 Entity 與 Dao:
/**
* 這兩個 class 的目錄結構:放在同個 package 下
*
* src/main/java/.../article/Article.java
* src/main/java/.../article/ArticleDao.java
*/
public class Article {
public static Article create(
User author, String content, Instant createTime) {
Objects.requireNonNull(author);
Objects.requireNonNull(content);
Objects.requireNonNull(createTime);
return new Article(
null,
author,
HtmlUtil.escape(content),
createTime);
}
// constructor 的 scope 是 package private,可以給
// 同一層的 ArticleDao 呼叫
Article(Long id,
Long author,
String content,
Instant createTime) {
// 略過欄位
}
// setter 也是 package scode
setId(Long id) {this.id = id);
}
public class ArticleDao {
public Article create(
User author, String content, Instant createTime) {
Article article = Article.create(author, content,
createTime);
Long id = jdbcHelper.insert(article);
//setId() 是 package scoped
article.setId(id);
return article;
}
//實際的 SQL 實作略過,不過可以看出 Dao 呼叫了 Article 的
//package scoped constructor
public Article findById(long id) {
ResultSet rs = jdbcHelper.query(" SELECT * FROM...");
return new Article(
rs.getLong("id"),
// .... 略過
);
}
}
ok,這範例有點長,有幾個重點:
Article Model 完整的保護
任何外面的 method 使用 Article 時它都會拿到完整 Article。因為 Article.create 好好的封裝了邏輯,它確保該有的資料都有,content 也有經過安全的 html escape。
ArticleDao 與 Article 同 package
而這裡的 Dao,在操作過程裡,會呼叫 Article 的 package scope setId() 及 constructor,這兩個呼叫其實是穿過 Article 的封裝直接呼叫的,是不安全的操作,所以在設計上,我將 Dao 和 model 物件放在同個 package 下,在 package 這個層級封裝。外面的人不管怎麼呼叫 Dao,他拿到的都是個完整的,有正確 ID 的 Article,而它也不能亂改亂破壞我傳給他的物件。
ArticleDao 作為 Entity Factory
Article 這個 entity 現在已經是建全的物件,所以放在哪使用都是 ok:
// 第一種,在 Dao 建立 Entity,也就是上面的範例,再重覆一次做為比較:
public class ArticleService {
public Article create(String author, String content) {
//...
Article article =
articleDao.create(user, content,
Instant.now());
//...
}
}
public class ArticleDao {
public Article create(
User author, String content, Instant createTime)
Article article = Article.create(author, content,
createTime);
//insert db and setId() ... etc
}
}
////////////////////////////////////////////////////////
// 第二種,在 Service 呼叫:
public class ArticleService {
public Article create(User author, String content) {
//...
Article article = Article.create(user, content,
Instant.now());
articleDao.insert(article);
//...
}
}
public class ArticleDao {
public void insert(Article article)
//insert db and setId() ... etc
}
}
在 Model 與 Dao 都能產生完整物件後,上面這兩種擺法就都可以了,沒有唯一解。不過第一種做法賦予了 Dao Entity Factory 這樣的角色,Dao 不再只是倉庫,而且還是工廠了。換句話說 entity 的整個生命週期:生成、讀出、修改、死亡都是歸 Dao 統管。大部份的情況下,我個人比較偏好第一種 Dao 兼任 factory 的設計,偶而混用第二種做法。不過這就像大括號要不要斷行一樣,見人見智,團隊裡有統一的風格就好。
Article.createTime 放在哪建立
這是題外話,不過這也是煩了我很久的問題之一:
class Article {
static Article create_1(
User author, String content, Instant createTime) {
return new Article(..., createTime);
}
static Article create_2(
User author, String content) {
Instant createTime = Instant.now();
return new Article(..., createTime);
}
}
上面的 createTime 一種是外面給的,另一種是自己產生。而這問題在 Dao 也有,createTime 該是 Dao 自己產生,還是由 service 層傳過來?
我的結論是,時間是個非常難控制的 side effect,所以 Model 和 Dao 這兩層都不該自己建立,該從上一層傳過來。如果有寫 Dao Unit Test 的經驗,就會了解時間從外面傳讓測試好寫太多 (資料庫查詢常常會有時間的排序)。
從時間這個問題延伸下去,我衍生出了一個守則:
任何難以預測的 side effect,都不該由 Model 或 Dao 層處理
舉些例子吧,像是 Article.content 的 HtmlUtil.escape() 這個邏輯該放在哪呢?escape 的邏輯可能很複雜,但是它沒有 side effect,它是 pure function,傳入 A 就得到 B,不管呼叫幾次。所以,它可以放在 Article 這個 model 裡,自己處理。
但是像 User.passwordHash 這樣的案例呢?用戶的帳號物件裡會放 hash 過的 password,以供登入時驗證比對。而這個 hash 因為安全性的要求,就算是同個密碼,每次算出來結果都要不一樣。而這就不是 pure function 了,所以 passwordHash 就會是由 service 層算好傳給 User/UserDao。
ps. 如果你設計的 password hash 同個密碼算出來的都一樣,那你該上思過崖,面壁思過個一年好好反省。如果你設計的 password 是存明碼,那也不用面壁了,直接跳崖吧!
總結
階層式架構是非常常見的設計,今天討論的是 controller/service/dao/model 這樣的四層架構。熟練的開發者,會懂得將處理 HttpServletRequest 的邏輯留在 Controller 層,不會洩露到 Service 層;也會將 SQL 語法留在 Dao 這一層,不會到處流竄。這是很直覺,很容易做到的。本文討論的則是企業邏輯,尤其是物件的生成這部份,該放在何處才是最佳解。
我的結論是放在何處並不是最重要的,重點是不論從哪一層存取物件,該物件都要合乎邏輯,而且強韌不易損壞。為達這個目標,大部份邏輯會一直下放,下放到 Model 層,而 Model 層穩固了,上面的層級自然也不容易壞。而 Dao 則是設計成與 Model 同個 package,雖然這違反了許多人的做法(大部份的人習慣所有 dao 自成一個 package),不過,畢竟 Dao 掌管了物件的生命週期,與 Model 習習相關,它們要放在一起設計,一起使用。
另外也提到了 side effect 相關的邏輯該放在何層。理想情況下,希望能在 model 層處理。但是難以預測的 side effect 會讓 Model/Dao 過於依賴外部資源,而且測試非常困難。因此我的建議是,非 pure function 的邏輯應該屬於負責 integration 的 Service 層。
如果能夠達到這些目標,那麼剩下的只是風格問題,可以隨各個團隊偏好調整了。