基於 FP 的一次 DDD 戰術設計實踐

引言

DDD(Domain Driven Design,領域驅動設計)最早由 Eric Evans 提出,是一種以領域為核心驅動力的開放的設計方法體系。這 15 年以來,DDD 一直在堅強的生長,尤其是微服務盛行的這幾年,DDD 重新煥發了青春,國內越來越多的技術愛好者開始關注並實踐 DDD。

DDD 作為一種開放的設計體系,不斷容納新的實踐方法,比如領域事件、事件溯源模式、CQRS 模式、事件風暴、六邊形架構、洋蔥架構、整潔架構,同時還引入 FP(Functional Programming,函數式)編程思想,利用純函數與抽象代數結構的不變性以及函數的組合性來表達領域模型。

領域專家和開發團隊緊密地工作在一起,先通過戰略設計合理的劃分出 BC(Bounded Context,限界上下文),然後在 BC 內通過戰術設計得到統一一致的無歧義的領域模型。領域模型是軟件開發過程中最核心的資產,各個角色都圍繞領域模型無障礙的溝通。隨著需求的不斷增加,各個角色對軟件要解決的業務問題和解決方式有了更深入的理解,藉助重構和自動化測試等技術實踐不斷演進領域模型,降低了軟件的實現複雜度,使得軟件更加貼合業務的本質。

基於 FP 進行領域建模的場景

DDD 在戰術設計實踐中應對的是領域的複雜性,並不牽涉技術層面的實現細節,其主要的設計元素包括:

  • 值對象(Value Object)
  • 實體(Entity)
  • 領域服務(Domain Service)
  • 領域事件(Domain Event)
  • 資源庫(Repository)
  • 工廠(Factory)
  • 聚合(Aggregate)
  • 應用服務(Application Service)

Eric Evans 通過下圖勾勒出了戰術設計各元素之間的關係:

基於 FP 的一次 DDD 戰術設計實踐


領域模型是從領域問題出發人為構建的一種面向領域的指示性語義,選擇某種編程範式就選定了特定的構建基礎。理論上不管選擇 OP(面向過程)、OO(面向對象)還是 FP(函數式)做為構建基礎都是圖靈完備的,但在工程上需要考量哪種編程範式與領域語義之間的 Gap 最小且維護成本最低。另外現代編程語言基本都支持多範式,使得程序員可以在局部靈活選擇最佳的編程範式。

人們在基於 OO 的領域建模方面已經積累了大量的經驗,而在基於 FP 的領域建模方面的經驗卻比較匱乏。筆者以前的 DDD 戰術設計實踐都是習慣性的基於 OO 編程範式的,經常會用到戰術設計的各種元素。直到在一次 DDD 戰訓營時,小夥伴們在教練的引導下針對告警子域的 BC 進行戰術設計,最後發現告警規則、告警防抖或告警消重相關的領域概念具有不可變性,都可以建模為值對象,從而基於 FP 來進行的領域建模和領域語義之間的 Gap 最小且維護成本最低。

一說值對象,很多同學就想到不可變性,其實不一定。Eric Evans 在書中強調了“何時允許可變性”的特殊情況:

保持 VALUE OBJECT 不變可以極大地簡化實現,並確保共享和引用傳遞的安全性,而且這樣做也符和值的意義。如果屬性的值發生改變,我們應該使用一個不同的 VALUE OBJECT,而不是修改現有的 VALUE OBJECT。儘管如此,在有些情況下出於性能考慮,讓需要讓 VALUE OBJECT 是可變的,這包括以下因素:
  • 如果 VALUE 頻繁改變;
  • 如果創建或刪除對象的開銷很大;
  • 如果替換(而不是修改)將打亂集群(像前面示例中討論的那樣);
  • 如果 VALUE 共享不多,或者共享不會提高集群性能,或其它某種技術原因。
再次強調:如果一個 VALUE 的實現是可變的,那麼就不能共享它。無論是否共享 VALUE OBJECT,在可能的情況下都要將它們設計為不可變的。

定義值對象並將它們指定為不可變是一條一般規則,基於 FP 來進行領域建模時涉及的值對象都遵守這個一般規則。

基於 FP 進行領域建模的場景,就是經過領域場景分析發現領域語義和 FP 編程範式之間的 Gap 最小。基於 FP 進行戰術設計,一般涉及的設計元素主要有三個:

  • 值對象:具有不可變性,表達了用戶場景提供的數據語義;
  • 領域服務:串聯領域模型的行為,封裝領域邏輯;
  • 應用服務:提供領域的 API 給用戶,最小依賴。

數形狀的遊戲

你是一名數學老師,在某次課距離下課還有十分鐘時,你決定做數形狀的遊戲,於是在黑板上畫了一個圖:

基於 FP 的一次 DDD 戰術設計實踐


  • 遊戲一:數數圖中有幾個三角形?
  • 遊戲二:數數圖中有幾個四邊形?

很多同學開始邊比劃邊數,不管是三角形還是四邊形,每個人的數法不盡相同,大多數同學數的結果都少於實際的,也有一部分同學數的多於實際的,最後數對的同學其實很少。但如果讓計算機來數的話,就必須將數三角形或四邊形的方法描述成計算機可以執行的形式化算法。這種描述方法可以有很多種,而我們希望找到一個抽象層次高且非常貼合領域的描述,以便降低後續的維護成本。

1. 數三角形

領域建模


基於 FP 的一次 DDD 戰術設計實踐


我們考慮一下:什麼是三角形?

如果沒有記錯的話,我們在小學就學過:三角形就是三個點,兩兩相連,但是三個點不在同一條線上。

我們針對數三角形的遊戲進行領域建模,核心就是解決下面幾個問題:

  • 上下文中的值對象都有哪些?
  • 三角形的三個點 (a,b,c) 都有哪些?
  • 計算機怎麼知道兩個點之間是否有連線,三個點是否在同一條線上?

值對象

我們很容易想到兩個值對象,即點和線,我們通過 Golang 來簡單表達:

type Point = byte
type Line = string

三角形有三個點:

[]Point{a, b, c}

三角形有三條邊,即三條線:

[]Line{"ab", "bc", "ac"}

我們可以把一條線看作該線上所有點的集合,下面給出 Points 的定義:

type Points = string

而對於點的集合,並不能斷定集合中的所有點都在同一條線上,所以 Points 的語義比 Line 的更廣泛。

根據上面的定義,老師在黑板上畫的圖形就可以表達為:

 points := "abcdefghijk"
lines := []Line{"abh", "acgi", "adfj", "aek", "bcde", "hgfe", "hijk"}

(x, y, z) 的無序集合 (x, y, z) 的 Golang 語言形態是 []Point{x, y, z},可以將它轉換成 Points 類型:

 xyz := Points([]Point{x, y, z})

我們已經知道點的集合 points,那麼 xyz 的集合簡單來講就是從 points 的集合中取三個點的無序子集,每個子集的類型是個 Points,所有子集的類型就是 []Points,可以簡單用 subset(points, 3) 來描述。不過為了通用,我們直接用 subset(points, n) : (Points, int) -> []Points 來建模:

subset(points, n) when len(points) < n -> []
subset(points, n) when len(points) == n -> [points]
subset(points, n) when n == 1 -> [points[0], points[1], ..., points[len(points) - 1]]

subset(points, n) ->
firsts = subset(points[1:], n - 1) with points[0]
lasts = subset(points[1:], n)
firsts append lasts

connected 和 insameline

我們已經知道線的集合 lines,目標是要知道點與點之間關係:2 個點的關係 connected,3 個點的關係 in_same_line,等價於 2 個點或 3 個點組成的集合 points 是否為 lines 中任一元素的子集,不妨用 belong(points, lines) : (Points, []Line) -> bool 來表示這種關係,而 belong 已經是一個原子語義。

我們下面用 belong 描述一下 connected(x, y) : (Point, Point) -> bool 和 in_same_line(x, y, z) : (Point, Point, Point) -> bool:

connected(x, y) ->
belong(xy, lines)
in_same_line(x, y, z) ->
belong(xyz, lines)

領域模型的建立

三角形的領域模型我們可以用形式化的方法表達如下:

is_triangle(a, b, c) ->
connected(a, b) and
connected(b, c) and
connected(a, c) and
not(in_same_line(a, b, c))

用代碼表達領域模型

model 代碼實現

IsTriangle 函數的實現代碼表達了領域模型:

func IsTriangle(points Points, lines []Line) bool {
connected := hasConnected(lines)
in_same_line := inSameLine(lines)
a := points[0]
b := points[1]
c := points[2]
return connected(a, b) &&
connected(b, c) &&
connected(a, c) &&
not(in_same_line(a, b, c))
}

關於 hasConnected, inSameLine 和 not 的函數實現,請參考:

https://github.com/agiledragon/ddd-sample-in-golang/blob/master/counting-shapes/domain/model/set.go

domain service 代碼實現

在領域服務中,先從點的集合中獲取 3 個點的所有無序子集,然後遍歷所有無序子集:判斷無序子集中的三個點是否構成了三角形,如果是,則加到 matches 切片中:

func CountingTriangles(points model.Points, lines []model.Line) []model.Points {
sets := model.Subset(points, 3)
matches := make([]model.Points, 0)
for _, set := range sets {
if model.IsTriangle(set, lines) {
matches = append(matches, set)
}
}
return matches
}

app service 代碼實現

應用服務委託領域服務來數三角形,並返回三角形的個數:

func CountingTriangles(points string, lines []string) int {
return len(service.CountingTriangles(points, lines))
}

學生數三角形代碼

我們通過測試代碼來模擬:

func TestCountingShapes(t *testing.T) {
points := "abcdefghijk"
lines := []string{"abh", "acgi", "adfj", "aek", "bcde", "hgfe", "hijk"}
Convey("TestCountingShapes", t, func() {
Convey("counting triangles", func() {
num := service.CountingTriangles(points, lines)
So(num, ShouldEqual, 24)
})
})
}

2. 數四邊形

領域建模

我們考慮一下:什麼是四邊形?

基於 FP 的一次 DDD 戰術設計實踐


如果沒有記錯的話,我們在初中就學過:由不在同一直線上的不交叉的四條線段依次首尾相接圍成的封閉的平面圖形或立體圖形叫四邊形,由凸四邊形和凹四邊形組成。

有了三角形領域模型的基礎,下面三個問題已經解決:

  • 上下文中的值對象;
  • (a, b, c, d) 的無序集合,subset(points, n) when n = 4;
  • 任意三個點不在同一直線上,即 not(in_same_line(a, b, c));

要形式化表達四邊形的領域模型,還要解決下面兩個問題:

  • 多條線段依次首尾相接構成一個環,我們記作 ring_order_connected(a, b, c, d),計算機如何知道?
  • 兩條線段相交,我們記作 cross_connected,計算機如何知道?

ringorderconnected

我們先形式化表達一下 ring_order_connected(x1, x2, ..., xn) 的含義:

ring_order_connected(x1, x2, ..., xn) ->
connected(x1, x2) and
connected(x2, x3) and
...
connected(xn-1, xn) and
connected(xn, x1)

我們以四邊形為例,將 ring_order_connected(a, b, c, d) 的含義圖形化:

基於 FP 的一次 DDD 戰術設計實踐


上面六個圖中:

  • 前面兩個圖 ring_order_connected(a, b, c, d) 為真,且有序集合 (a, b, c, d) 是四邊形;
  • 中間兩個圖 ring_order_connected(a, b, c, d) 為真,而有序集合 (a, b, c, d) 有交叉線不是四邊形;
  • 後面兩個圖 ring_order_connected(a, b, c, d) 為真,雖然有序集合 (a, b, c, d) 有交叉線不是四邊形,但圖五有序集合 (a, b, d, c) 是四邊形,圖六有序集合 (a, d, b, c) 是四邊形。

從上面的分析可以看出,四邊形的識別已經比三角形複雜很多,(a, b, c, d) 不能簡單的看作一個無序集合,而是一個有序集合。當 (a, b, c, d) 是一個四邊形時,邊 "ad" 和 邊"bc" 沒有交叉線,同時邊 "ab" 和 邊"cd" 沒有交叉線。

既然 ring_order_connected 的輸入是有序集合,而我們通過 subset(points, n) 得到的是無序集合,那麼無序集合如何轉換成有序集合?

對於 (x1, x2, ..., xn),當 x1 確定後,x2 有 n-1 個位置,x3 有 n-2 個位置,x2 有 2 個位置, xn 僅有一個位置,即當無序集的大小為 n 時,有續集的個數為 (n-1)!。

當 n=4 時,我們實例化一下:

set(x1, x2, x3, x4) ->
order_set(x1, x2, x3, x4) or
order_set(x1, x2, x4, x3) or
order_set(x1, x3, x2, x4) or
order_set(x1, x4, x2, x3) or
order_set(x1, x3, x4, x2) or
order_set(x1, x4, x3, x2)

cross_connected

當 cross_connected(ab, cd) 返回真時表示邊 ab 和邊 cd 是交叉線。我們用肉眼可以清楚的看見兩條邊是否交是叉線,而計算機如何知道呢?

通過觀察老師在黑板上畫的圖,發現任意兩個交叉線的銜接點都有標記,於是豁然開朗。

對於 cross_connected(ab, cd) 來說,ab 邊所在的線段和 cd 邊所在的線段的兩個“點的集合”中,如果 a 和 b 之間的點構成的“子集”與 c 和 d 之間的點構成的“子集”有交集,則說明邊 ab 和邊 cd 是交叉線。

我們把前面的兩個圖的交叉線的銜接點標記一下:

基於 FP 的一次 DDD 戰術設計實踐


對於上面的任一張圖,兩個子集都是 [e],所以交集也是 [e],這就是說 (a, b, c, d) 表示的有序環連接有交叉線。

更一般的,兩個子集的可能性包括:

  • 某個子集為 [];
  • 兩個子集都不為 [],但交集為 [],比如 [xyz] 與 [tmn];
  • 兩個子集的交集至少有一個元素,比如 [xyz] 與 [zmn]。

領域模型的演進

有了 ring_order_connected 後,三角形三條邊的兩兩相連就可以通過 ring_order_connected(a, b, c) 來形式化表達,這時三角形的領域模型就精練為:

is_triangle(a, b, c) ->
ring_order_connected(a, b, c) and
not(in_same_line(a, b, c))

四邊形的領域模型我們用形式化的方法表達如下:

is_quadrangle(a, b, c, d) ->
ring_order_connected(a, b, c, d) &&
not(cross_connected(ab, cd)) &&
not(cross_connected(ad, bc)) &&

not(in_same_line(a, b, c)) &&
not(in_same_line(a, b, d)) &&
not(in_same_line(a, c, d)) &&
not(in_same_line(b, c, d))

:(a, b, c, d) 是有序集合,無序集合到有序集合的轉換職責分離到領域服務。

用代碼表達領域模型

model 代碼實現

重構 IsTriangle 函數的實現精練領域模型:

func IsTriangle(points Points, lines []Line) bool {
ring_order_connected := ringOrderConnected(lines)
in_same_line := inSameLine(lines)
a := points[0]
b := points[1]
c := points[2]
return ring_order_connected(a, b, c) &&
not(in_same_line(a, b, c))
}

通過 IsQuadrangle 函數來演進領域模型:

func IsQuadrangle(points Points, lines []Line) bool {
ring_order_connected := ringOrderConnected(lines)
cross_connected := hasCrossConnected(lines)
in_same_line := inSameLine(lines)
a := points[0]
b := points[1]
c := points[2]
d := points[3]
ab := Points([]Point{a, b})
cd := Points([]Point{c, d})
ad := Points([]Point{a, d})

bc := Points([]Point{b, c})
return ring_order_connected(a, b, c, d) &&
not(cross_connected(ab, cd)) &&
not(cross_connected(ad, bc)) &&
not(in_same_line(a, b, c)) &&
not(in_same_line(a, b, d)) &&
not(in_same_line(a, c, d)) &&
not(in_same_line(b, c, d))
}

