上一篇文章中介绍了一些如何在领域驱动设计中使用函数式编程,进而提升代码可用性的技巧。其中缺少了使用函数式编程中 Typeclass(类型类) 的应用场景,恰好最近需要对重构遗留代码中的一个模块(看我拖更的频率就知道这个重构不简单?),经过思考之后觉得这项重构可以借助 Monad 提升可维护性,所以稍作整理后便有了这篇文章。希望能给大家带来一些新的启发。
如何优雅的实现业务规则校验
具体的业务需求其实不难,在保单正式生效之前需要做一系列的校验,保证相关业务数据的正确,例如财务数据,保费数据等。虽然逻辑比较简单,大部分都是查询数据库,然后按照结果判断是否为正常数据,但是规则的数量非常多。现有的实现中将所有逻辑都在一个类中完成,单个文件的代码长度超过 3000 行,虽然做了一些简单的封装,例如将规则提炼为单个的私有函数,但是依然难以管理与测试,后续新规则的开发与维护都很不方便。
在之前的文章中我介绍过如何使用 Specification 模式对领域对象进行校验,分离领域层的逻辑与规则校验的逻辑。在具体实现的过程中如果能够合理运用函数式编程中的一些特性能够帮助我们编写更加流畅的代码,并更好的管理复杂度。
开始我们的重构之前,先简单看一下现有代码是怎么样的:
String result = StringUtils.EMPTY;
result = checkPolicyProduct(policy);
if (StringUtils.isNotEmpty(result) {
throw new ValidationException(result);
}
result = checkXXXXX(policy);
......
上述代码的逻辑不难理解,checkXXXX
是当前类中用以进行业务规则校验的私有方法,它接受的参数都是类型为 Policy
的领域实体,并且会返回一个字符串,如果字符串内容为空则表示通过校验,否则就包含了引起校验失败原因的描述。
我们先不考虑原有接口设计是否合理,先想办法在不改变这些校验方法接口的情况下如何重构,毕竟我们希望先能够复用这些已经存在的业务代码。
使用 Either 管理异常
因为篇幅的原因,我这里就不介绍 Monad 的定义,毕竟这不是一句话就能说明白的东西: 一个单子(Monad)说白了不过就是自函子范畴上的一个幺半群而已 (大雾)。但是学习 Monad 的过程无疑是非常有价值的,我推荐可以从 Functors, Applicatives, And Monads In Pictures - adit.io 开始,有了一些概念后再阅读更多的材料。额外提一下,这篇文章的作者也是 算法图解 (豆瓣) 的作者,同样也是非常值得一读的作品。
如果你是 Java 程序员,那么你日常使用的 List
与 Optional
对象都是 Monad,所以不必过于纠结与概念,轻松往下看就好。
Either 类型
在开始介绍新的校验实现之前,先介绍一下 Either
这个数据类型,同时为了更好的展示函数式编程的优点,我依然会使用 Kotlin 编写示例代码。Either
并不是 Kotlin 内建支持的数据类型,因此我也会引入第三方库 Λrrow 的支持。如果你是 Java 程序员也不必担心,可以通过引入 Vavr 来支持 Either
。
@higherkind sealed class Either<out A, out B>
上面是 Either
的定义,它是一个由两部分组成的数据类型,即左值(Left Value) 和右值(Right Value),这两个值的类型可以是不同的。任何一个 Either
的实例,要么是左值,要么是右值,没有例外,因此 Either
也是一个 ADT,即抽象代数类型。
实际使用中,我们往往使用 Either
封装函数调用的异常,你可能在其他的库中看到过类似的应用,不同的是使用的数据类型可能是 Try
,其实这两者非常类似,只是无论 Λrrow 还是 Vavr 都把 Try
列为了 Deprecated
,不再建议使用,所以这次我们也是使用 Either
编写示例代码。
通常我们使用左值表示异常,而右值表示正常调用后的返回结果,即: Either
。接着让我们开始校验规则的接口设计。
Spec 接口
先看一下我们的 Spec
接口:
interface Spec<T> {
fun validate(entity: T): Either
}
这个接口很简单,其中只有一个 validate
方法,接受一个领域对象,然后返回一个封装了异常或是正常结果的 Either
。同时为了能够更好的管理规则,我们使用 Composition
组合模式设计一个表示规则集的数据结构:
abstract class SpecSet<T> : Spec<T> {
private val specs: List>init {this.specs = Collections.unmodifiableList(buildSpecs())
}override fun validate(entity: T): Either {return this.specs.fold(Either.Right(entity))
{ result, spec ->return when (result) {is Either.Left -> resultis Either.Right -> result.flatMap { e -> spec.validate(e) }
}
}
}abstract fun buildSpecs(): List>
}
SpecSet
是一个抽象类,它同样实现了 Spec
接口,而它在内部使用 List
对象保存多个 Spec
接口的对象。通过继承 SpecSet
,并实现抽象方法 buildSpecs
就能完成规则集数据的构建。
SpecSet
中的 validate
方法会依次调用 specs
中每个 Spec
对象的 validate
方法完成校验。只是与单纯的循环不同,这里我们使用了 fold
方法,这个方法与我们熟悉的 reduce
方法类似,会将容器中的每个元素作为方法调用的参数进行规约。
我们会按照每次调用结果是左值(异常),还是右值(正常)决定下一次是否继续执行后续的检查。而 Either
上的 flatMap
方法正是体现了 Monad 的便利之处。
完成了接口定义之后让我们看看如何具体使用。
应用
我们先通过实现 Spec
接口定义了三个校验规则: 是否已经通过核保,保费是否已经缴纳,保险产品是否有效,示例代码如下:
class UnderwritingValidateSpec : Spec<Policy> {
override fun validate(policy: Policy): Either {if (......) {return Either.Left(RuntimeException("Invalid underwriting data"))
}return Either.Right(policy)
}
}class PremiumValidateSpec : Spec<Policy> {
......
}class PolicyProductValidateSpec : Spec<Policy> {
......
}
代码中的逻辑很容易明白,如果能够通过校验则会以右值的形式返回传入的 Policy
对象,否则会返回用左值包装的RuntimeException
对象。
接着我们定义一个用于保单承保前的规则校验集合:
class PolicyIssueValidateSpecSet: SpecSet<Policy>() {
override fun buildSpecs(): List> {return listOf(UnderwritingValidateSpec(), PremiumValidateSpec(), PolicyProductValidateSpec())
}
}
接着我们就能注入规则集并运行了:
val sp: Spec = PolicyIssueValidateSpecSet()
sp.validate(policy).mapLeft { e -> throw e }
这么做的优点在于能够把不同的规则放置在不同的目录之下管理,并能按照需要在规则集层面进行组装。规则的运行,定义,组装都在不同的类中完成,互相没有紧密耦合的关系,而通过 Either
能够很方便的封装校验过程中发生的异常,减少不必要的代码。
小结
从上面寥寥的数十行代码中我们就通过函数式编程的 Monad 的特性实现了一个不错的规则验证框架,在这里 Monad 为我们封装了由于异常引起的副作用,使得各个规则校验函数的调用可以组合,从而让复用变为可能。类似的例子其实还有很多,例如使用其他的类型类从函数层面完成各式各样的组合操作,能够更为方便的支持并行计算。
而我们的例子其实还能进一步提升,细心的你一定看出我们现在校验的逻辑是一种短路逻辑,即遇到第一个校验失败时就会停止调用后续的校验方法。但是实际项目中我们则希望即使遇到了异常也能继续向下执行。当所有校验方法执行完成后,将所有的异常信息汇总后在返回给用户。需要完成这样的修改并不难,作为读者的你不妨自己尝试一下,我想你一定会获得更为深入的函数式编程体验!
希望这次的文章会让你对函数式编程以及 DDD 都产生兴趣,我们下次见吧!
DDD实践手册(番外篇: 函数式编程与领域驱动设计)
DDD 中的那些模式 — 领域事件
DDD 中的那些模式 — 使用 Specification 管理业务规则
DDD 中的那些模式 — CQRS