最近因為又開始搞新的 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 層。
如果能夠達到這些目標,那麼剩下的只是風格問題,可以隨各個團隊偏好調整了。