使用函数式语言实践DDD

长期以来我都在实践OOP,进而通过OOP来实现DDD,特别是如何通过面向对象的技巧来建立一个领域模型。OO的一些特性在建立领域模型时显得恰如其分,能否掌握OO的技巧,对创建领域模型有着至关重要的作用。
这篇文章为大家介绍一种常见的函数式架构,特别是如何通过函数式语言来实现DDD,进而利用函数式组合的特性,创建函数pipeline。
软件架构是围绕着领域模型而做的若干设计,如果按照c4模型的定义,软件架构由下面四个级别的架构组成的:

  • "System context"是最高层的架构,代表着整个系统
  • "Container"是组成"System context"的单元,通常用来表示可部署的单元,例如一个"API service", 一个web应用程序等
  • "Component"是组成"Container"的基本单元,通常指组若干抽象组件,是一个"Container"里面的骨架,也是本文要重点介绍的架构
  • "Code"具体到了代码级别,通常指实现某个"Component"应该有哪几个类组成

使用单体应用来承载多个限界上下文

领域驱动设计中有一半概念是在讨论问题域,并不是一上来就教你如何写代码,这说明理解一个问题域是复杂的,看清问题的本质是需要时间的。当你开始着手划分限界上下文的时候,说明你已经对需求有了很好的了解。但是经验告诉我们,刚开始你的理解,往往都不是最终的需求,或者仍然需要多次跟领域专家确认和交互,才能得到最终的需求。
这个时候,如果你一上来就按照限界上下文划分微服务,往往可能会步入Microservice Premium
要想软件在一开始就能达到快速试错的目的,一上来就做微服务, 会让步子迈得有点大。微服务架构带来了分布式的复杂性,使得前期生产效率大大降低,另外还存在船大难掉头的情况,一旦设计出现返工,生产效率也会打折扣。当然,这不是绝对的,如果架构师已经在该行业深耕多年,对业务更是了如指掌,项目一开始就设计为微服务也未尝不可。
在项目初期,在需求还不是非常明确的时候,你完全可以创建一个单体应用,然后通过不同的模块或程序集来隔离不同的界限上下文,通过不断的试错和快速反馈来调整你的解决方案。
一种比较严格的说法是,当你关闭其中一个微服务,如果整个应用程序都崩了,其实你设计的不是一个微服务架构,而是一个分布式单体应用程序。

代码结构

在过去的若干年里,我经常使用一种叫“Layer architecture"的软件架构, 这种架构往往把代码分成若干层:

  • 基础设施层:通常用来负责跟第三方或者数据库打交道,用来持久化数据或者API请求。
  • 领域层或者业务逻辑层:用来封装业务逻辑
  • 应用程序层:通常是很薄的一层,用来协调领域层和基础设施层
  • 展现层:用来展现UI或者输出API结果
    这种架构方式是一个自上往下的输入,最后从下往上输出结果的工作流(图1)。

    实际上,当我在使用这种方式组织代码时,遇到最大的挑战在于:这种分层方式,把同一个输入到输出的的若干部分,横向的分散到了若干层中。当你需要修改某个API时,需要同时修改若干个层。另外这种组织代码的方式,往往会让OO走向混乱,一个名叫OrderApplicationService的类中放满了各种跟Order相关的方法,通常对Order的操作有数十种之多,他们属于OrderApplicationService吗?如果属于,任何一个跟Order相关操作的参数变化,都会引起这个类被改动,这种对类的频繁修改合理吗?
    函数式编程中,更倾向于纵向组织代码(图2),

    例如一个API操作,就是一个文件或者模块,整个操作自上而下的流程被组织到同一个文件里,这样做的好处是,针对某个功能的修改,只关注与当前工作流相关的文件即可。

信任边界

在问题域里,各种业务之间的边界是模糊的,限界上下文则是业务在解决方案上的映射,是人为划分的边界。在边界里面的内容,是可信任和合法的,相反,界限外面的一切输入,则是非法和不可信任的(图3)。

这就要求我们在限界上下文的边界,引入验证逻辑,从而阻止外部输入,以及验证对外部的输出。
常见的验证逻辑如:

  • 输入DTO,需要转化为领域模型,用于处理业务逻辑
  • 对输入数据的合法性验证,例如:用户名不能为空,邮件格式是否正确
  • 对输出类型的安全性校验,例如:防止在输出数据里包含用户密码等敏感信息
    验证逻辑并不是FP独有的,不过FP中常常使用Applicative对数据进行验证,从而收集多个用户Error。关于Applicative, 以后会单独写文章介绍。
    一旦输入数据突破信任边界,在领域模型建模的过程中,你不需要担心用户名是否是空,邮件格式是否正确等问题。你应该专注于使用FP的代数数据类型进行领域建模,请参考我之前写过一篇使用函数式语言来建立领域模型--类型组合
    对输出的验证则不太一样,主要关心对输出数据的安全性保护,防止将一些领域模型中的私有属性输出到外部世界。

通过状态机来处理业务逻辑

纵然,通过FP的代数数据类型(Algebraic data type)能够快速完成领域建模,但是我们知道,领域模型不是静态的,它是由一些列事件组成的过程。而这种转化过程,正是领域模型状态发生变化的过程,即状态机(图4)。

领域模型状态转换的过程跟实现语言无关,一个设计精良的领域模型,就好比一个状态机。例如在买机票的过程中,填写个人信息,填写联系人,选座,买保险和付款的过程,就是订单状态发生变化的过程。再比如用户注册的过程,填写基本信息,验证邮箱,也是用户信息状态发生变化的过程。以OO为例,我们习惯于通过增加标志位的方式,进行领域建模:

type User = {
  name: string
  password: string
  email: Email | null 
  isEmailVerified: boolean //当验证完email后设置为true
  canLogin: boolean //当email被验证后方可login
}

业务逻辑的实现过程,就是填充用户属性和修改标志位的过程。然而,这种方式实际上存在若干问题:

  • 有些属性在业务前期是不需要的,例如canLogin, 只有验证完email才有效
  • 有些标志位实际上不是单独存在的,例如isEmailVerified就跟email是紧密相关的,而这个模型无法反映出来这一信息
  • email被定义为可空类型,导致使用该模型的地方不得不使用null检查
    通过状态机的机制,重新考虑用户注册过程:(图5)

按照上面的状态重新对用户建模,得到的模型如下:

type UnVerifiedUser = {
  name: string
  password: string
}

type VerifiedEmailUser = {
  name: string
  password: string
  email: Email
}

type User =
  | UnVerifiedUser
  | VerifiedEmailUser
  

如果有更多的用户状态,你还可以持续添加到User类型中。
这种通过"|"创建的User类型被称为在FP中被称为union类型,也叫product或sum类型, 在TypeScript被称为Discriminated union。这时候的User类型,可以用来在领域模型中实现领域逻辑,通常这种union类型需要配合模式匹配来完成,例如修改密码,登录,修改邮件地址等逻辑,都是针对User类型做模式匹配的过程。关于模式匹配的用法,在此不再细说。
这种通过状态机的方式,实现业务逻辑时有下面几个好处:

  • 业务模型在不同的状态,提供不同的业务能力
  • 模式匹配会强制你处理每种状态的行为,避免遗漏一些边边角角的情况
  • 相比于将所有状态记录在同一个模型中,状态机可以帮你梳理整个业务状态的变化

保持纯净的领域模型

函数式编程的一个主要目标就是让代码有预测性,通过函数签名理解函数的用途。为了达到这个目的,函数式语言设计了若干特性,例如不可变的数据结构,还有各类Monad来避免副作用。在DDD实践中,应该避免I/O相关的代码出现Domain中。例如读写数据库,调用第三方系统的API等相关代码,需要把这类具有副作用的代码推到Domain的外围。如果需要做的更好,那就必须使用CQRS加Event Sourcing。我在之前一篇文章提到过这个观点,不过部分读者没有理解其中的意思,我在这里再做一些说明。首先,CQRS不仅仅是为了读写分离,从而提高读写性能。读模型和写模型(领域模型)的分离意味着职责也是分离的,从而在设计领域模型的时候,打消对查询性能的考虑,有助于设计出纯净的领域模型。当然仅靠CQRS还是不够的,有些时候任然无法完全脱离数据库的考虑,因为领域模型始终是要持久化在数据库里,你就要考虑数据库相关的约束,例如主外键,如何建表,如何高效存储一个列表等。而持久化一个Event则完全摆脱了数据库技术,因为一个Event就是一个json, 只有这样才能设计出理想的领域模型。当然引入CQRS和ES在项目初期成本略高,不再详细描述。

通过Monad创建pipeline

以API为例,一个完整的用户请求就是一个Pipeline(图6)。

假设每一步都是有若干个函数组成,我们能够将他们组合到一起吗?答案是很难,主要原因如下:

  • 每一步的若干个函数签名很难保持一致,导致compose这样的函数无法正常工作
  • 部分I/O相关的函数可能是异步的,领域模型中的代码大多是同步的,很难将他们组合在一起
  • 在函数式编程中,通常不会通过try...catch的方式处理异常,一方面异常也是一种副作用,另一方面,异常让函数签名不再完整。如何把每一步的异常带到最外面也成了问题
    而解决这一切的手段就是Monad, 简而言之,Monad是一种抽象方式,能够将monadic风格的函数连接起来。什么又是monadic? 简单来说这是一种接收普通类型,返回某种lift类型(泛型)的函数。例如通过IO, Task, Either相关的Monad来解决此类问题。具体内容请关注本人的函数式系列博客。

小结

这篇文章总结了一些使用函数式语言实践DDD的大致思路,也为函数式架构提供了一些参考。由于篇幅的原因,并没有介绍到DDD的方方面面,同时,一些实现细节则是点到为止,例如如何使用Monad。总体来说,函数式语言的代数数据类型,以及函数式的一些思想,为实践领域驱动设计提供了其他的选项。

posted @ 2021-03-09 23:48  richiezhang  阅读(2060)  评论(11编辑  收藏  举报