领域驱动中的Android开发

news2024/11/29 10:55:49

领域驱动的Android开发

构建有意义的模型

在Android开发世界中,最佳架构的竞争一直存在。MVC、MVP、MVVM、MVI是互联网上许多文章讨论的热门话题。

我们知道View不应包含任何复杂逻辑。我们知道Controller、Presenter和ViewModel之间的区别。但是,我们知道如何为我们的应用程序构建一个合适的模型吗?

贫血领域模型反模式

很多时候,我们将模型视为一组仅持有数据的类。没有别的东西。所有的逻辑都是在View Model、Use Case、Repository或Service中实现的,它们修改这些模型类。

这种反模式的根本问题在于它与面向对象设计的基本思想背道而驰;而面向对象设计的基本思想是将数据和处理过程结合在一起。贫血领域模型实际上只是一种过程式风格的设计。

更糟糕的是,许多人认为贫血对象就是真正的对象,完全忽略了面向对象设计的关键点。

这个反模式在Martin Fowler的一篇博文中进行了描述。我敢说这是最常见的反模式之一,在许多应用程序中被广泛使用。

https://martinfowler.com/bliki/AnemicDomainModel.html

领域驱动的Android开发

有了领域驱动设计的思想,我们可以构建一个有意义的模型,不仅持有数据,而且主要描述和解决现实世界中的业务问题。为了本文的目的,我准备了一个领域驱动的Android应用程序作为示例。您可以在GitHub上找到它。

https://github.com/Maruchin1/domain-driven-android

这是一款快餐店的会员应用。用户购买食物可以获得积分,然后可以在移动应用程序中兑换积分换取优惠券。

1. 显示优惠券

我们应用的第一个需求是显示所有可用优惠券的列表,并能够预览每一个优惠券。所有的优惠券都来自一个返回JSON格式的HTTP端点。
此时,我们的应用仅在屏幕上显示来自服务器的数据。但即便如此,我们已经可以从DDD(领域驱动设计)方法中获益。

[
  {
    "id": "1",
    "name": "Cheesburger with fries",
    "points": 200,
    "image": "https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/images/cheesburger_with_fries_coupon.jpeg"
  },
  {
    "id": "2",
    "name": "Chicekburger with fries",
    "points": 150,
    "image": "https://raw.githubusercontent.com/Maruchin1/domain-driven-android/master/images/chickenburger_with_fries_coupon.jpeg"
  },
]

定义实体(Entities)

我们首先定义一个Coupon类,它是一个实体(Entity)。实体代表我们领域的主要概念。每个实体的实例都有唯一的标识。我们可以通过id属性来识别Coupon。

data class Coupon(val id: ID, ...)

值(Values)的定义

除了实体(Entities),我们的模型还可以包含值(Values)。与实体不同,值没有标识和生命周期。每个实体由值或其他嵌套实体构建。在Coupon中,我们没有任何嵌套实体,只有值(Values)。
当值(Value)有多个字段时,我们可以使用数据类(data class)来表示它。对于只有一个字段的值(Value),Kotlin提供了很好的值类(value class)功能。我们还可以使用内置类型,如java.net.URL

data class Coupon(
  val id: ID,
  val name: Name,
  val points: Points,
  val image: URL,
)

为什么不使用原始类型?

当我们定义模型类时,我们经常只是使用原始类型(如String或Int)来复制JSON的结构。这样的实现虽然有效,但缺少很多重要信息。

data class Coupon(
  val id: String,
  val name: String,
  val points: Int,
  val image: String,
)

实体(Entities)和值(Values)可以带有附加约束

我们知道我们的积分(Points)并不像一个简单的原始类型Int那么简单。业务要求积分必须是非负的。这个要求应该在模型中得到体现。

当我们单独定义积分(Points)值(Value)时,我们可以为其添加额外的约束。在init块中,你可以看到一个require语句,用于验证积分(Points)是否为非负整数。

不同的概念不应使用相同的类型来表示

接下来我们有三个不同的属性,它们都作为String发送。但它们都只是文本吗?我们可以替换名称(name)和网址(url)的值,并期望我们的应用正常工作吗?显然不行。

我们可以考虑为表示不同领域概念的数据使用专用的值(Values)。这样,我们可以创建代表ID和名称的Values,并使用内置的URL类型来表示图片(image)。

如果想了解更多关于Values的内容,可以参考Arrow库的创始人的一篇很好的文章。

添加存储库(Repository)

我提到每个实体都有一个生命周期。为了管理这个生命周期,我们依赖于存储库(Repository)。每个存储库负责管理一个单独的实体。它提供CRUD操作,允许我们创建、读取、更新和删除给定实体的实例。

class CouponsRepository @Inject constructor(
  private val couponsApi: CouponsApi,
  private val scope: CoroutineScope,
) {

  private val coupons: StateFlow<List<Coupon>> = flow {
      emit(couponsApi.fetchAllCoupons())
    }.map { couponsJson ->
      couponsJson.toDomain()
    }.shareIn(scope, SharingStarted.Lazily)
  
  fun getAllCoupons(): Flow<List<Coupon>> {
    return coupons
  }

  fun getCoupon(id: ID): Flow<Coupon?> {
    return coupons.map { coupons ->
      coupons.find {it.id == id }
    }
  }
}

目前这些就是我们所需要的。因为没有其他逻辑要求,CouponsRepository可以直接在View Model中使用。我们使用它们来显示所有优惠券的列表以及单个优惠券的详细信息。

2. 用户账户及已收集积分

每个用户都必须创建一个账户以便收集积分。我们希望在用户积分达到足够兑换特定优惠券时通知用户。

在下面的示例中,我们的用户拥有170积分。超过此积分的优惠券将被置灰。预览界面也是一样。如果用户积分足够,"收集"按钮将启用,否则无法点击,并显示额外的说明。

定义一个新的实体

第一步是在我们的模型中表示一个账户概念。我们已经知道如何使用带有值的实体来正确表示它。

data class Account(
  val email: Email,
  val collectedPoints: Points,
)

正如你所看到的,我们使用了Coupon中存在的Points类。这个值具有特定的含义和额外的约束。通过这样做,我们在两个不同的实体中分享了相同的含义和逻辑。

将业务规则添加到模型中

如上所述,我们知道在我们的业务中,只有当用户拥有足够的积分来兑换特定的优惠券时,才能收集该优惠券。这个规则可以直接在模型中轻松描述。

data class Account(
  val email: Email,
  val collectedPoints: Points,
) {

  fun canExchangePointsFor(coupon: Coupon): Boolean {
    return collectedPoints >= coupon.points
  }
}

为了使这个逻辑起作用,我们还需要使我们的Points类可比较。

@JvmInline
value class Points(val value: Int) : Comparable<Points> {
  init {
    check(value >= 0)
  }

  override fun compareTo(other: Points): Int {
    return value.compareTo(other.value)
  }
}

到了用例的时间

我们在模型中描述了业务问题。现在我们需要能够从应用程序的其他部分访问这个逻辑。为此,我们引入了两个用例。

class GetAllCollectableCouponsUseCase @Inject constructor(
  private val accountRepository: AccountRepository,
  private val couponsRepository: CouponsRepository,
) {

  operator fun invoke(): Flow<List<CollectableCoupon>> {
    return combine(
      accountRepository.getLoggedInAccount().filterNotNull(),
      couponsRepository.getAllCoupons(),
    ) { account, coupons ->
      allCoupons.sortedBy { coupon ->
        coupon.points
      }.map { coupon ->
        CollectableCoupon(coupon, account)
      }
    }
  }
}
class GetCollectableCouponUseCase @Inject constructor(
  private val accountRepository: AccountRepository,
  private val couponsRepository: CouponsRepository,
) {

  operator fun invoke(couponId: ID): Flow<CollectableCoupon> {
    return combine(
      accountRepository.getLoggedInAccount.filterNotNull(),
      couponsRepository.getCoupon(couponId).filterNotNull(),
    ) { account, coupon ->
      CollectableCoupon(coupon, account)
    }
  }
}

它们都依赖于相同的CollectableCoupon类。这个类既不是实体也不是值。我们只是使用它来表示一个聚合和转换后的信息,可以从用例中返回。

