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

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

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

文章目录

  • [为什么出现 DDD?](#为什么出现 DDD?)
  • [Domain Primitive(DP)](#Domain Primitive(DP))
    • [案例1 - 将 隐形的 概念 显性化(DP 核心概念之一)](#案例1 - 将 隐形的 概念 显性化(DP 核心概念之一))
      • [基于 MVC 架构案例实现](#基于 MVC 架构案例实现)
      • [评估1 - 接口清晰度](#评估1 - 接口清晰度)
      • [评估2 - 数据验证和错误处理](#评估2 - 数据验证和错误处理)
      • [评估3 - 业务代码的清晰度](#评估3 - 业务代码的清晰度)
      • [评估4 - 可测试性](#评估4 - 可测试性)
    • [解决方案 - 基于 DP 案例实现](#解决方案 - 基于 DP 案例实现)
      • [评估1 - 接口清晰度](#评估1 - 接口清晰度)
      • [评估2 - 数据验证和错误处理](#评估2 - 数据验证和错误处理)
      • [评估3 - 业务代码的清晰度](#评估3 - 业务代码的清晰度)
      • [评估4 - 可测试性](#评估4 - 可测试性)
    • [案例2 - 将 隐性的 上下文 显性化(DP 核心概念之二)](#案例2 - 将 隐性的 上下文 显性化(DP 核心概念之二))
    • [案例3 - 封装 多对象 行为(DP 的核心概念之三)](#案例3 - 封装 多对象 行为(DP 的核心概念之三))
    • 总结
      • [使用 Domain Primitive 的三原则](#使用 Domain Primitive 的三原则)
      • [Domain Primitive 和 DDD 里 Value Object 的区别](#Domain Primitive 和 DDD 里 Value Object 的区别)
      • [什么情况下使用 Domain Primitive](#什么情况下使用 Domain Primitive)
  • 最后

为什么出现 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 逻辑如下:

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

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

kotlin 复制代码
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:

kotlin 复制代码
userService.register("0571-12345678", "李云龙", "陕西省西安市xxx") 

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

kotlin 复制代码
fun findByName(name: String): User?
fun findByPhone(phone: String): User?
fun findByNameAndPhone(name: String, phone: String): User?

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

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

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

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

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

kotlin 复制代码
if (phone.isNullOrBlank() || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
    throw ValidationException("phone");
}

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

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

kotlin 复制代码
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 - 业务代码的清晰度

kotlin 复制代码
//取电话号里的区号,然后通过区号找到区域内的 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 中:

kotlin 复制代码
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:

kotlin 复制代码
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 之后的效果:

kotlin 复制代码
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 - 接口清晰度

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

kotlin 复制代码
fun register(name: Name, phone: Phone, address: Address)

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

kotlin 复制代码
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 发送一条消息.

代码如下:

kotlin 复制代码
fun sendMessage(content: String, targetId: Long) {
    messageService.sendMessage(content, targetId)
}

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

解决方案

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

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

kotlin 复制代码
data class Message (
    val postId: Long,
    val content: String,
)

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

kotlin 复制代码
fun sendMessage(message: Message, targetId: Long) {
    messageService.sendMessage(message, targetId)
}

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

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

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

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

代码如下:

kotlin 复制代码
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对象将负责其自己的单位转换行为,并且我们可以根据需要组合使用多个对象来执行更复杂的转换逻辑。

kotlin 复制代码
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)  
    }  
}

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

kotlin 复制代码
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 的所有操作包装掉,仅暴露必要行为

最后


码字不易~

相关推荐
jk_10116 分钟前
MATLAB中decomposition函数用法
开发语言·算法·matlab
weixin_4640780716 分钟前
C#串口温度读取
开发语言·c#
无敌の星仔19 分钟前
一个月学会Java 第2天 认识类与对象
java·开发语言
豆豆41 分钟前
为什么用PageAdmin CMS建设网站?
服务器·开发语言·前端·php·软件构建
落落落sss1 小时前
MybatisPlus
android·java·开发语言·spring·tomcat·rabbitmq·mybatis
简单.is.good2 小时前
【测试】接口测试与接口自动化
开发语言·python
代码敲上天.2 小时前
数据库语句优化
android·数据库·adb
Yvemil72 小时前
MQ 架构设计原理与消息中间件详解(二)
开发语言·后端·ruby
程序员是干活的2 小时前
私家车开车回家过节会发生什么事情
java·开发语言·软件构建·1024程序员节
我是陈泽2 小时前
一行 Python 代码能实现什么丧心病狂的功能?圣诞树源代码
开发语言·python·程序员·编程·python教程·python学习·python教学