使用函式式語言來建立領域模型

richiezhang發表於2021-01-01

使用函式式語言來建立領域模型

領域模型=程式碼=文件

如果說敏捷軟體開發主張面對面溝通,通過快速迭代的手段,讓有價值的軟體儘早面向市場,從而適應快速變化的需求。

那麼DDD則為敏捷開發過程中的溝通形式作出了進一步的補充,DDD讓領域模型和程式碼以及文件之間畫上了等號,主張讓程式碼成為團隊之間溝通和交流的途徑。縱觀DDD的所有環節,無一不是在打通領域專家和開發人員之間的溝通和交流,而程式碼無疑是最有效,最實時的共享模型。
DDD的精髓在於通過讓開發人員理解領域,進而讓開發人員使用程式語言建立一個跟領域專家腦海中一致的領域模型,使得該領域模型成為大家共享知識的途徑,這將有效的減少不同利益相關者的溝通及交流,確保所有人都在解決同一個問題。

領域建模

領域建模是整個DDD環節中最最考驗開發人員功底的一環,不同於傳統的資料庫建模技術,開發人員需要有很好的抽象能力,通過恰如其分的程式設計技術,將領域知識對映到一個程式碼模型中。
長期以來OO語言被認為是領域建模的首選,一些OO的技巧可以很好的用來抽象領域模型。而函式式語言則被普遍認為只能用來做資料處理,科學計算等。本文將為大家展示如何通過函數語言程式設計語言進行領域建模,本文選用TypeScript編寫例項,TypeScript型別系統完全滿足函數語言程式設計需求,當然本文也適用於其他擁有靜態型別系統的函數語言程式設計語言。

TypeScript的型別系統

實際上你只需要知道少量的知識就可以開始領域建模了,從這個角度來講,實際上函式式型別系統更適合領域建模,從而讓領域模型成為文件。

型別

各類程式語言在設計的時候就已經提供了類似string, bool, number等簡單型別(primitive),然而在真實世界裡面,你還需要將這些型別組合成更大的型別,從而來對映現實世界。
在TypeScript中,type關鍵字用來組合更大的型別:

type Name = {
  firstName: string
  middleName: string
  lastName: string
}

上面型別的用途是顯而易見的,除此之外type還有起別名的用途,不要小瞧這個特性,他可以幫助你把領域知識記載在你的領域模型中,考慮下面的程式碼:

const timeToFly = 10

你能一眼看出這句程式碼代表的領域知識嗎?也許不能,fly多久?查文件?No,你應該時刻告訴自己,程式碼等於文件。改進後的程式碼如下:

type Second = number
const timeToFly: Second = 10

Or型別

OO語言無法建立這種型別,在TypeScript,這種型別被稱為聯合(Union Types),通過符號|來建立,考慮下面的型別:

type Pet = Fish | Bird

PetFish或者是Bird型別。一般來說函式式語言都會有強大的模式匹配能力,來處理這種型別,然而受制於TypesScript沒有模式匹配或者說能力很弱,通常情況下,會在型別裡面新增一個字串字面量, 從而來區分不同的型別, 在次不再細說。

And型別

在Typescript中,這種型別被稱為交叉型別(Intersection Types),通過符號&來建立,考慮下面的型別:

type ABC = A & B & C 

表示ABC型別包含所有A、B、C三個型別裡面的屬性。

定義函式型別

在TypeScript中,函式與其他型別沒什麼區別,也可以通過type關鍵字來定義,例如:

type Add = (a: number) => (b: number) => number

Add是一個函式,接收兩個型別為number的型別a和b,返回number。

通過程式碼來共享領域知識

type CreditCard = {
  cardNo: string
  firstName: string
  middleName: string
  lastName: string
  contactEmail: Email
  contactPhone: Phone
}

通過前面介紹的知識,我們很容易就可以寫出上面的程式碼,用來描述CreditCard這種支付方式。注意我們沒有使用class
但這是一個靠譜的領域模型嗎?如果不靠譜,它的問題在哪裡?
這段程式碼最大的問題是他沒有把本該擁有的領域知識記錄在其中,我來試著問你幾個問題:
問:middle name可以為空嗎?
答1:不清楚,也許需要查文件。
答2:也許可以吧?middle name可以為null

為可空型別建模

在函數語言程式設計語言中,可空型別被定義為Option,雖然null在ts中是合法的(注:我們可以通過strictNullChecks來強致null檢查),但是在函數語言程式設計語言中,你只能通過Option型別來表達可空型別。
當領域專家告訴你:middle name可以存在,或者為空。注意用詞,說明我們可以通過Union型別來為可空型別建模。

type Option<T> =  T | null

一個簡單的Option其實就是一個型別, 當然你可以使用一個更加複雜的Option實現, 不過不在我們今天的討論範圍內。經過修改後的程式碼變成了這樣:

type CreditCard = {
  cardNo: string
  firstName: string
  middleName: Option<string>
  lastName: string
  contactEmail: Email
  contactPhone: Phone
}

避免基本型別偏執(Primitive Obsession)

