FP 視角下的領域驅動設計
這周在學習 union type 時偶然學到一個很有衝擊的軟體工程思想 -- 領域驅動設計。
在瞭解了這個思想後,我意識到最近很困擾我的 JS 防禦式程式設計的問題有更深的缺陷,那就是領域模型一開始就沒定義好。說到領域模型,一般都會聯想到後端,特別是 Java 開發。前端的業務邏輯一般不需要上這麼複雜的概念。不過,領域驅動設計還是給了我啟發,讓我意識到問題出在哪裡。
我認識領域驅動設計(下簡稱 DDD)還是從函數語言程式設計視角入門的。提到 DDD,一般會認為它只和面向物件程式設計有關係,而我所通過 F# 瞭解到的,ML 系語言的 Hindley–Milner 型別系統,除了可以用來檢查型別,還有很重要的作用是它能用來靈活完整地去設計領域模型。
假設我們要定義一個聯絡人型別:

上面的程式碼用 TypeScript 來表達的話基本長差不多。這個型別定義的問題是它沒有傳達領域知識:
-
你不知道哪些欄位是可選的
-
你不知道欄位的限制。比如,FirstName 只能限制在50個字元以內。
-
你不知道欄位之間的相互關聯。比如前三個欄位都應該在一個組裡面。
-
你不知道欄位的領域邏輯。比如郵箱地址變了後,郵箱認證就要變為 false。
上面這些問題,本應該在定義型別的時候就體現出來。而用傳統面向物件的型別系統,比如 TypeScript,是做不到的。如果嘗試去做的話,會讓領域模型程式碼和實現細節程式碼混在一起。
下面來看 F# 的型別系統怎樣解決這些問題。
DDD 裡面有個術語叫有限上下文(Bounded Context),即在領域模型裡面的詞語,只有放在當前領域上下文才有意義。這些詞語構成了領域模型裡面的通用語言(ubiquitous language)。看例子:

這個模組描述了一個紙牌遊戲的領域模型。Hand, Player, Deck 等等詞彙,只有放在 CardGame 這個有限上下文中才能被理解;而這些詞彙就構成了通用語言。上面這段程式碼不僅定義了資料型別,而且定義了領域模型!這種型別定義非常好懂。通過有限上下文和通用語言的建立,我們能做到“永續性無知”(Persistant Ignorance),即不用懂程式碼實現也能看懂領域模型。更神奇之處在於,上面的程式碼不僅僅是一個模型描述,而且是一段可執行程式碼!這體現了 程式碼即設計,設計即程式碼 的思想。
再來反思一下我們在定義型別時常常忽視的一些問題,比如郵箱地址的資料型別真的只是字串嗎?訂單數量的資料型別真的只是整數嗎?合法的郵箱地址應該需要經過正則匹配,訂單數量常常也會有上下限。用 F# 可以表達如下:
type EmailAddress = EmailAddress of string let createEmailAddress (s:string) = if Regex.IsMatch(s,@"^\S+@\S+\.\S+$") then Some(EmailAddress s) else None createEmailAddress: string -> EmailAddress option type OrderLineQty = OrderLineQty of int let createOrderLineQty qty = if qty > 0 && <= 99 then Some(OrderLineQty qty) else None createOrderLineQty: int -> OrderLineQty option 複製程式碼
Some 和 None 很顯式地傳達了資料的可能狀態,符合模型規約就返回 Some,否則就返回 None。Some 和 None 是 F# 內建的代數資料型別(可以理解為可組合資料型別),它們可以和其它代數資料型別無感知組合。對比下我們日常用 JS 開發時的做法,不符合要求就返回 undefined 或者 null,然後再在呼叫處做防禦處理。這裡的問題是 undefined 和 null 並不能用來傳達領域資訊,它們沒有帶上下文就扔給接收者了。(提到這裡應該能明白用 Maybe 資料型別和用 _.get 的本質區別了)
再回到一開始丟擲的問題,解決辦法如下:
type EmailAddress = EmailAddress of string let createEmailAddress (s:string) = if Regex.IsMatch(s,@"^\S+@\S+\.\S+$") then Some(EmailAddress s) else None createEmailAddress: string -> EmailAddress option type String50 = String50 of string let createString50 (s:string) = if s.Length <= 50 then Some(String50 s) else None createString50: string -> String50 option type PersonalName = { FirstName: String50 MiddleInitial: String50 | option LastName: String50 } type VerifiedEmail = VerifiedEmail of EmailAddress type VerificationService = (EmailAddress * VerificationHash) -> VerifiedEmail option type EmailContactInfo = | Unverified of EmailAddress | Verified of VerifiedEmail type Contact = { Name: PersonalName Email: EmailContactInfo } 複製程式碼
上面的程式碼不僅是完整的領域模型,而且可編譯執行。經過領域模型的嚴格規約,我們不用再寫防禦程式碼了。上面的 型別程式碼就是編譯時單元測試。
還值得注意的一點是,隨著領域模型的完善,通用語言是在擴充套件的,比如 VerifiedEmail 等詞彙。通用語言的豐富意味著我們與領域專家(一般是產品需求方,比如產品經理)的理解更容易達成一致。
瞭解到這些思想後我內心感受是複雜的。儘管我前一段時間還為別人吐槽 JS 垃圾而不滿,但最近我對 JS 的不滿也增加了好多。JS 仍然是入門程式設計價效比比較高的語言,但我不會認為它是最好的語言了……
一方面是它允許一些糟糕寫法,沒有強制規約,另一方面是它缺失一些能力,比如靜態型別。TypeScript 帶來了一堆模板程式碼,讓程式碼臃腫囉嗦,價效比太低。最重要的是它無法提供本文展示的領域設計能力。現在我開始明白當 Eric Elliott 說 JS 需要的是靠近 Haskell 的型別系統,而不是 Java 的,他想表達的是什麼意思。(也有可能我是錯的,對 TS 一開始就比較牴觸,寫的不多)
上面的思考只是對 Domain Modelling Made Functional 一書的倉促總結。更深的含義可能沒表達完整。感興趣的話推薦閱讀這本書。