data class CollectableCoupon(
  val coupon: Coupon,
  val canCollect: Boolean,
) {
  
  constructor(coupon: Coupon, account: Account) : this(
    coupon = coupon,
    canCollect = account.canExchangePointsFor(coupon),
  )
}

用例只应协调工作

如你所见,用例中没有具体的业务逻辑。它们不决定何时可以收集优惠券。它们只是协调工作,并将所有任务委托给模型。

域驱动设计一书的作者Eric Evans说:

该层保持薄。它不包含业务规则或知识,只协调任务并将工作委托给下一层中的领域对象的协作。

同样,Robert C. Martin在他的《架构整洁之道》中也表达了类似的观点:

这些用例编排数据的流动,使之与实体相互配合,并指导这些实体使用其企业级业务规则来实现用例的目标。

3. 收集优惠券

到目前为止,我们只是读取了一些数据。在那里,域驱动设计方法非常有用,但当我们尝试实现改变应用程序状态的逻辑时,它真正发挥作用。

在下一个迭代中,我们添加了一个选项来激活特定的优惠券。激活过程有几个步骤:

  1. 用户选择要激活的优惠券。
  2. 应用程序检查用户是否已经收集了足够的积分。
  3. 应用程序减少用户积分的数量,减去优惠券的值。
  4. 应用程序生成一个随机的激活码。
  5. 应用程序将激活码分配给优惠券。
  6. 激活码展示给用户。
  7. 应用程序从60秒倒计时到0秒,这是优惠券的有效期。
  8. 当优惠券过期时,应用程序将激活码从中移除。

从用户的角度来看,这个过程相对简单,但在应用程序中可能非常棘手。让我们看看如何使用域驱动设计使其更加清晰。

为账户添加更多业务规则

在模型中,我们已经有了检查是否可以用已收集的积分来兑换所选优惠券的能力。现在我们还需要进行实际兑换。我们必须更新账户并减少已收集积分的数量。

data class Account(
  val email: Email,
  val collectedPoints: Points,
) {

  fun canExchangePointsFor(coupon: Coupon): Boolean {
    return collectedPoints >= coupon.points
  }

  fun exchangePointsFor(coupon: Coupon): Account {
    check(canExchangePointsFor(coupon))
    return copy(collectedPoints = collectedPoints - coupon.points)
  }
}

为使其生效,我们还需要使用正确的减法操作更新我们的Points类。

@JvmInline
value class Points(val value: Int) : Comparable<Points> {
  init {
    check(value >= 0)
  }

  ...

  operator fun minus(other: Points): Points {
    return Points(value - other.value)
  }
}

为什么要复制实体?

exchangePointsFor方法返回账户的副本。面向对象编程的主要思想是将数据和行为组合在对象中。

其他技术,主要是后端技术,通常将实体定义为可变的。我们的collectedPoints字段可以是var而不是val。这样,exchangePointsFor方法只能更改collectedPoints的值,而无需进行任何副本。

data class Account(
  val email: Email,
  // Now it is mutable
  var collectedPoints: Points,
) {

  fun canExchangePointsFor(coupon: Coupon): Boolean {
    return collectedPoints >= coupon.points
  }

  fun exchangePointsFor(coupon: Coupon) {
    check(canExchangePointsFor(coupon))
    // We don't create copy but update the same instance
    collectedPoints = collectedPoints - coupon.points
  }
}

在移动应用程序中,我们更喜欢依赖于不可变数据。这样做可以让我们在处理多个线程、响应式流等方面更加自信和安全。但是使用不可变数据并不意味着我们不能使用域驱动的方法。唯一的区别是,与其在实体内部更改值,我们创建一个带有新值的副本。

激活码

在这个功能的描述中,我们提到了一个激活码。在我们的情况下,它不是一个实体,而只是一个包含两个字段的值。

data class ActivationCode(val value: String, val remainingTime: Duration) {
  init {
    require(value.length == LENGTH)
  }

  val expired: Boolean
    get() = remainingTime.inWholeSeconds <= 0

  suspend fun waitForActivation(): ActivationCode {
    check(!expired)
    delay(1.seconds)
    return copy(remainingTime = remainingTime - 1.seconds)
  }

  companion object {
    const val LENGTH = 8
  }
}

