FP 视角下的领域驱动设计

1,760 阅读4分钟

这周在学习 union type 时偶然学到一个很有冲击的软件工程思想 -- 领域驱动设计。

在了解了这个思想后,我意识到最近很困扰我的 JS 防御式编程的问题有更深的缺陷,那就是领域模型一开始就没定义好。说到领域模型,一般都会联想到后端,特别是 Java 开发。前端的业务逻辑一般不需要上这么复杂的概念。不过,领域驱动设计还是给了我启发,让我意识到问题出在哪里。

我认识领域驱动设计(下简称 DDD)还是从函数式编程视角入门的。提到 DDD,一般会认为它只和面向对象程序设计有关系,而我所通过 F# 了解到的,ML 系语言的 Hindley–Milner 类型系统,除了可以用来检查类型,还有很重要的作用是它能用来灵活完整地去设计领域模型。

假设我们要定义一个联系人类型:

5c54596a08db8

上面的代码用 TypeScript 来表达的话基本长差不多。这个类型定义的问题是它没有传达领域知识:

  1. 你不知道哪些字段是可选的

  2. 你不知道字段的限制。比如,FirstName 只能限制在50个字符以内。

  3. 你不知道字段之间的相互关联。比如前三个字段都应该在一个组里面。

  4. 你不知道字段的领域逻辑。比如邮箱地址变了后,邮箱认证就要变为 false。

上面这些问题,本应该在定义类型的时候就体现出来。而用传统面向对象的类型系统,比如 TypeScript,是做不到的。如果尝试去做的话,会让领域模型代码和实现细节代码混在一起。

下面来看 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 等词汇。通用语言的丰富意味着我们与领域专家(一般是产品需求方,比如产品经理)的理解更容易达成一致。

上面的思考只是对 Domain Modelling Made Functional 一书的仓促总结。更深的含义可能没表达完整。感兴趣的话推荐阅读这本书。

参考: Domain Modeling Made Functional - Scott Wlaschin