基于 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]

如对此文有什么疑问 请在评论区下方留言哦!


分享到:


相關文章: