本文部分借鉴了阿里技术专家的 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)
}
}
这里有很重要的几个元素:
- val 修饰,确保 phone 是一个 不可变的 Value Object(一般 VO 都是 val 的)
- 逻辑都在 init 中,确保 Phone 类创建出来之后,一定是校验过的
- 之前的 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)
}
问题如下:
- 单一职责原则:将多个逻辑(单位比较、转换率)混在一起.
- 与业务代码混杂在一起
解决方法
上述案例中,可以考虑将单位转换的逻辑封装到一个单独的类中,并允许创建多个 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 的所有操作包装掉,仅暴露必要行为
最后
码字不易~