8

使用函数式语言实践DDD

 3 years ago
source link: https://www.cnblogs.com/richieyang/p/14500639.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

使用函数式语言实践DDD

使用单体应用来承载多个限界上下文
代码结构
信任边界
通过状态机来处理业务逻辑
保持纯净的领域模型
通过Monad创建pipeline
小结

长期以来我都在实践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。总体来说,函数式语言的代数数据类型,以及函数式的一些思想,为实践领域驱动设计提供了其他的选项。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK