领域驱动的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. 收集优惠券
到目前为止,我们只是读取了一些数据。在那里,域驱动设计方法非常有用,但当我们尝试实现改变应用程序状态的逻辑时,它真正发挥作用。
在下一个迭代中,我们添加了一个选项来激活特定的优惠券。激活过程有几个步骤:
- 用户选择要激活的优惠券。
- 应用程序检查用户是否已经收集了足够的积分。
- 应用程序减少用户积分的数量,减去优惠券的值。
- 应用程序生成一个随机的激活码。
- 应用程序将激活码分配给优惠券。
- 激活码展示给用户。
- 应用程序从60秒倒计时到0秒,这是优惠券的有效期。
- 当优惠券过期时,应用程序将激活码从中移除。
从用户的角度来看,这个过程相对简单,但在应用程序中可能非常棘手。让我们看看如何使用域驱动设计使其更加清晰。
为账户添加更多业务规则
在模型中,我们已经有了检查是否可以用已收集的积分来兑换所选优惠券的能力。现在我们还需要进行实际兑换。我们必须更新账户并减少已收集积分的数量。
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是多么简单。所有这一切都要归功于有意义的域模型,它处理了所有的逻辑工作
如果您想在自己的项目中尝试,请记住以下规则:
- 实体描述了模型中的主要领域概念。它们具有唯一的身份和生命周期。
- 为了管理实体的生命周期,我们使用存储库,它暴露简单的CRUD操作。
- 值用于描述实体的部分。它们没有身份和生命周期,也不需要存储库。
- 当实体或值的创建更加复杂时,我们可以将这种复杂性提取到单独的工厂中。
- 模型不仅应该包含数据,还应该包含业务约束、规则和行为。
- 用例仅协调应用程序中的逻辑过程。所有的决策和数据修改应该委托给模型。
- 如果您的应用程序很简单,没有需要协调的过程,那么您可能根本不需要用例。
GitHub
https://github.com/Maruchin1/domain-driven-android
https://arrow-kt.io/learn/design/domain-modeling/