問:cardNo可以用string來表示嗎?如果是,它可以是任意字串嗎?firstName可以是任意長度的字串嗎?很顯然,你無法回答上面的問題,源於這個模型並沒有包含有此類領域知識。
也許在程式語言裡面,cardNo可以用string表達,但是cardNo在領域模型中,string無法表達出cardNo的領域知識。
cardNo是一個200打頭的19位字串,name是一個不超過50位的字串,這樣的領域資訊可以通過type alias來實現:

type CardNo = string
type Name50 = string
...

有了上面兩個型別,你就有機會通過定義函式的方式,將cardNo業務規則包含在領域模型中。

type GetCardNo = (cardNo: string) => CardNo

如果使用者輸入了一個20位的字串,函式GetCardNo返回什麼?null?丟擲異常?實際上函數語言程式設計語言有比異常更加優雅的Error handling方式, 例如Either Monad或者Railway oriented programming。本文雖然不包含這類話題,但至少目前我們可以用Option來表示這個函式簽名:

type GetCardNo = (cardNo: string) => Option<CardNo>

這個函式型別清晰的表達了整個驗證過程,使用者輸入一個字串, 返回一個CardNo型別,或者空。修改後的領域模型變成了這樣:

type CreditCard = {
 cardNo: Option<CardNo>
 firstName: Name50
 middleName: Option<string>
 lastName: Name50
 contactEmail: Email
 contactPhone: Phone
}

於是,現在的程式碼擁有跟多的領域知識,豐富的型別還充當了單元測試的角色,例如,你永遠都不會把一個email賦值給contactPhone,它們不是string, 它們代表不同的領域知識。

領域模型的原子性和聚合性

這個領域模型中的三個name可以分別修改嗎?例如只修改middle name?如果不可以,如何將這種原子性的修改知識包含在領域模型中?
實際上我們很容易就能把NameContact兩個型別分離出來並加以組合:

type Name = {
  firstName: Name50
  middleName: Option<string>
  lastName: Name50
}

type Contact = {
  contactEmail: Email
  contactPhone: Phone
}

type CreditCard3 = {
  cardNo: Option<CardNo>
  name: Name
  contact: Contact
}

Make illegal states unrepresentable

在領域建模過程中,這是一條非常重要的原則,用通俗的話可以理解為:你建立的領域模型應該有儘可能多的靜態檢查和約束,讓錯誤發生在編譯時,而不是執行時,從而杜絕犯錯誤的機會。其實整個領域建模都是在遵循這個原則,例如上面的Email型別和Phone型別,為什麼不用string來表示呢?因為string給與的領域知識不夠,從而允許開發人員有了犯錯誤的機會。
讓我們最後看一個例子,用來說明這條原則如何被應用在領域建模中。 上面領域模型中有一個contact型別,包含一個Email和Phone屬性。支付成功後,系統可以通過這兩個屬性給使用者發通知,由此延伸出來這樣一條規則:使用者必須至少填寫一個Email或者一個Phone來接受支付訊息。
首先,上面的領域模型是不匹配這條業務規則的,因為Email和Phone型別都是非空型別,意味著這兩個屬性都應該是必填項。
我們能不能把它倆都改為Option型別呢?

type Contact = {
  contactEmail: Option<Email>
  contactPhone: Option<Phone>
}

顯然也不行,實際上就是違反了Make illegal states unrepresentable, 給與了程式碼犯錯的機會,你的領域模型表達出了一種非法的狀態,即Email和Phone都可以為空,你也許會說我的xxService做了驗證呢,它倆絕對不會同時為空。對不起,我們希望我們的領域模型能夠包含這種領域知識,至於xxService,跟領域模型無關。到底能否將這一規則表達在領域模型中嗎?答案是肯定的,規則中有一個字,即我們可以通過Or型別(union)來表達這種關係:

type OnlyContactEmail = Email 
type OnlyContactPhone = Phone
type BothContactEmailAndPhone = Email & Phone

type Contact = 
  | OnlyContactEmail
  | OnlyContactPhone
  | BothContactEmailAndPhone

結束語

本文旨在通過函數語言程式設計語言來指導領域建模,整個程式碼示例中沒有出現類或者子類,更不會出現abstract, bean等關鍵字,衡量一個領域模型的好壞取決於
1)領域模型是否包含了儘可能多的領域知識,能否反映領域專家腦海中的業務模型
2)領域模型能否成為文件,進而成為所有人溝通和共享知識的途徑
同時,一些語言,框架的”行話“應該越少越好,例如你在領域模型中建立了一個叫做AbstractContactBase的類,除了增加複雜度,對共享領域模型這一目的幫助甚少。
實際上函數語言程式設計語言的型別系統,不但能夠幫助開發者建立一個豐富的領域模型,同時簡單可組合的型別系統,也為程式碼即文件提供了基礎。不可否認真實世界遠比本文所描述的例子複雜,但是大部分複雜的部分,並不會出現在領域模型中,例如函數語言程式設計中的各種”行話“,他們往往出現在資料請求的validation, 請求第三方,資料轉化,持久化等實現階段。在未來的文章中將會描述整個http請求到領域模型再到輸出過程中如何通過函數語言程式設計語言來實現。

相關文章