它包含了许多有用的信息和规则。首先,我们知道激活码必须有8个字符。我们还知道当remainingTime达到0时,它将过期。最后,waitForActivation方法实现了一秒钟的滴答声,我们用它来倒计时。

激活码工厂

创建实体或值的实例有时可能更加复杂,使用构造函数可能不是最佳选择。在我们的代码库中,我们可以引入工厂,以一种隔离的方式处理这个任务。

const val ALLOWED_CHARS = "1234567890QWERTYUIOPASDFGHJKLZXCVBNM"
const val REMAINING_SECONDS = 60

class ActivationCodeFactory @Inject constructor() {

  fun createRandomActivationCode(): ActivationCode {
    val code = buildString(ActivationCode.LENGTH) {
      repeat(ActivationCode.LENGTH) {
        append(ALLOWED_CHARS[Random.nextInt(ALLOWED_CHARS.length)])
      }
    }
    return ActivationCode(value = code, remainingTime = REMAINING_SECONDS.seconds)
  }
}

优惠券的业务规则

一旦我们有了激活码,我们就可以使用它来为优惠券实现业务规则。

data class Coupon(
  val id: ID,
  val name: Name,
  val points: Points,
  val image: URL,
  val activationCode: ActivationCode?,
) {

  val canBeActivated: Boolean
    get() = activationCode != null && !activationCode.expired

  fun collect(activationCode: ActivationCode) = copy(
    activationCode = activationCode,
  )

  suspend fun waitForActivation() = copy(
    activationCode = activationCode?.waitForActivation(),
  )

  fun reset() = copy(
    activationCode = null,
  )
}

我们的初始实体现在变得更加有意义。我们可以看到只要优惠券有一个activationCode,并且这个activation码没有expired,优惠券就canBeActivated

我们可以通过向优惠券传递一个激活码来收集优惠券。然后,我们等待激活,这是通过减少激活码内的remainingTime来处理的。最后,我们可以通过从优惠券中删除激活码来重置优惠券。

各位,现在来了最好的部分。我们可以将所有这些业务规则结合在一起,实现完整的逻辑流程。这个流程将由用例进行协调。

class CollectCouponUseCase @Inject constructor(
  private val accountRepository: AccountRepository,
  private val couponsRepository: CouponsRepository,
  private val activationCodeFactory: ActivationCodeFactory,
  private val scope: CoroutineScope,
) {

  suspend operator fun invoke(couponId: ID) {
    // Get necessary data
    var account = checkNotNull(accountRepository.getLoggedInAccount().first())
    var coupon = checkNotNull(couponsRepository.getCoupon(couponId).first())
    // Take points from the account
    account = account.exchangePointsFor(coupon)
    // Collect the coupon using Activation Code
    val activationCode = activationCodeFactory.createRandomActivationCode()
    coupon = coupon.collect(activationCode)
    // Save all the changes
    accountRepository.saveLoggedInAccount(account)
    couponsRepository.updateCoupon(coupon)
    // Launch separate job to not block the UI with the countdown
    scope.launch {
      // Countdown until Coupon expiration
      while (coupon.canBeActivated) {
        coupon = coupon.waitForActivation()
        couponsRepository.updateCoupon(coupon)
      }
      // Reset the Coupon to the initial state
      coupon = coupon.reset()
      couponsRepository.updateCoupon(coupon)
    }
  }
}

瞧!我们复杂的业务流程通过一个简洁而清晰的用例得到了封闭。

让我们总结一下

我们解决了多个不同的业务问题,甚至没有触及应用程序的View Model。如果您查看GitHub存储库,您会发现这些View Model是多么简单。所有这一切都要归功于有意义的域模型,它处理了所有的逻辑工作

如果您想在自己的项目中尝试,请记住以下规则:

  1. 实体描述了模型中的主要领域概念。它们具有唯一的身份和生命周期。
  2. 为了管理实体的生命周期,我们使用存储库,它暴露简单的CRUD操作。
  3. 值用于描述实体的部分。它们没有身份和生命周期,也不需要存储库。
  4. 当实体或值的创建更加复杂时,我们可以将这种复杂性提取到单独的工厂中。
  5. 模型不仅应该包含数据,还应该包含业务约束、规则和行为。
  6. 用例仅协调应用程序中的逻辑过程。所有的决策和数据修改应该委托给模型。
  7. 如果您的应用程序很简单,没有需要协调的过程,那么您可能根本不需要用例。

GitHub

https://github.com/Maruchin1/domain-driven-android
https://arrow-kt.io/learn/design/domain-modeling/

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

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

相关文章

数据结构复习(一)到循环队列

第一章 数据结构绪论 数据结构&#xff1a;是相互之间存在一种或多种特定关系的数据元素的集合。 第2章 算法 算法&#xff1a;解决特定问题求解步骤的描述&#xff0c;在计算机中表现为指令的有限序列&#xff0c;并且每条指令表示为一个或多个操作。 算法具有五个基本特性…

2023.7.17-用*来画一个直角在左(右)下方的等腰直角三角形

功能&#xff1a;如题&#xff0c;直角边长度为5。 代码&#xff1a; #define _CRT_SECURE_NO_WARNINGS 1 #include<stdio.h> int main() {int a, b, i,j;printf("直角边的长度为&#xff1a;");scanf("%d",&a);for (i 1; i < a;i){for (j…

JMeter和Postman:哪一个更适合性能测试?

Postman 和 JMeter 都可以用来做 API 测试&#xff0c;但是它们之间有一些区别。 测试类型 Postman 主要用于功能测试和集成测试&#xff0c;而 JMeter 主要用于性能测试和负载测试&#xff0c;例如压力测试和并发测试。因此&#xff0c;如果你需要测试应用程序的性能和可伸缩…

​python接口自动化(四十一)- 发xml格式参数的post请求(超详解)​

简介 最近在工作中&#xff0c;遇到一种奇葩的接口&#xff0c;它的参数数据是通过xml&#xff0c;进行传递的&#xff0c;不要大惊小怪的&#xff0c;林子大了什么鸟都有&#xff0c;每个人的思路想法不一样&#xff0c;开发的接口也是各式各样的&#xff0c;如果想要统一的话…

2023年NOC决赛-加码未来编程赛项决赛模拟题-Python模拟题--卷3

第一题、是古罗马恺撒大帝用来对军事情报进行加解密的算法&#xff0c;它采用了替换方法对信息中的每一个英文字符循环替换为字母表序列中该字符后面的第三个字符&#xff0c;即&#xff0c;字母表的对应关系如下&#xff1a; 原文&#xff1a;A B C D E F G H I J K L M N O …

(转载)BP 神经网络的数据分类(matlab实现)

1案例背景 1.1 BP神经网络概述 BP神经网络是一种多层前馈神经网络,该网络的主要特点是信号前向传递,误差反向传播。在前向传递中,输入信号从输人层经隐含层逐层处理,直至输出层。每一层的神经元状态只影响下一层神经元状态。如果输出层得不到期望输出,则转入反向传播,根据预测…

微服务负载均衡器RibbonLoadBalancer实战

1.负载均衡介绍 负载均衡&#xff08;Load Balance&#xff09;&#xff0c;其含义就是指将负载&#xff08;工作任务&#xff09;进行平衡、分摊到多个操作单元上进行运行&#xff0c;例如FTP服务器、Web服务器、企业核心应用服务器和其它主要任务服务器等&#xff0c;从而协同…

支付通道及系统设计

支付渠道&#xff0c;也可以叫支付通道&#xff0c;是指能够提供资金流转功能的通道&#xff0c;包括但不限于银行、第三方支付机构。我们常见的借记卡&#xff08;储蓄卡&#xff09;、贷记卡&#xff08;信用卡&#xff09;、微信、支付宝、云闪付等支付方式&#xff0c;都是…

re学习(18)[ACTF新生赛2020]rome1(Z3库+window远程调试)

参考视频: Jamiexu793的个人空间-Jamiexu793个人主页-哔哩哔哩视频 代码分析&#xff1a; 其主要内容在两个while循环中&#xff08;从定义中可知flag位16个字符&#xff09;。 看第二个循环&#xff0c;可知是比较result和经过第一个循环得到的v1比较&#xff08;就是flag…

VXLAN在Linux上的实践

在笔记本上使用VMWare安装两台Ubunutu 22.04虚拟机&#xff0c;这两台虚拟机都桥接在一个物理网卡上&#xff0c;IP地址分别为192.168.31.113和192.168.31.131。 HOST1上的VXLAN配置如下&#xff1a; # ip link add vxlan0 type vxlan id 42 dstport 4789 remote 192.168.31.1…

什么是核心层?汇聚层?接入层

什么是核心层?汇聚层?接入层-百度经验 核心层&#xff1a; 核心层的功能主要是实现骨干网络之间的优化传输&#xff0c;骨干层设计任务的重点通常是冗余能力、可靠性和高速的传输。核心层一直被认为是所有流量的最终承受者和汇聚者&#xff0c;所以对核心层的设计以及网络设备…

STM32(HAL库)驱动GY30光照传感器通过串口进行打印

目录 1、简介 2、CubeMX初始化配置 2.1 基础配置 2.1.1 SYS配置 2.1.2 RCC配置 2.2 软件IIC引脚配置 2.3 串口外设配置 2.4 项目生成 3、KEIL端程序整合 3.1 串口重映射 3.2 GY30驱动添加 3.3 主函数代 3.4 效果展示 1、简介 本文通过STM32F103C8T6单片机通过HAL库方…

gma 2.0.0a3 (2023.07.17) 更新日志

安装 gma 2.0.0a3 pip install gma2.0.0a3新增 1、为矢量要素&#xff08;Feature&#xff09;添加 【Difference】&#xff08;差集&#xff09;方法   取第一个矢量要素与第二个矢量要素的几何差集。  2、为矢量要素&#xff08;Feature&#xff09;添加几种几何形状测试…

springCloud通过两种方式配置热更新

该热更新实际就是通过改动nacos官网里面的配置管理的妹纸内容实现 定义一个config包&#xff0c;在该包下面复制该代码 package cn.itcast.user.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.spring…

如何实现以图搜图

一、前言 在许多搜索引擎中&#xff0c;都内置了以图搜图的功能。以图搜图功能&#xff0c;可以极大简化搜索工作。今天要做的就是实现一个以图搜图引擎。 我们先来讨论一下以图搜图的难点&#xff0c;首当其冲的就是如何对比图片的相似度&#xff1f;怎么样的图片才叫相似&a…

每天一道C语言编程:排队买票

题目描述 有M个小孩到公园玩&#xff0c;门票是1元。其中N个小孩带的钱为1元&#xff0c;K个小孩带的钱为2元。售票员没有零钱&#xff0c;问这些小孩共有多少种排队方法&#xff0c;使得售票员总能找得开零钱。注意&#xff1a;两个拿一元零钱的小孩&#xff0c;他们的位置互…

Windows 10快速启动怎么关闭?

有的用户电脑在开启快速启动后&#xff0c;发现电脑的开机速度确实变快了&#xff0c;但有的用户开启快速启动后开机速度反而变慢了&#xff0c;所以想关闭快速启动。那电脑快速启动怎么关闭&#xff1f; 快速启动与休眠 快速启动与电脑的休眠功能相似&#xff0c;但又有所不同…

获取网络包的硬件时间戳

转自&#xff1a;如何获取网络包的硬件时间戳_飞行的精灵的博客-CSDN博客 在一些应用中我们需要获取网路报文进出MAC的精准的时间戳。相比较于软件时间戳&#xff0c;硬件时间戳排除了系统软件引起的延时和抖动。如下图所示意&#xff1a; 下面我们使用北京飞灵科技有限公司开…

在命令行执行命令后出现 Permission denied 的问题解决

解决在项目目录安装一个有 “bin” 配置的依赖包后&#xff0c;执行 “bin” 命令&#xff0c;出现了 Permission denied 的问题。 问题 比如有这样一个包 json2playwright &#xff0c;它的 package.json 中 “bin” 是&#xff1a; "bin": {"pince": &q…

如何让Vue项目本地运行的时候,同时支持http://localhost和http://192.168.X.X访问?

方法1&#xff1a;在package.json的"scripts":→ "dev":末尾追加 --host 0.0.0.0 方法2&#xff1a;将config\index.js的"dev":→ "host":修改为0.0.0.0