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

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

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

文章目录

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

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

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

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 的校验逻辑//取电话号里的区号,然后通过区号找到区域内的 SalesRepvar areaCode: String? = nullval areas = arrayOf("0571", "021", "010")for (i in phone.indices) {val prefix: String = phone.substring(0, i)if (areas.contains(prefix)) {areaCode = prefixbreak}}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 = prefixbreak}
}
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) {//找到区域内的 SalesRepval 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 的所有操作包装掉,仅暴露必要行为

最后


码字不易~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/diannao/210.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

C#中检查一个矩阵是否可逆

在C#中&#xff0c;要检查一个矩阵是否可逆&#xff08;即是否是满秩的&#xff0c;或者说是否有逆矩阵&#xff09;&#xff0c;以及计算它的逆矩阵&#xff0c;你可以使用数学库&#xff0c;比如Math.NET Numerics。这个库提供了强大的数学和统计功能&#xff0c;包括线性代数…

JavaEE初阶Day 10:多线程(8)

目录 Day 10&#xff1a;多线程&#xff08;8&#xff09;单例模式阻塞队列1. 生产者消费者模型1.1 生产者消费者模型解耦合1.2 生产者消费者模型削峰填谷 2. 生产者消费者代码3. 阻塞队列实现 Day 10&#xff1a;多线程&#xff08;8&#xff09; 单例模式 单例模式&#xf…

SQL Server Management Studio 显示行号

前言 在使用 SQL Server Management Studio (SSMS) 进行数据库管理和查询时&#xff0c;能够看到代码的行号是非常有用的。这可以帮助您更容易地定位代码错误、讨论特定的代码行&#xff0c;或者在执行长查询时快速找到特定行。在本文中&#xff0c;我将向您展示如何在 SSMS 中…

2024年华中杯数学建模竞赛ABC题思路分析

简单分析一下各个题目可能需要用到的方法和模型&#xff0c;完整代码和成品论文见文末 A题 太阳能路灯光伏板的朝向设计问题: 1. 球面几何、天文学相关知识,如赤纬角、太阳高度角、时角等概念和公式 2. 太阳辐射模型,根据太阳能辐射强度、大气衰减系数等计算地表太阳辐射强度…

蓝桥杯第十五届javab组个人总结

javab组 额今天早上打完了得对自己此次比赛做总结&#xff0c;无论是明年还参赛还是研究生蓝桥杯&#xff0c;体验感有点差&#xff0c;第一题其实一开始想手算但怕进位导致不准确还是让代码跑了&#xff0c;但跑第202420242024个数&#xff08;被20和24整除&#xff09;一直把…

【网络编程】Web服务器shttpd源码剖析——线程池调度

hello &#xff01;大家好呀&#xff01; 欢迎大家来到我的网络编程系列之web服务器shttpd源码剖析——线程池调度&#xff0c;在这篇文章中&#xff0c;你将会学习到在Linux内核中如何创建一个自己的并发服务器shttpd&#xff0c;并且我会给出源码进行剖析&#xff0c;以及手绘…

FebHost:注册.CA域名的企业有什么限制?

在加拿大&#xff0c;只要满足加拿大互联网注册管理局的“加拿大注册要求”&#xff0c;任何类型的企业都可以注册.CA域名。这些要求的目的是为了确保.CA域名空间作为一个重要的公共资源得到合理的使用和开发&#xff0c;以促进所有加拿大人的社会和经济发展。 以下是一些主要…

双向带头循环链表的接口函数实现

学习内容&#xff1a; 1.双向链表的节点形式以及函数声明 #include<stdio.h> #include<stdlib.h> #include<assert.h> typedef int DataType; typedef struct ListNode { int val; struct ListNode* next; struct ListNode* prev; }LTNode; voi…

docker安装EelasticSearch、目录权限修改、并安装IK 中文分词器

文章目录 docker安装EelasticSearch、目录权限修改、并安装IK 中文分词器1、docker安装ES2、docker ps发现容器没有正常启动&#xff0c;docker logs 容器id 查看日志发现是挂载目录的权限不足3、修改目录的权限4、使用docker restart 容器id重新启动刚才没有启动成功的容器5、…

Leetcode 4.18

Leetcode 1.无重复字符的最长子串2.最长回文子串3.整数反转4.字符串转换整数 (atoi)5.正则表达式匹配 1.无重复字符的最长子串 无重复字符的最长子串 滑动窗口&#xff0c;先让右指针右移&#xff0c;如果发现这个子串有元素和右指针当前元素重复。 则&#xff1a; 左指针右移…

【嵌入式之中断】

Cortex-M4集成了嵌套式矢量型中断控制器(Nested Vectored Interrupt Controller (NVIC))来实现高效的异常和中断处理。NVIC实现了低延迟的异常和中断处理&#xff0c;以及电源管理控制。它和内核是紧密耦合的。 凡是打断程序顺序执行的事件都称为异常&#xff08;exception&am…

极狐GitLab x LigaAI,AI 时代研发提效新范式

GitLab 是一个全球知名的一体化 DevOps 平台&#xff0c;很多人都通过私有化部署 GitLab 来进行源代码托管。极狐GitLab 是 GitLab 在中国的发行版&#xff0c;专门为中国程序员服务。可以一键式部署极狐GitLab。 近日&#xff0c;极狐GitLab 和 LigaAI 宣布合作&#xff0c;双…

Linux进程间共享内存通信时如何同步?

在Linux中&#xff0c;进程间的共享内存通信需要通过同步机制来保证数据的正确性和一致性&#xff0c;常用的同步机制包括信号量、互斥锁、条件变量等。 其中&#xff0c;使用信号量来同步进程间的共享内存访问是一种常见的方法。每个共享内存区域可以关联一个或多个信号量&am…

全量知识系统 详细程序设计 之“编程理念”(QA 百度搜索)

Q1. 今天聊聊 全量知识系统 &#xff08;“全知系统”&#xff09;详细程序设计 之“编程理念” 全量知识系统&#xff08;全知系统&#xff09;是一个旨在整合、处理和提供广泛知识的系统。在详细程序设计之前&#xff0c;确立清晰的编程理念是至关重要的。以下是一些建议的编…

CentOS 7安装Zookeeper

说明&#xff1a;本文介绍如何在CentOS 7操作系统下使用Zookeeper 下载安装 首先&#xff0c;去官网下载所需要安装的版本&#xff0c;我这里下载3.4.9版本&#xff1b; 上传到云服务器上&#xff0c;解压 tar -xvf zookeeper-3.4.9.tar.gz修改配置 进入Zookeeper目录下的co…

视觉感知画质评价算法CenseoQoE介绍

视频评价 视频质量评价(Video Quality Assessment,VQA)是指通过主观、客观的方式对视频图像的内容、画质等,进行感知、衡量与评价。 关于视频评价的详细介绍可以参考:视频质量评价VQA。 CenseoQoE CenseoQoE 是一个针对图像与视频感知画质评价从算法模型训练到应用落地…

【技术变现之道】如何打造IT行业的超级个体?

前言 在当今的数字化时代&#xff0c;IT行业蓬勃发展&#xff0c;为具备技术专长的个人提供了无限的可能性。想要成为IT行业的超级个体&#xff0c;实现知识与技能的变现吗&#xff1f;以下是一些高效途径&#xff0c;助你一臂之力&#xff01; 1. 独立接单外包 1&#xff09…

vue3数字滚动组件

效果图 一、安装插件 npm i vue3-count-to 二、components文件夹下新建BaseCountTo.vue文件 <template><BaseCountTo :endVal"endVal" :decimals"decimals" /> </template> <script setup > import { defineComponent, watch, r…

改手机IP地址的软件推荐

随着移动互联网的普及&#xff0c;手机已成为人们日常生活中不可或缺的一部分。而在使用手机的过程中&#xff0c;IP地址作为一个重要的网络标识&#xff0c;有时也需要进行修改或更改。为了满足这一需求&#xff0c;市面上涌现出了许多改手机IP地址的软件。虎观代理将对这些软…

韩顺平Java | C27 正则表达式

入门介绍 需求&#xff1a;提取文本中某类字符 传统方法&#xff1a;遍历每个字符&#xff0c;判断其是否在ASCII码中某种类型得编码范围内&#xff0c;代码量大&#xff0c;效率不高 正则表达式(RegExp, regular expression)&#xff1a;处理文本的利器&#xff0c;是对字符…