使用函式式語言實踐DDD

richiezhang發表於2021-03-09

長期以來我都在實踐OOP,進而通過OOP來實現DDD,特別是如何通過物件導向的技巧來建立一個領域模型。OO的一些特性在建立領域模型時顯得恰如其分,能否掌握OO的技巧,對建立領域模型有著至關重要的作用。
這篇文章為大家介紹一種常見的函式式架構,特別是如何通過函式式語言來實現DDD,進而利用函式式組合的特性,建立函式pipeline。
軟體架構是圍繞著領域模型而做的若干設計,如果按照c4模型的定義,軟體架構由下面四個級別的架構組成的:

  • "System context"是最高層的架構,代表著整個系統
  • "Container"是組成"System context"的單元,通常用來表示可部署的單元,例如一個"API service", 一個web應用程式等
  • "Component"是組成"Container"的基本單元,通常指組若干抽象元件,是一個"Container"裡面的骨架,也是本文要重點介紹的架構
  • "Code"具體到了程式碼級別,通常指實現某個"Component"應該有哪幾個類組成

使用單體應用來承載多個限界上下文

領域驅動設計中有一半概念是在討論問題域,並不是一上來就教你如何寫程式碼,這說明理解一個問題域是複雜的,看清問題的本質是需要時間的。當你開始著手劃分限界上下文的時候,說明你已經對需求有了很好的瞭解。但是經驗告訴我們,剛開始你的理解,往往都不是最終的需求,或者仍然需要多次跟領域專家確認和互動,才能得到最終的需求。
這個時候,如果你一上來就按照限界上下文劃分微服務,往往可能會步入Microservice Premium
要想軟體在一開始就能達到快速試錯的目的,一上來就做微服務, 會讓步子邁得有點大。微服務架構帶來了分散式的複雜性,使得前期生產效率大大降低,另外還存在船大難掉頭的情況,一旦設計出現返工,生產效率也會打折扣。當然,這不是絕對的,如果架構師已經在該行業深耕多年,對業務更是瞭如指掌,專案一開始就設計為微服務也未嘗不可。
在專案初期,在需求還不是非常明確的時候,你完全可以建立一個單體應用,然後通過不同的模組或程式集來隔離不同的界限上下文,通過不斷的試錯和快速反饋來調整你的解決方案。
一種比較嚴格的說法是,當你關閉其中一個微服務,如果整個應用程式都崩了,其實你設計的不是一個微服務架構,而是一個分散式單體應用程式。

程式碼結構

在過去的若干年裡,我經常使用一種叫“Layer architecture"的軟體架構, 這種架構往往把程式碼分成若干層:

  • 基礎設施層:通常用來負責跟第三方或者資料庫打交道,用來持久化資料或者API請求。
  • 領域層或者業務邏輯層:用來封裝業務邏輯
  • 應用程式層:通常是很薄的一層,用來協調領域層和基礎設施層
  • 展現層:用來展現UI或者輸出API結果
    這種架構方式是一個自上往下的輸入,最後從下往上輸出結果的工作流(圖1)。

    實際上,當我在使用這種方式組織程式碼時,遇到最大的挑戰在於:這種分層方式,把同一個輸入到輸出的的若干部分,橫向的分散到了若干層中。當你需要修改某個API時,需要同時修改若干個層。另外這種組織程式碼的方式,往往會讓OO走向混亂,一個名叫OrderApplicationService的類中放滿了各種跟Order相關的方法,通常對Order的操作有數十種之多,他們屬於OrderApplicationService嗎?如果屬於,任何一個跟Order相關操作的引數變化,都會引起這個類被改動,這種對類的頻繁修改合理嗎?
    函數語言程式設計中,更傾向於縱向組織程式碼(圖2),

    例如一個API操作,就是一個檔案或者模組,整個操作自上而下的流程被組織到同一個檔案裡,這樣做的好處是,針對某個功能的修改,只關注與當前工作流相關的檔案即可。

信任邊界

在問題域裡,各種業務之間的邊界是模糊的,限界上下文則是業務在解決方案上的對映,是人為劃分的邊界。在邊界裡面的內容,是可信任和合法的,相反,界限外面的一切輸入,則是非法和不可信任的(圖3)。

這就要求我們在限界上下文的邊界,引入驗證邏輯,從而阻止外部輸入,以及驗證對外部的輸出。
常見的驗證邏輯如:

  • 輸入DTO,需要轉化為領域模型,用於處理業務邏輯
  • 對輸入資料的合法性驗證,例如:使用者名稱不能為空,郵件格式是否正確
  • 對輸出型別的安全性校驗,例如:防止在輸出資料裡包含使用者密碼等敏感資訊
    驗證邏輯並不是FP獨有的,不過FP中常常使用Applicative對資料進行驗證,從而收集多個使用者Error。關於Applicative, 以後會單獨寫文章介紹。
    一旦輸入資料突破信任邊界,在領域模型建模的過程中,你不需要擔心使用者名稱是否是空,郵件格式是否正確等問題。你應該專注於使用FP的代數資料型別進行領域建模,請參考我之前寫過一篇使用函式式語言來建立領域模型--型別組合
    對輸出的驗證則不太一樣,主要關心對輸出資料的安全性保護,防止將一些領域模型中的私有屬性輸出到外部世界。

通過狀態機來處理業務邏輯

縱然,通過FP的代數資料型別(Algebraic data type)能夠快速完成領域建模,但是我們知道,領域模型不是靜態的,它是由一些列事件組成的過程。而這種轉化過程,正是領域模型狀態發生變化的過程,即狀態機(圖4)。

領域模型狀態轉換的過程跟實現語言無關,一個設計精良的領域模型,就好比一個狀態機。例如在買機票的過程中,填寫個人資訊,填寫聯絡人,選座,買保險和付款的過程,就是訂單狀態發生變化的過程。再比如使用者註冊的過程,填寫基本資訊,驗證郵箱,也是使用者資訊狀態發生變化的過程。以OO為例,我們習慣於通過增加標誌位的方式,進行領域建模:

type User = {
  name: string
  password: string
  email: Email | null 
  isEmailVerified: boolean //當驗證完email後設定為true
  canLogin: boolean //當email被驗證後方可login
}

業務邏輯的實現過程,就是填充使用者屬性和修改標誌位的過程。然而,這種方式實際上存在若干問題:

  • 有些屬性在業務前期是不需要的,例如canLogin, 只有驗證完email才有效
  • 有些標誌位實際上不是單獨存在的,例如isEmailVerified就跟email是緊密相關的,而這個模型無法反映出來這一資訊
  • email被定義為可空型別,導致使用該模型的地方不得不使用null檢查
    通過狀態機的機制,重新考慮使用者註冊過程:(圖5)

按照上面的狀態重新對使用者建模,得到的模型如下:

type UnVerifiedUser = {
  name: string
  password: string
}

type VerifiedEmailUser = {
  name: string
  password: string
  email: Email
}

type User =
  | UnVerifiedUser
  | VerifiedEmailUser
  

如果有更多的使用者狀態,你還可以持續新增到User型別中。
這種通過"|"建立的User型別被稱為在FP中被稱為union型別,也叫product或sum型別, 在TypeScript被稱為Discriminated union。這時候的User型別,可以用來在領域模型中實現領域邏輯,通常這種union型別需要配合模式匹配來完成,例如修改密碼,登入,修改郵件地址等邏輯,都是針對User型別做模式匹配的過程。關於模式匹配的用法,在此不再細說。
這種通過狀態機的方式,實現業務邏輯時有下面幾個好處:

  • 業務模型在不同的狀態,提供不同的業務能力
  • 模式匹配會強制你處理每種狀態的行為,避免遺漏一些邊邊角角的情況
  • 相比於將所有狀態記錄在同一個模型中,狀態機可以幫你梳理整個業務狀態的變化

保持純淨的領域模型

函數語言程式設計的一個主要目標就是讓程式碼有預測性,通過函式簽名理解函式的用途。為了達到這個目的,函式式語言設計了若干特性,例如不可變的資料結構,還有各類Monad來避免副作用。在DDD實踐中,應該避免I/O相關的程式碼出現Domain中。例如讀寫資料庫,呼叫第三方系統的API等相關程式碼,需要把這類具有副作用的程式碼推到Domain的外圍。如果需要做的更好,那就必須使用CQRS加Event Sourcing。我在之前一篇文章提到過這個觀點,不過部分讀者沒有理解其中的意思,我在這裡再做一些說明。首先,CQRS不僅僅是為了讀寫分離,從而提高讀寫效能。讀模型和寫模型(領域模型)的分離意味著職責也是分離的,從而在設計領域模型的時候,打消對查詢效能的考慮,有助於設計出純淨的領域模型。當然僅靠CQRS還是不夠的,有些時候任然無法完全脫離資料庫的考慮,因為領域模型始終是要持久化在資料庫裡,你就要考慮資料庫相關的約束,例如主外來鍵,如何建表,如何高效儲存一個列表等。而持久化一個Event則完全擺脫了資料庫技術,因為一個Event就是一個json, 只有這樣才能設計出理想的領域模型。當然引入CQRS和ES在專案初期成本略高,不再詳細描述。

通過Monad建立pipeline

以API為例,一個完整的使用者請求就是一個Pipeline(圖6)。

假設每一步都是有若干個函式組成,我們能夠將他們組合到一起嗎?答案是很難,主要原因如下:

  • 每一步的若干個函式簽名很難保持一致,導致compose這樣的函式無法正常工作
  • 部分I/O相關的函式可能是非同步的,領域模型中的程式碼大多是同步的,很難將他們組合在一起
  • 在函數語言程式設計中,通常不會通過try...catch的方式處理異常,一方面異常也是一種副作用,另一方面,異常讓函式簽名不再完整。如何把每一步的異常帶到最外面也成了問題
    而解決這一切的手段就是Monad, 簡而言之,Monad是一種抽象方式,能夠將monadic風格的函式連線起來。什麼又是monadic? 簡單來說這是一種接收普通型別,返回某種lift型別(泛型)的函式。例如通過IO, Task, Either相關的Monad來解決此類問題。具體內容請關注本人的函式式系列部落格。

小結

這篇文章總結了一些使用函式式語言實踐DDD的大致思路,也為函式式架構提供了一些參考。由於篇幅的原因,並沒有介紹到DDD的方方面面,同時,一些實現細節則是點到為止,例如如何使用Monad。總體來說,函式式語言的代數資料型別,以及函式式的一些思想,為實踐領域驅動設計提供了其他的選項。

相關文章