24 February 2015

最近因為又開始搞新的 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 層。

如果能夠達到這些目標,那麼剩下的只是風格問題,可以隨各個團隊偏好調整了。


回響

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