FP 視角下的領域驅動設計

這周在學習 union type 時偶然學到一個頗有衝擊的軟件工程思想 -- 領域驅動設計。前端

在瞭解了這個思想後,我意識到最近很困擾個人 JS 防護式編程的問題有更深的缺陷,那就是領域模型一開始就沒定義好。說到領域模型,通常都會聯想到後端,特別是 Java 開發。前端的業務邏輯通常不須要上這麼複雜的概念。不過,領域驅動設計仍是給了我啓發,讓我意識到問題出在哪裏。編程

我認識領域驅動設計(下簡稱 DDD)仍是從函數式編程視角入門的。提到 DDD,通常會認爲它只和面向對象程序設計有關係,而我所經過 F# 瞭解到的,ML 系語言的 Hindley–Milner 類型系統,除了能夠用來檢查類型,還有很重要的做用是它能用來靈活完整地去設計領域模型。後端

假設咱們要定義一個聯繫人類型:函數式編程

5c54596a08db8

上面的代碼用 TypeScript 來表達的話基本長差很少。這個類型定義的問題是它沒有傳達領域知識:函數

  1. 你不知道哪些字段是可選的單元測試

  2. 你不知道字段的限制。好比,FirstName 只能限制在50個字符之內。學習

  3. 你不知道字段之間的相互關聯。好比前三個字段都應該在一個組裏面。測試

  4. 你不知道字段的領域邏輯。好比郵箱地址變了後,郵箱認證就要變爲 false。ui

上面這些問題,本應該在定義類型的時候就體現出來。而用傳統面向對象的類型系統,好比 TypeScript,是作不到的。若是嘗試去作的話,會讓領域模型代碼和實現細節代碼混在一塊兒。spa

下面來看 F# 的類型系統怎樣解決這些問題。

DDD 裏面有個術語叫有限上下文(Bounded Context),即在領域模型裏面的詞語,只有放在當前領域上下文才有意義。這些詞語構成了領域模型裏面的通用語言(ubiquitous language)。看例子:

5c54597d0e032

這個模塊描述了一個紙牌遊戲的領域模型。Hand, Player, Deck 等等詞彙,只有放在 CardGame 這個有限上下文中才能被理解;而這些詞彙就構成了通用語言。上面這段代碼不只定義了數據類型,並且定義了領域模型!這種類型定義很是好懂。經過有限上下文和通用語言的建立,咱們能作到「持久性無知」(Persistent 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 一書的倉促總結。更深的含義可能沒表達完整。感興趣的話推薦閱讀這本書。

參考: Domain Modeling Made Functional - Scott Wlaschin

相關文章
相關標籤/搜索