DDD 领域驱动设计 - Domain Primitive(Kotlin 落地实现)

发布于:2024-04-19 ⋅ 阅读:(17) ⋅ 点赞:(0)

本文部分借鉴了阿里技术专家的 DDD 系列文章,笔者只是在学习 DDD 的过程中记录自己想法、思考.

Ps:为了便于理解,笔者改动了部分案例,语言也换成 Kotlin

为什么出现 DDD?


项目开发中,见到的最多的可能就是 MVC 架构:

  • 想想我们到底有多久没有写过 “面向对象式” 的代码了?基本都是面向数据库表编程,走向面向过程的道路一发不可收拾.
  • 随着业务的发展,代码都堆积在 service ,导致代码的可维护性越来越差.
  • 实体类之间的关系复杂关系,牵一发而动全身,不敢轻易改代码
  • 外部依赖层直接从 service 层调用、字段转化、异常处理全都堆在一起,变成 “屎山” …

此时,DDD 就出现了.

DDD 不是一个套框架,而是一种架构思想,所以在代码层面缺少了足够的约束,导致 DDD 在实际应用中上手门槛很高,可以说绝大多数人对 DDD 的理解都有所偏差(随便一搜,漫天的 DDD 理论文章,却没有几篇落地实践的)。

当然,关于 DDD 的里面的理论我们并非所有都照搬照抄(最开始这本书就是一个人著作的,难免会出现个人客观想法),而是要辩证的去学习里面的东西,总结出一套自己团队用起来舒服,合理的代码结构,提升代码的质量、可测试性、安全性、健壮性.

这一篇,来讲讲最基础,但是又最核心,最具价值的 Domain Primitive.

Ps:Domain Primitive 的概念和命名来自于 Dan Bergh Johnsson & Daniel Deogun 的书 Secure by Design。

Domain Primitive(DP)


DP 可以说式一些模型、方法、架构的基础,就像 Integer、String 一样,DP 无处不在. 这里我们不讲概念,而是从案例入手.

案例1 - 将 隐形的 概念 显性化(DP 核心概念之一)

基于 MVC 架构案例实现

这里我们先来看一个简单的栗子,case 逻辑如下:

一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,同时希望在用户注册后通过用户电话(假设仅限座机)的地域(区号)对业务员发奖金.

一个简单的用户注册代码实现如下:

data class User (
    val userId: Long? = null,
    val name: String,
    val phone: String,
    val address: String,
    val repId: Long? = null,
)

@Service
class UserServiceImpl(
    private val userRepo: UserRepo,
    private val salesRepRepo: SalesRepRepo,
): UserService {

    override fun register(name: String?, phone: String?, address: String?) {
        //逻辑校验
        if(name.isNullOrBlank()) {
            throw ValidationException("name")
        }
        if(phone.isNullOrBlank() || !isValidPhoneNumber(phone)) {
            throw ValidationException("phone")
        }
        //此处省略 address 的校验逻辑

        //取电话号里的区号,然后通过区号找到区域内的 SalesRep
        var areaCode: String? = null
        val areas = arrayOf("0571", "021", "010")
        for (i in phone.indices) {
            val prefix: String = phone.substring(0, i)
            if (areas.contains(prefix)) {
                areaCode = prefix
                break
            }
        }
        val rep: SalesRep? = salesRepRepo.findRep(areaCode)

        // 最后创建用户,落盘,然后返回
        val user = User(
            name = name,
            phone = phone,
            address = address!!, //省略 address 的校验逻辑
            repId = rep?.repId
        )

        return userRepo.save(user)
    }

    private fun isValidPhoneNumber(phone: String): Boolean {
        val pattern = "^0[1-9]{2,3}-?\\d{8}$".toRegex()
        return pattern.matches(phone)
    }

}

评估1 - 接口清晰度

通过以下方式调用注册服务,编译器式不会报错的,并且很难通过代码发现 bug:

userService.register("0571-12345678", "李云龙", "陕西省西安市xxx") 

普通的 Code Review 也很难发现问题,很可能在代码上线之后才暴露出问题. 因此这里有另一种常见的解决方案,如下:

fun findByName(name: String): User?
fun findByPhone(phone: String): User?
fun findByNameAndPhone(name: String, phone: String): User?

虽然可读性有所提升,但是同样也面临着刚刚一样的问题. 这里的思考是:“有没有办法能让方法入参一目了然,避免入参错误导致 bug”.

评估2 - 数据验证和错误处理

a) 逻辑校验代码一般会出现 service 方法的最前端,确保 fail-fast,如下:


//逻辑校验
if(name.isNullOrBlank()) {
    throw ValidationException("name")
}
if(phone.isNullOrBlank() || !isValidPhoneNumber(phone)) {
    throw ValidationException("phone")
}
//此处省略 address 的校验逻辑

b) 但是假设如果你有多个类似的接口和类似的入参,在每个方法中这段逻辑会被重复. 而更严重的是如果未来要拓展电话号里去包含手机号时,可能需要加入以下代码:

if (phone.isNullOrBlank() || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
    throw ValidationException("phone");
}

如果将来你很多地方都用到了 phone 这个入参,但是有个地方忘记修改了,会造成 bug. 这既是 DRY 原则被违背时会发生的问题.

c) 如果有新需求,需要把入参的错误原因返回,那么这段代码就会变得更复杂:

if (phone.isNullOrBlank()) {
    throw ValidationException("phone不能为空");
} else if (!isValidPhoneNumber(phone)) {
    throw ValidationException("phone格式错误");
}

d) 在 spring-boot-starter-validation 中提供的注解(@NotNull、@Max…)可以解决一部分原因,另外可以使用 ValidationUtils 自定义工具类来校验. 但是还是不能避免以下情况:

  • 大量的校验逻辑集中在 ValidationUtils 中,很容易违背 Single Responsibility 单一性原则,导致代码混乱和不可维护.
  • 业务异常和校验异常混杂

评估3 - 业务代码的清晰度

//取电话号里的区号,然后通过区号找到区域内的 SalesRep
var areaCode: String? = null
val areas = arrayOf("0571", "021", "010")
for (i in phone.indices) {
    val prefix: String = phone.substring(0, i)
    if (areas.contains(prefix)) {
        areaCode = prefix
        break
    }
}
val rep: SalesRep? = salesRepRepo.findRep(areaCode)

// 最后创建用户,落盘,然后返回
val user = User(
    name = name,
    phone = phone,
    address = address!!, //省略 address 的校验逻辑
    repId = rep?.repId
)

这段代码中,就出现了一个很常见的情况,就是 从入参中抽取一部分数据(例如上述代码中的 phone),然后调用一个外部依赖获取新数据(salesRepRepo.findRep(areaCode)),然后从整个新数据中抽取部分数据用作其他作用. 这种代码通常被称为 “胶水代码” ,本质是由于入参不符合我们所需导致的.

常见的办法是将这段代码抽离出来,放到一个静态的工具类 PhoneUtils 中:

companion object {
    private fun findCode(phone: String): String? {
        val areas = arrayOf("0571", "021", "010")
        for (i in phone.indices) {
            val prefix: String = phone.substring(0, i)
            if (areas.contains(prefix)) {
                return prefix
            }
        }
        return null
    }
}

但是要思考的是,静态工具类是否是最好的实现方式呢?当你的项目中存在大量的静态工具类,你是否还能找到和兴的业务逻辑呢?

评估4 - 可测试性

假如一个方法中有 N 个参数,每个参数有 M 个校验逻辑,那么至少要有 N * M 个 case.

再假设有 X 个方法中都用到 phone 这个字段,那么这 X 个方法都需要进行测试,也就是说需要 X * N * M 个 case

Ps:这样的测试成本是相当的高的. 那么如果才能降低测试成本呢?

解决方案 - 基于 DP 案例实现

实际上,电话号仅仅只是一个用户的一个 String 类型参数,不存在任何逻辑,但实际上存在 电话号 转 区号 这样一个业务逻辑充直接塞到了 service 中,因此我们可以将 电话号的概念 显性化 ,通过写一个 Value Object:

data class Phone(
    val phone: String?
) {
    init {
        if(phone.isNullOrBlank()) {
            throw ValidationException("phone 不能为空")
        }
        if(!isValidPhoneNumber(phone)) {
            throw ValidationException("phone 格式错误")
        }
    }

    fun getAreaCode(): String? {
        phone?.let {
            val areas = arrayOf("0571", "021", "010")
            for (i in it.indices) {
                val prefix: String = it.substring(0, i)
                if (areas.contains(prefix)) {
                    return prefix
                }
            }
        }
        return null
    }

    private fun isValidPhoneNumber(phone: String): Boolean {
        val pattern = "^0[1-9]{2,3}-?\\d{8}$".toRegex()
        return pattern.matches(phone)
    }

}

这里有很重要的几个元素:

  1. val 修饰,确保 phone 是一个 不可变的 Value Object(一般 VO 都是 val 的)
  2. 逻辑都在 init 中,确保 Phone 类创建出来之后,一定是校验过的
  3. 之前的 findAreaCode 变成了 Phone 类里的 getAreaCode,突出了 AreaCode 是 Phone 中的一个计算属性

将 Phone 显性化之后,实际上是生成了一个 Type(数据类型)和 一个 Class(类):

  • Type:表示可以通过 Phone 去显性的表示电话号这个概念.
  • Class:表示今后可以把所有跟电话号相关的逻辑完整的放到一起

这两个概念加起来,就构成了标题中的 Domain Primitive(DP)

这里看一下使用 DP 之后的效果:

data class User (
    val userId: Long? = null,
    val name: Name,
    val phone: Phone,
    val address: Address,
    val repId: Long? = null,
)

@Service
class UserServiceImpl(
    private val userRepo: UserRepo,
    private val salesRepRepo: SalesRepRepo,
): UserService {

    override fun register(
        name: Name,
        phone: Phone,
        address: Address
    ) {
        //找到区域内的 SalesRep
        val rep: SalesRep? = salesRepRepo.findRep(phone.getAreaCode())

        // 最后创建用户,落盘,然后返回
        val user = User(
            name = name,
            phone = phone,
            address = address,
            repId = rep?.repId
        )

        return userRepo.save(user)
    }

}

Ps: 根据需要,这里 userId 和 repId 也可以是 Value Object

可以看到数据校验逻辑和非业务逻辑都消失了,剩下的都是核心业务逻辑,一目了然,接下来继续从上面的四个维度评估

评估1 - 接口清晰度

重构之后,接口声明非常清晰:

fun register(name: Name, phone: Phone, address: Address)

之前容易出现 bug,按照现在的写法,让接口 API 变得干净,易拓展:

userService.register(Name("李云龙"), Phone("0571-12345678"), Address("陕西省西安市xxx"))

评估2 - 数据验证和错误处理

重构后,业务逻辑代码中没有了任何数据验证,也不会抛出异常,这都归功于 DP 的特性

再来看,DP 的另一个好处就是遵顼了 DRY 原则 和 单一性原则,将来如果需要修改 Phone 的校验逻辑,只需要再一个类里修改即可.

评估3 - 业务代码的清晰度

除了不需要校验数据之外,原来的胶水代码,现在修改了 Phone 中的一个计算属性. 胶水代码通常不可复用,使用 DP 后,变得可复用、可测试的代码

评估4 - 可测试性

Phone 本身还是需要 M 个 case,但是我们只需要测试单一对象.

因此单个方法就从原来的 N * M 变成了 N + M.

案例2 - 将 隐性的 上下文 显性化(DP 核心概念之二)

背景

现在需要实现一个场景:让 用户A 给 用户B 发送一条消息.

代码如下:

fun sendMessage(content: String, targetId: Long) {
    messageService.sendMessage(content, targetId)
}

这个方法中,我们假设消息发送者的 id 是默认的,或者是其他地方确认的,这是一个隐性的上下文,但是在实际的应用中,消息的发送者id 通常是一个重要的信息,它可能会影响到消息发送方法、权限校验等逻辑信息.

解决方案

为了解决这个问题,我们可以将发送者这个隐形的上下文显性化,将发送者和消息内容组合成一个独立完整的概念.

