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

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

領域模型=代碼=文檔

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

那麼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請求到領域模型再到輸出過程中如何通過函數式編程語言來實現。

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