使用函數式語言實踐DDD

長期以來我都在實踐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。總體來說,函數式語言的代數數據類型,以及函數式的一些思想,爲實踐領域驅動設計提供了其他的選項。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章