如下:我们定义一个 Message 类:

data class Message (
    val postId: Long,
    val content: String,
)

然后,修改原有的 sendMessage 方法:

fun sendMessage(message: Message, targetId: Long) {
    messageService.sendMessage(message, targetId)
}

这样,通过将发送者这个隐性的上下文显性化,并于消息内容合并为一个完整的 Message 对象,避免了很多当前看不出来,但是未来可能会暴雷的 bug.

Ps: 这个案例中,根据某些特定的场景,也可以将 postId、content、targetId 整体归为一个 Message 中.

案例3 - 封装 多对象 行为(DP 的核心概念之三)

现在需要实现一个场景:将一个物品的单位转化另一个单位(此处为 公斤 和 磅 的相互转化),然后再通过计算出来的值处理其他逻辑

代码如下:

fun convertWeight(weight: Double, fromUnit: String, toUnit: String) {
    val conversionRate = if(fromUnit == toUnit) {
        weight
    } else if(fromUnit == "kg" && toUnit == "lb") {
        2.20462
    } else if(fromUnit == "lb" && toUnit == "kg") {
        0.453592
    } else {
        throw IllegalArgumentException("Unsupported unit conversion!")
    }
    val result = conversionRate * weight
    
    //... 其他业务逻辑
    handlerResult(result)
}

问题如下:

  1. 单一职责原则:将多个逻辑(单位比较、转换率)混在一起.
  2. 与业务代码混杂在一起

解决方法

上述案例中,可以考虑将单位转换的逻辑封装到一个单独的类中,并允许创建多个 ConversionRate 对象来表示不同的转换率。这样,每个ConversionRate对象将负责其自己的单位转换行为,并且我们可以根据需要组合使用多个对象来执行更复杂的转换逻辑。

data class ConversionRate(  
    val rate: Double,  
    val fromUnit: String,  
    val toUnit: String  
) {  
    fun convert(weight: Double): Double {  
        return rate * weight  
    }  
}  
  
class UnitConversion {  
    private val conversionRates: Map<Pair<String, String>, ConversionRate>  
  
    init {  
        conversionRates = mapOf(  
            Pair("kg", "lb") to ConversionRate(2.20462, "kg", "lb"),  
            Pair("lb", "kg") to ConversionRate(0.453592, "lb", "kg")  
            // 可以添加更多转换率  
        )  
    }  
  
    fun convertWeight(weight: Double, fromUnit: String, toUnit: String): Double {  
        val conversionPair = Pair(fromUnit, toUnit)  
        val conversionRate = conversionRates[conversionPair]  
            ?: throw IllegalArgumentException("Unsupported unit conversion!")  
  
        if (fromUnit == toUnit) {  
            return weight // 如果源单位和目标单位相同,则直接返回原重量  
        }  
  
        return conversionRate.convert(weight)  
    }  
}

这样原先的业务代码就优化成了这样:

fun convertWeight(weight: Double, fromUnit: String, toUnit: String) {
    val result = UnitConversion().convertWeight(weight, fromUnit, toUnit)
    
    //... 其他业务逻辑
    handlerResult(result)
}

总结

使用 Domain Primitive 的三原则

  • 让隐性的概念显性化
  • 让隐性的上下文显性化
  • 封装多对象行为

Domain Primitive 和 DDD 里 Value Object 的区别

在 DDD 中, Value Object 这个概念其实已经存在:

  • 在 Evans 的 DDD 蓝皮书中,Value Object 更多的是一个非 Entity 的值对象
  • 在Vernon的IDDD红皮书中,作者更多的关注了Value Object的Immutability、Equals方法、Factory方法等

Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象。在 VO 的 Immutable 基础上增加了 Validity 和行为。当然同样的要求无副作用(side-effect free)。

什么情况下使用 Domain Primitive

常见的 DP 的使用场景包括:

  • 有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
  • 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
  • 可枚举的 int :比如 Status(一般不用Enum因为反序列化问题)
  • Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
  • 复杂的数据结构:比如 Map<String, List> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为

最后


码字不易~