關於 ringOrderConnected 和 hasCrossConnected 的實現,請參考:

https://github.com/agiledragon/ddd-sample-in-golang/blob/master/counting-shapes/domain/model/set.go

domain service 代碼實現

在領域服務中,先獲取 4 個點的所有無序子集,然後遍歷所有無序子集:將每個無序子集轉換成 6 個有序子集,遍歷所有有序子集,判斷有序子集中的 4 個點是否構成了四邊形,如果是,則 append 到 matches 切片中,並退出有序子集的遍歷,接著遍歷無序子集:

func CountingQuadrangles(points model.Points, lines []model.Line) []model.Points {
sets := model.Subset(points, 4)
matches := make([]model.Points, 0)
for _, set := range sets {
a := set[0]
b := set[1]
c := set[2]
d := set[3]
orderSets := []model.Points{
model.Points([]model.Point{a, b, c, d}),
model.Points([]model.Point{a, b, d, c}),
model.Points([]model.Point{a, c, b, d}),
model.Points([]model.Point{a, c, d, b}),
model.Points([]model.Point{a, d, b, c}),
model.Points([]model.Point{a, d, c, b}),
}
for _, orderSet := range orderSets {
if model.IsQuadrangle(orderSet, lines) {
matches = append(matches, orderSet)

break
}
}
}
return matches
}

app service 代碼實現

應用服務委託領域服務來數四邊形,並返回四邊形的個數:

func CountingQuadrangles(points string, lines []string) int {
return len(service.CountingQuadrangles(points, lines))
}

學生數四邊形代碼

我們通過測試代碼來模擬(數三角形和數四邊形在同一個測試函數中):

func TestCountingShapes(t *testing.T) {
points := "abcdefghijk"
lines := []string{"abh", "acgi", "adfj", "aek", "bcde", "hgfe", "hijk"}
Convey("TestCountingShapes", t, func() {
Convey("counting triangles", func() {
num := service.CountingTriangles(points, lines)
So(num, ShouldEqual, 24)
})
Convey("counting quadrangles", func() {
num := service.CountingQuadrangles(points, lines)
So(num, ShouldEqual, 18)
})
})
}

小結

領域模型是從領域問題出發人為構建的一種面向領域的指示性語義,選擇某種編程範式就選定了特定的構建基礎。理論上不管選擇 OP、OO 還是 FP 做為構建基礎都是圖靈完備的,但在工程上需要考量哪種編程範式與領域語義之間的 Gap 最小且維護成本最低。人們已經在基於 OO 的領域建模方面積累了大量的經驗,而在基於 FP 的領域建模方面的經驗卻比較匱乏。

本文主要分享基於 FP 的一次 DDD 戰術設計實踐。首先闡述了基於 FP 進行領域建模的場景是什麼,然後以數形狀的遊戲為案例完整展示了戰術設計實踐的全過程,不僅有領域模型的建立,還有領域模型的演進。在案例中,我們用形式化的方法描述領域模型,用整潔的代碼表達領域模型,降低了軟件的實現複雜度,使得軟件更加貼合業務的本質。

附錄

counting-shapes 源碼鏈接:

https://github.com/agiledragon/ddd-sample-in-golang

第一個遊戲:數數一共有多少個三角形?

24,詳情如下:[abc abd abe acd ace ade aef aeg aeh afg afh agh ahi ahj ahk aij aik ajk beh ceg def ehk fhj ghi]

第二個遊戲:數數一共有多少個四邊形?

18,詳情如下:[aceh adeg adeh afhk aghj aghk bcgh bcih bdfh bdjh bekh cdfg cdji ceki dekj efjk egik fgij]

如對此文有什麼疑問 請在評論區下方留言哦!


分享到:


相關文章: