这次番外篇的内容其实挺有意思,领域驱动设计与函数式编程又有什么关系呢?似乎这是八杆子打不着的两样东西,有这样的疑惑很正常,所以不妨继续往下看,听我慢慢的说。

函数式编程

在开始介绍两者关系之前,先对函数式编程做一个简单的介绍。函数式编程与我们熟悉的结构化编程不同,前者属于声明式编程(Declarative Programming),而后者属于命令式编程(Imperative Programming),这是两种不同的编程范式。与我们熟悉的 C,C++,Java 相比,函数式编程语言有着自己非常显著的特点,一般被常常提及的有以下几点:

  • 函数是一等公民,支持高阶函数,即函数本身能够作为另一个函数的参数或是返回值。
  • 不变的数据结构,数据一旦生成就不允许修改,类似其他编程语言中的 constant 变量。
  • 支持类似 Sum,Product 的抽象代数数据类型(ADT)。
  • 支持 Functor,Monad 这样的类型类(Typeclasses)。

其实你会发现上述很多特性在近几年新出现的编程语言中都被大量使用,而一些老的编程语言也逐渐增加了更多的函数式编程的特性,例如 Java 从 8 开始正式加入了 lambda 的支持等。这也从另一方面说明函数式编程越来越被广大开发者所接受,并逐渐成为主流。

与领域驱动设计的结合

使用传统的面向对象语言,例如 Java 能够实现领域驱动设计中的各种模式或是设计策略,这点毫无疑问。但是在某些方面利用函数式编程的某些特性往往能够取得更加好的效果,例如更简洁的代码,更好的封装性,以及对外部框架更少的依赖。接下来我会使用 Kotlin 实现一些领域驱动设计中的概念,大家会看到一些截然不同的东西。

之所以选择 Kotlin 作为演示的语言,一则它作为 JVM 平台上的新锐语言吸收了大量函数式编程的特性,同时它又不像 Scala 那么复杂,语法上更为简单明了些。而不选用 Groovy 的原因是我不太熟悉,充其量只会写写 Gradle 的构建脚本 ?

模块

作为 DDD 中的一个概念,模块(Module)却很少被提及。一方面的原因是 DDD 的几本书中对于模块的阐述有些过于抽象,只是谈到了一些指导意见和简单的示例代码。另一方面是很多时候开发者容易把「模块」与「限界上下文」搞混,但是在 DDD 的原书中明确说明了两者是不同的。一般对模块的使用是把「相同业务概念」的对象放在一起,包括聚合,包括领域服务,包括工厂,仓库。而模块的名称应该是统一语言的一部分,是与业务相关的名词。

如果使用 Java 实现模块,那么我们的选择只有 Java 所提供的 package 机制。在大部分情况下 package 都表现的不错,但是在某些情况下,会有一些小小的问题。假设我们的系统中有两个模块,分别对应保险理赔的两个阶段,申请和受理,这两个模块下各自有一个针对理赔案件的聚合对象,在 Java 如何实现呢?大致代码是这样的:

package com.xxx.claim.application;

public class ClaimCase {
    ……
}

package com.xxx.claim.acceptance;

public class ClaimCase {
    ……
}

你可以使用 package 表示两个不同的模块,然后在两个模块下各自有一个名为 ClaimCase 的聚合。然后如果在某个 Service 中需要同时用到这两个聚合,你就只能用类似 com.xxx.claim.application.ClaimCase 这样的全限定名来引用不同模块下的同名聚合,显得非常冗繁。或者你可以将模块名放在聚合之前,类似 AppliedClaimCase,但有时候这样的可读性又不好,不容易其他人理解。

我们看一下 Kotlin 的一种可选方案,代码如下:

package com.xxx.claim

object Application {
  class ClaimCase {
      ……
  }
}

object Acceptance {
  class ClaimCase {
      ……
  }
}

我们可以通过 Nested Class 的特性将业务概念类似的对象都放在一个 object 中,而引用不同对象时就很方便: val newClaimCase = Application.ClaimCase() ,同时这样的代码可读性更好,对于揭示业务含义的能力也越强。

更加自由的方法定义

上一条可能和函数式编程的关系不大,很多借助的是 Kotlin 的特性,但是不难发现函数式编程语言往往都提供了更为灵活的包结构,或者说是编程的模块结构,比如刚才的例子使用 Scala 的 trait 也能轻松的实现。而对于方法的定义和使用上则更加凸显函数式编程的灵活。

Java 作为典型的面向对象编程语言,所有的方法都必须依附在类上。但是在实际项目中,并不是所有的方法都要归属某个类的,例如在理赔模块下有些方法是可以在所有理赔子模块中使用的,那么如何定义这些模块内的共用方法呢? 此时一种常见的做法是定义一个领域服务类,ClaimService,但是这样往往有些牵强,容易把许多无处安放的方法都放在里面。

在 Kotlin 中就可以拥有更为优雅的解决方案。Kotlin 中允许我们更为自由的定义方法,或者称之为函数。针对上面的例子,我们可以在 com.xxx.claim 的 package 下创建名为 claim.kt 的文件,然后直接在文件中定义方法:

package com.xxx.claim

fun <E: DomainEvent> publishClaimEvent(event: E) {
    ……
}

当需要调用方法时就可以直接调用而无需创建一个用来包装这些方法的服务类,不仅减少了不必要的封装,也更加符合业务意义。

同时这也鼓励我们更多的使用短小,简洁的方法,然后通过方法组合形成更大的,更复杂的方法,从而增加系统的灵活性与代码的复用。而对方法进行自由的组合也是函数式编程的一大特性。

更优雅的工程模式

「工厂」也是领域驱动设计中的一个核心概念,用来创建领域对象,特别是封装聚合的创建。在 Java 中没有直接对工厂的支持,需要采用设计 模式中的工厂模式。在功能上并没有什么缺陷,但问题在于多了不少冗余的代码。一种做法是针对每个领域对象创建一个工厂类,但是这样等于代码和文件数要翻一倍,太麻烦。另一种就是在领域对象上创建一个静态方法,用来创建对象的实例,同时还要把构造函数变为私有。

但是在 Kotlin 中可以通过伴生对象很方便的完成这一点:

class Accident private constructor(val dateOfAccident: LocalDateTime, val accidentType: AccidentType){
    companion object Factory {
        fun create(dateOfAccident: LocalDateTime, accidentType: AccidentType): Accident {
            return Accident(dateOfAccident, accidentType)
        }
    }
}

//创建 Accident
val accident = Accident.create(LocalDateTime.now(), AccidentType.CRITICAL)

通过伴生对象我们能够更好的封装领域对象的创建逻辑,同时将创建的逻辑从领域对象中与其他领域逻辑隔离开,放入到一个专门的伴生对象中,但是又没有增加额外的源文件。

模式匹配与策略模式

策略模式(Strategy Pattern)是开发者平时使用频率很高的一种设计模式,也是一种非常实用的模式。它能将类似的算法分散在不同的子类中,免去大量繁琐的 if 分支语句。但是在实际使用中,往往会遇到这样那样的问题。我们来看一个实际的例子。

在保险理赔金额的计算过程中需要按照投保险种计算赔付的金额,而不同类型险种计算时的算法是不同的,理所当然的这是一个应用策略模式的好机会。简单假设我们现在有固定期限的寿险,给付型的医疗保险以及账户型的万能险三种不同产品,它们的计算方式各不相同。于是你很快的定义了一个计算赔付金额的接口以及对应具体算法的实现类,代码如下:

public interface ClaimPaymentCalculator {
  ……
}

public class LifeInsuranceClaimPaymentCalculator implements ClaimPaymentCalculator {
  ……
}

……

但在定义具体计算金额方法的时候你犯难了,这个方法的参数怎么定呢?固定期寿险计算需要输入基本保额,而医疗险的计算则不仅需要输入基本保额还需要输入住院天数,每日住院津贴的金额,万能险则需要输入基本保额和投资账户内的余额,也就是说这些计算规则的输入参数都是不同的。

这时你有几种选择,最简单的就是设计一个通用的数据结构,把这三种产品计算的数据都放进去,但是这样做的缺点很明显,其一是以后如果增加新的产品,再有新的计算参数,你就要修改这个通用的数据结构,非常的不稳定。其二是调用这个方法的代码可能遗漏某些参数,而这样的 bug 只可能在测试或是运行时发现,不能在编译时进行检查,无疑也是一个大问题。

那么 Kotlin 与函数式编程中又有什么好办法呢?

sealed class ClaimPaymentCalculator
class LifeInsuranceClaimPaymentCalculator(val basicInsuredAmount: BigDecimal) : ClaimPaymentCalculator() {
    fun doCalculate(): BigDecimal {
        ……
    }
}

class HealthInsurancePaymentCalculator(val basicInsuredAmount: BigDecimal) : ClaimPaymentCalculator() {
    fun doCalculate(daysInHospital: Int, dailyExpense: BigDecimal): BigDecimal {
        ……
    }
}

class UniversalInsurancePaymentCalculator(val basicInsuredAmount: BigDecimal) : ClaimPaymentCalculator() {
    fun doCalculate(accountBalance: BigDecimal): BigDecimal {
        ……
    }
}

fun calculateClaimPayment(calculator: ClaimPaymentCalculator): BigDecimal {
    ……
    return when (calculator) {
        is LifeInsuranceClaimPaymentCalculator -> calculator.doCalculate()
        is HealthInsurancePaymentCalculator -> calculator.doCalculate(daysInHospital, BigDecimal(dailyExpense))
        is UniversalInsurancePaymentCalculator -> calculator.doCalculate(BigDecimal(accountBalance))
    }
}

上面的代码中我们定义了计算赔付金额的接口以及三个具体的实现类,但是这个接口只是一个标识性的接口,并没有定义计算的方法。具体计算的方法在实现的子类中。可以看到这些计算方法的参数是不一样的。然后在 calculateClaimPayment 中通过模式匹配(pattern match),调用了不同的计算方法,而且能够传入不同的参数,保证了计算接口参数的最小化。同时由于接口上的 sealed 关键字,编译器在编译阶段就能保证我们没有遗漏需要处理的子类,整个代码逻辑非常清晰,且不会出错。

似乎缺了点什么……

谈及函数式编程,如果没有说到抽象代数类型,函子,单子,幺半群(我知道,你们其实就想听这个)就好象半夜饥肠辘辘想吃方便面的时候,发现缺了调味包。虽然能吃,但是却没了什么味道。但是因为篇幅的原因,这次就先写到这,下一篇的番外篇会介绍如何使用函数式编程中的 Monad(对,就是那个「说白了不过就是自函子范畴上的一个幺半群而已」的 Monad),Functor 在领域驱动设计中的应用,希望你会喜欢。

欢迎关注我的微信号「且把金针度与人」,获取更多高质量文章

QR.png


Joshua
17 声望13 粉丝