【函数响应式领域建模】笔记(一)
本文是笔者学习《函数响应式领域建模》第一版的笔记,加上笔者理解记录了重点概念。有兴趣的读者请购买正版图书
1 函数式领域建模:介绍
- 领域建模及领域驱动设计
- 函数式纯领域模型的好处
- 针对提升响应能力的响应式建模
- 函数式 + 响应式
1.1 什么是领域模型
领域模型时问题领域不同实体间关系的蓝图以及其他一些重要的细节,如:
- 属于领域的对象
- 对象之间互动所展示出来的行为
- 领域的语言
- 模型操作内的上下文
实现一个领域模型最主要的挑战是管理它的复杂度。
复杂度分为:1)固有复杂度,由领域核心业务规则决定,如,从银行申请个人借贷,需要根据个人信息决定合适的额度;2)解决方案所引入,如额外的批量处理,并发等,称为模型的附带复杂度。
高效模型的实现方式一个基本原则是减少附带复杂度的数量。如使用技术手段来降低附带复杂度的数量。
1.2 领域驱动设计介绍
在实现一个领域模型时,对领域的理解是至关重要的。只有掌握不同实体在现实世界中是如何工作的,才能知道如何在解决方案中实现它们。理解领域并将核心特性抽象成模型的形式,就是我们所说的领域驱动设计DDD。
1.2.1 边界上下文
在领域驱动设计的世界里,术语边界上下文描述了在整个模型里的某个小模型。完整的领域模型就是多个边界上下文的集合。边界上下文通常在一个高水平的粒度上描述系统里某个完整的功能域。
不同的边界上下文之间应当尽量减少交互。即每个边界上下文应当充分内聚,降低与其他边界上下文的耦合。
1.2.2 领域模型元素
区分实体和值对象的最佳方法是:实体有一个不变的身份标识,而值对象拥有一个不变的值。值对象是语义上的不可变,而实体是语义上的可变,但需要用不可变的结构来实现它。
实际上可变的引用拥有更好的性能(重用以减少内存分配的次数)
但是可变的数据结构在面对并发操作时会导致薄弱的代码基础,同时增加理解的复杂性。
通常的建议是从不可变数据结构开始,如果需要某些代码获得更好的性能,再使用可变结构。为了保持API的不变需要引用一个包装函数来封装易变部分。
领域模型的核心是不同领域元素之间行为或交互的集合。
举例:一个用户去银行或ATM在两个账户间转账。会导致一个账户中减少一笔款项并在另一个账户中存入这个款项,反映为不同账户里的余额变化。同时必须验证账户是否处于激活状态,源账户资金是否充足等等。DDD将这种行为的集合建模为一个或多个服务。既可以将它打包成一个独立的服务,也可以将其作为大服务集合的一个组成部分,这个集合是一个更通用的模块。
实体
有一个身份标识
在生命周期里传递多种状态
在业务中通常有明确的生命周期
账户、银行。唯一标识是账户号码
值对象
不可变
能在实体间自由共享
地址、账户类型
服务
比实体和值对象更高级别的抽象
涵盖多个实体和值对象
通常对一个业务的用例进行建模
银行服务,包括记入借方、记入贷方、转账、余额检查。。
注意不同元素在不同上下文中有不同的语义。如地址在上面的上下文中是一个值对象,但是在一个地理编码服务的边界上下文中地址就变为一个实体。
1.2.3 领域对象的生命周期
- 创建:可能需要一个特殊的抽象来负责创建银行账户
- 参与行为:对象在内存中如何表示。一个复杂实体可能会包括其他的实体和值对象。如账户实体包含对银行实体的引用。
- 持久化
工厂
使用工厂抽象来创建实体
- 将所有创建的代码保存在一个位置
- 抽象了来自调用者创建实体的过程 工厂归属有两种:1) 工厂成为定义领域对象模块的一个组成部分。Scala中的伴生对象是工厂天然的实现;2)或者将工厂作为领域服务集合的一个组成部分。
trait Account{/**/}case class CheckingAccount(/* parameters */) extends Account
case class SavingsAccount(/* parameters */) extends Account
case class MoneyMarketAccount(/* parameters */) extends Account
object Account {
def apply(/* parameters */) = ...
}
聚合
一个聚合可以由一个或多个实体、值对象以及原始属性组成。边界上下文中的聚合呗看做模型中的执行边界,保持着业务规则的一致性。聚合根的两个目标:
- 确保聚合内部业务规则的一致性
- 防止聚合的实现泄露给其它客户端,聚合支持的所有操作都要通过外观模式执行 下面是一个聚合的具体例子:
trait Account { // Account包含账户聚合的基本特征 def no: String
def bank: Bank // 对另一个对象的引用
def address: Address // 地址是一个值对象
// omitted ...
}
case class CheckingAccount( // 账户的具体实现,这里的字段覆盖了 trait 中的 def
no: String,
bank: Bank,
address: Address
)
更多Scala中的case class、minxin以及trait的细节请参考Scala官网
注意我们这里将账户建模为不可变实体。
仓储
聚合是通过工厂创建出来的(case class可以视作工厂),并在对象生命周期的激活阶段在内存代表基础的实体。
仓储(repository)提供了一个接口以持久化的形态来存放此聚合。通常来说仓储基于RDBMS
trait AccountRepository { def query(accountNo: String) : Option[Account]
def query(criteria: Criteria[Account]): Seq[Account)
def write(accounts: Seq[Account)): Boolean
def delete(account: Account): Boolean
}
领域元素的三个最重要的类型(实体、值对象、服务)以及操作它们的三种模式(factory、aggregate、repository)
1.2.4 通用语言
建立词汇表
对业务进行更大范围的抽象,从而建立更小的词汇表
trait AccountSevice { def debit(a: Account, amount: Amount): Try[Account] = ...
def credit(a: Account, amount: Amount): Try[Account] = ...
def transfer(from: Account, to: Account, amount: Amount) = for { // 使用for处理Try,简明优雅。如果需要处理Failure,请使用模式匹配
d <- debit(from, amount)
c <- credit(to, amount)
} yield (d, c)
}
- 函数体最小化,不包含任何无关的细节。上面的例子仅仅是两个账户间转账的领域逻辑
- 使用了银行领域的术语,容易让人理解
- 只描述了正常的执行路径,封装了所有异常路径。这里适用的for表达式是单子化(monadic) 的,更多细节请参考[这里](http://docs. scala-lang.org/tutorials/tour/sequence-comprehensions.htmI)。它可以在执行序列中照顾好所有异常。 在一个上下文内部,词汇表必须清晰明确
要形成一个一致的通用语言,必须让API具备足够的表达力,使得一个领域专家可以只看API就了解上下文。
1.3 函数化思想
领域建模有很多种途径,面向对象也是其中一种
拥有可变状态的聚合的最大问题就是易变性:很难在并行下抽象,也很难推理代码
可变状态带来的问题远远多于它解决的问题
type Amount = BigDecimalcase class Balance(amount: Amount = 0)
class Account(val no: String, val name: String, val dateOfOpening: Date, val balance: Balance = Balance()) { // 这里使用了不可变的Balance
def debit(a: Amount) = ...
def credit(a: Amount) = ...
}
上面的代码没有了可变状态,每次更改余额创建新的Account
但Account依然是一个同时拥有状态和行为的抽象。后面我们再来解耦
1.3.1 哈,纯粹的乐趣
设计函数式领域模型的通用原则:
- 将不可变状态建模为代数数据(ADT)
- 在模块中将行为建模为函数,这里的模块是指一个粗糙的业务功能单元(比如一个领域服务)。这样就将状态从行为中分离了出来。行为比状态更好组合,因此在模块中包含相关的行为有助于提升组合性。
- 模块里的行为对ADT中的类型起作用 面向对象捆绑了状态和行为,函数式编程则对它们进行解耦
- 使用case class对Account建模。保证ADT参数的不变性
- ADT的定义不包含任何行为。将行为放到领域服务中去。服务在模块中丁宁以,在Scala中被实现为trait。trait充当mixin,可以很容易的用小模块构造大模块
- 当需要一个模块实例时,使用该关键字object。即用函数化的思想将状态和行为解耦——状态在ADT里,行为是模块中的独立函数
- debit和credit都是纯函数
- 相对抛异常来说使用Try、Sucess和Failure更加函数化,而且有更好的组合性
def today = Calendar.getinstance.getTimetype Amount = BigDecimal
case class Balance(amount: Amount = 0)
case class Account(no: String, name: String, dateOfOpening: Date, balance: Balance = Balance()) // Account 聚合现在是一个ADT
trait AccountService { // AccountService 现在是一个领域服务
def debit(a: Account, amount: Amount): Try[Account] = ...
def credit(a: Account, amount: Amount): Try[Account) = ...
}
object AccountService exends AccountService // 使用关键字object 实例化服务
现在debit和credit都是纯函数调用了
在主流的面向对象语言中,函数和对象一般封装在同一个抽象里(class)
现在可以组合多个credit和debit如:
val a = Account("a1", "John", today)for {
b <- credit(a, 1000)
c <- debit (b, 200)
d <- debit(c, 190)
} yield d
Scala中的异常:在函数式编程里,异常是不纯粹的。为了能函数化地处理异常,Scala定义了Try抽象。Try可以和其他抽象用纯函数的方式组合。
1.3.2 纯函数组合
组合是将零件或元素结合形成新的事物。 现在在领域中加入查账功能,并将生成的查询记录在某个地方:
val genrateAuditLog: (Account, Amount) => Try[String] = ...val write: String => Unit
debit(source, amount) // 计入借方(贷出)
.flatMap(b => generateAuditLog(b, amount)) // 如果记账通过,生成一个查账记录
.foreach(write) // 写入存储
1.4 管理副作用
外部世界导致的异常就是一种副作用。副作用不仅是负面作用,更多的是指超出函数本身的额外作用
trait AccountService { def openCheckingAccount (customer: Customer, effectiveDate: Date) = {
// does an identity verification and throws exception if not passed
}
}
需要从领域逻辑中抽离副作用:
- 领域逻辑和副作用纠缠在一起,违背了关系的隔离。
- 难以展开单元测试。这种单元测试不得不依赖于模拟(想想java spring中的mock地狱)
- 难以推导领域逻辑。代码混乱
- 妨碍代码模块化。代码无法被其它函数组合 下面看看如何解耦:
trait AccountService { def verifyCu stomer(customer: Customer): Option[Customer] = {
if (Verifications.verifyRecord(customer ) ) Some(customer)
else None
def openCheckingAccount(customer: Customer, effectiveDate: Date) = {
//. . +一一开户逻辑
Account(accountNo, openingDate , customer.name, customer.address, .. )
}
object AccountService extends AccountService
}
1.5 纯模型元素的优点
- 引用透明表达式是纯粹的
- 引用透明表达式是替换模式的前提
- 用替换模式协助方程式推导
1.6 响应式领域模型
响应式模型的标准
积极响应用户的交互
否则没人会使用我们的应用
弹性
积极响应失败。失败是不能陷入未知状态。必须重启部分应用模型,或者给用户一个恰当的反馈
伸缩性
意味着在不同负载的情况下保持良好的响应能力。系统可以承受负载的波动,就算面对高负载,也要控制住延迟水平
消息驱动
为确保弹性和伸缩性,系统必须保持松耦合,通过使用异步消息将阻塞最小化
1.6.1 响应式模型的3+1视图
模型的响应能力可以从三个方面来获得:弹性、伸缩性即并行性
1.6.2 揭穿我的模型不能失败的神话
响应式模型最主要的一个方面就是围绕失败来设计,提升模型的综合弹性
处理来自内部的失败,同样要处理来自外部的失败
把失败从业务逻辑中解耦
所有的失败由一个模块来处理,它会不会编程伸缩性的瓶颈?如何保证失败处理和其他领域逻辑的模块一样可扩展?
在应用中集中地处理失败
总结,在模型内部处异常逻辑会带来:
- 复杂的代码结构
- 领域逻辑与异常处理代码的耦合
- 对于未预料的失败难以扩展 因此需要将失败处理作为一个独立组件:
- 更好的关注点分离
- 将失败委托为独立的组件
- 失败处理的策略可随时添加
- 更好的伸缩性
1.7 事件驱动编程
考虑这样一个功能:作为一个用户,向银行索要所有资产的投资报告,需要抓取典型项目、计算并汇总,最终生成报告:
- General currency holdings
- Equity holdings
- Debt holdings
- Loan information
- Retirement fund valuation
val curr: Balance = getCurrencyBalance(..)val eq: Balance = getEquityBalance(..)
val debt: Balance = getDebtBalance(..)
val loan: Balance = getLoanInformation(..)
val retire: Balance = getRetirementFundBalance(..)
val portfolio = generatePortfolio(curr, eq, debt, loan, retire, portfolio)
上面代码的问题在于任意一个方法都会阻塞主线程
上面代码的架构仅支持本地执行模型
val fcurr: Future[Balance] = getCurrencyBalance(..)val feq: Future[Balance] = getEquityBalance(..)
val fdebt: Future[Balance] = getDebtBalance(..)
val floan: Future[Balance] = getLoanInformation(..)
val fretire: Future[Balance] = getRetireFundBalance(..)
val portfolio: Future[Balance] = for {
c <- fcurr
e <- feq
d <- fdebt
l <- floan
r <- fretire
} yield generatePortfolio(c, e, d, l, r)
在这里,每个独立函数都不承诺将控制权还给主线程前返回Balance,而返回一个Future
这种情况下,计算的总延迟就是所有设计余额计算函数的延迟中最大的哪一个,也就是generatePortfolio计算的延迟。同时将整体计算函数委托给了一个Future,主线程的执行被空了出来,它可以去服务其他请求,而不用在等待这些函数的完成
当计算线程返回一个Future,相当于给调用线程派发了一个事件
1.7.1 事件与命令
对系统全局状态产生作用,对汇总做写操作,当前场景下改变账户的余额
系统内对象在系统中产生结果之前发送消息
作为一个可变消息,常被一个单独的系统处理
如果违反某些约束就会导致失败
发送一个通知给感兴趣的订阅者——在这个案例中,就是账户的所有者
系统产生结果之后发生消息
可以被多个部分处理,对消息的响应也各不相同
不会失败,因为相关的结果已经在系统中产生了
1.7.2 领域事件
在事件驱动编程模型中,事件会触发领域逻辑并且在领域模型内参与各种交互。前面提到了两种通知:命令和事件。他们只有轻微的不同
事件有一个重要的特征:他们是不可变的。它们是系统中已经发生的事情,已经发生的事情无法改变
围绕领域事件建模,这种领域模型被称作自追踪模型,可以在任何时间节点追踪模型
领域事件的定义:
- 唯一定义的一个类型:在模型中,针对每个事件都有一个相应的类型
- 自包含作为一个行为:每个领域事件都包含系统中南刚发生变化的所有相关信息
- 用户可见:模型中的下游组件为了进一步的行为可以消费事件
- 时间相关:可能是最重要的特性,一个事件的单调性被建立在事件流中
1.8 函数式遇上响应式
响应式模型可以帮助代码良好地模块化,这样不同的事件处理者可以独立运行
这要求事件之间没有或者极少共享状态,和函数式编程天然吻合。函数式也要求从纯逻辑中分离副作用
纯的引用透明模块将扮演事件处理器,它们可以并发执行领域逻辑,使得模型保持响应和弹性
1.9 总结
- 在模型内避免共享可变状态
- 引用透明
- 有机增长
- 聚焦在核心领域:DDD代码的每一层都努力保持不变性
- 函数使响应更容易
- 针对失败的设计
- 用基于事件的建模来补充函数式模型
2 Scala与函数式领域模型
为什么选择Scala:Scala同时具备OO和函数的能力,成为实现和组织领域模型的强有力的组合
2.1 为什么是Scala
Scala的主要特性如何映射到更简单的实现函数响应式模型元素
内建代数数据类型(case class)
协助建模领域对象
纯函数
协助建模领域行为,比如在个人银行系统中实现debit、credit等业务逻辑
函数组合与高阶函数
通过将小的行为组合成为大的行为,可以组合debit和credit来实现两个账户之间的转账逻辑
具有类型推断的高级静态类型系统
在类型本身里封装一些约束和业务逻辑,使模型更加健壮。同时代码更加简洁
trait和对象组合
trait有利于模块化。可以通过将多个trait组合成对象来组织模型,实现不同的函数功能。trait通过类型进行参数化,允许插入一些行为以满足特定的业务规则
支持泛型
帮助建立泛型的抽象,后期再具体实例化。比如可为通用用户类型C定义一个领域服务PortfolioService[C],模拟服务的共同工作流
支持并发模型
Scala支持并发的抽象,如actor和future,有助于建立响应式非阻塞性元素模型,而不需要再写线程、锁相关的底层代码
2.2 静态类型和富领域模型
储蓄账户产生利息,支票账户不产生任何利息,下面的函数接受一个账户并根据指定的时间段计算利息:
case class Account(..)def calculateInterest(account: Account, period: DateRange) = {
if (!(account.accountType == SAVING))
Failure(...)
Success(...)
}
现在定义一个特定的账户类型,并修改calculateInterest方法:
trait interestBearingAccount extends Account { def rateOfIntrest: BigDecimal
}
case class SavingsAccount(..) extends InterestBearingAccount
def calculateInterest[A <: InterestBearingAccount] (account: A, period: DateRange) = ...
现在函数中定义了只有某种账户类型才能计算利息的逻辑:
- 领域逻辑现在更加明确,更有表现力
- 函数calculateInterest将合法的账户作为签名的一个部分。因此API的用户不用看具体的实现就知道函数允许的合法账户类型
- 再也不用写测试用例来检验传递给函数的账户类型是否正确
- 函数唯一能接受的就是账户信息。而它们是InterestBearingAccount的子类型,通过输入这些信息,可以确保编译器在。,,处理时拥有更多的信息,以便基于此类信息进行优化(编译器将丢弃其他类型的账户调用请求,这也意味着其搜索空间能更好的结构化)。
2.3 领域行为的纯函数
List(s1, s2, s3).map(calculateInterest(_, dateRange)) // 计算各账户的余额List(s1, s2, s3).map(calculateInterest(_, dateRange)).foldLeft(BigDecimal(0)((a, e) => e.map(_ + a).getOrElse(a))) // 计算累计总利息,注意这里的e是Try[BigDecimal类型]
List(s1, s2, s3).map(calculateInterest(_, dateRange)).filter(_.isSuccess)
def getCurrencyBalance(a: Account): Try[Amount] = ...
def getAccountFrom(no: String): Try[Account] = ...
def calculateNetAssetValue(a: Account, balance: Amount): Try[Amount] = ...
// for 表达式使用 flatMap 和 map组合器将操作序列化。实际上就是一系列map和flatMap、filter的组合
val result: Try[(Account, Amount)] = for {
s <- getAccountFrom("a1")
b <- getCurrencyBalance(s)
v <- calculateNetAssetValue(s, b)
if (v > 1000000)
} yield (s, v) // 如果净资产大于100,000 就记录账号与净资产值
使用函数式时可以将值从一个函数传递给另一个函数,不需要任何中间状态。这也是编译器能够更好的应用某些优化技术(如融合)的原因。比如上面的map组合器从List直接获取输入,不需要任何中间计算,而如果使用命令式语言的for循环就会需要中间过程(个人感觉:用Java的fori循环时会多出index索引的开销;但是使用for-each它们中间计算的步骤貌似是一样的,因为Scala的map还是在每次循环中将head::tail赋值给临时中间变量var,只不过这个过程发生在了map实现内)
// 语句(命令式)。每个语句都是一个任务指派;不返回任何值,只有副作用;难以组合if <condition> {
<statement1>
} else {
<statement2>
}
// 表达式(函数式)。如果每个表达式引用透明,那么整体引用透明;如果表达式返回值,可以传递给另一个函数;容易被组合;容易被推导
val result = if <condition> {
<expression1>
} else {
<expression2>
}
2.3.1 回顾抽象的纯粹性
如果都是纯函数,可以很容易的进行各种组合:
def calculatelnterest: SavingsAccount => BigDecimal = { a => a . balance.amount * a.rateOflnterest
}
def deductTax: BigDecimal => BigDecimal = { interest =>
if (interest < 1000) interest else (interest - 0.1 * interest)
}
// 组合
val accounts = List(a1, a2, a3)
accounts.map(calculatelnterest) .map(deductTax) // 1
// f andThen g 等同于 g(f(x)); f compose g 等同于 f(g(x))
accounts.map(calculatelnterest andThen deductTax) // 2
对于1,使用calculateInterest和deductTax做了两次map;第一个map生成了余额的list作为第二个map的输入;第二个map最终圣城扣税后的净利息列表。因此在输入的集合和最终输入集合间有一个中间状态的集合,也就是利息的列表
在2中做了优化,干掉了中间状态的集合
所以在代码中的list.map(f).map(g) 形式可以使用 list.map(g compose f) 来做替换。但是如果函数f有副作用,g就会挂掉(因为会发生异常)。不要讲纯逻辑和副作用捆绑在一个函数里,解耦它们。这样至少可以再其纯逻辑部分享受组合的好处
2.3.2 引用透明的其他好处
- 易测试
- 并行执行。在Scala中,如果对集合进行了映射,如collection.map,只需要改为collection.par.map 就能转换为并行执行,且没有任何不良反应
2.4 代数数据类型和不变性
2.4.1 基础:和类型与乘积类型
对美元、澳元、欧元和印度卢比建模:
sealed trait Currencycase object USD extends Currency
case object AUD extends Currency
case object EUR extends Currency
case object INR extends Currency
type Currency = USD + AUD + EUR + INR,这里Currency能拥有的独立值的数量是4,也就是Currency的居民数。我们称Currency是和类型
将每个独立类型的居民数相加得到最终的居民数,注意这里每个独立类型的居民数是乘积类型
2.4.2 模型中的ADT数据结构
和类型使我们可以将变化建模在一个特定数据类型内部,乘积类型帮助群组相关的数据成为一个更大的抽象
使用ADT的另一个好处是编译器会自动校验包含数据类型的各种标签
ADT会迫使我们根据所定义的规则,严格地建立抽象
2.4.3 ADT与模式匹配
ADT可以协助构建模型的函数型,这里要用到模式匹配。能提升可读性,还能使代码更加健壮
sealed trait Instrument // Instrument 表示和类型与乘积类型的组合case class Equity(isin: String, name: String, dateOfIssue: Date) extends Instrument
case class FixedIncome(isin: String, name: String, dateOfIssue: Date, issueCurrency: Currency, nominal: BigDecimal) extends Instrument
sealed trait Currency extends Instrument
case object USD extends Currency
case object JPY extends Currency
// 定义 Amount 辅助类
case class Amount(a: BigDecimal, c: Currency) {
def +(that: Amount) = {
require(that.c == c)
Amount(a + that.a, c)
}
}
// Balance类型存储不同账户的余额
case class Balance(amount: BigDecimal, ins: Instrument, asOf: Date)
def getMarketValue(e: Equity, a: BigDecimal): Amount = // ...
def getAccruedInterest(i: String): Amount = // ...
// 根据Balance的类型计算账户的净持有值
def getHolding(account: Account): Amount = account.balance match {
case Balance(a, c: Currency, _) => Amount(a, c)
case Balance(a, e: Equity, _) => getMarketValue(e, a)
case Balance(a, FixedIncome(i, _, _, c, n), _) => Amount(n * a, c) + getAccruedInterest(i)
}
在每个和类型内部还有乘积类型,可以通过模式匹配来解构
如果使用OO或子类型编码这类实现,就会将我们引向观察者模式。Java中的观察者模式会导致代码结构变得很负责,非常难以扩展。这也可能是为什么这个模式会有这么多的变种。ADT解决问题的方式要优雅的多
模式匹配有另一个巨大的好处是编译器会检查模式匹配的穷尽性,如果漏掉了任何Instrument,编译器会发出警告。这对于新增Instrument来说十分有用
2.4.4 ADT鼓励不变性
代数类型的基本特性就是不变性
而不可变数据是函数式编程中事件的最重要的原则之一
在领域模型中要尽可能的保证领域行为的纯洁性,尽量运用不可变数据
2.5 局部用函数,全局用OO
一个非传统的领域模型天生拥有很多对象,包括实体、值对象、服务以及相关的行为。如果把所有这些产物都归拢到同一个命名空间,会把模型变成一个巨无霸。没有清晰地职责划分,抽象也不会被拆分到合适的命名空间
模块是这些问题的答案。不同的功能需要划分到不同的模块
在个人银行业务系统的上下文环境中,用户的投资报告组合可以成为一个模块,税费计算可以成为一个模块,审核也可以成为一个模块
整个模型必须作为一个整体来工作,会发生跨模块的交互。比如当一次交易发生在在线用户交互模块时,该信息必须流入审核模块
模块必须松耦合高内聚。即:
- 一个模块执行一个明确的任务,内部必须紧密聚合。不需要有部分功能由外部模块来执行;
- 两个模块之间的耦合应该尽可能小,不应该存在强依赖。因为一个发生改变就会影响到另外一个,这也违背了模块设计原则
2.5.1 Scala中的模块
作为模块化设计的实现技术,Scala提供了trait和对象。小的trait也可以自由组合成大的trait
trait PortfolioGeneration extends BalanceComputation with InterestCalculation with TaxCalculation with Logging {
// .. implementations
}
// 具体化对象
object PortfolioGeneration extends PortfolioGeneration
所有的trait是彼此正交的——比如说Logging可以在很多其他上下文中重用,BalanceComputation可以被重用为客户账单的组成部分
上面的 trait PortfolioGeneration 是必须的
模块需要能够参数化以便根据业务规则包含不同的变体。比如计算一个账户在一个指定时间段的累计利息,需要计算:
- 利息
- 需要从利息中扣除的税费
- 上面的第二项,需要详细的税表,税表含具体交易类型所对应的缴税项目和税率。 要考虑依赖于业务规则的多样性部分:一个计算客户净利息的模块需要设计另一个计算扣减税费的模块。与此同时,计算税费的模块需要根据税表被参数化,其依赖的是执行的交易类型(利息计算 InterestComputation)
下面是具体实现
sealed trait TaxTypecase object Tax extends TaxType
case object Fee extends TaxType
case object Commission extends TaxType
sealed trait TransactionType
case object InterestComputation extends TransactionType
case object Dividend extends TransactionType
type Amount = BigDecimal
case class Balance(amount: Amount = 0)
trait TaxCalculationTable { // 基于交易类型参数化的税费计算表
type T <: TransactionType
val transactionType: T
def getTaxRates: Map[TaxType, Amount] = { //...
}
}
trait TaxCalculation { // 基于税费计算表参数化的税费计算逻辑
type S <: TaxCalculationTable
val table: S
def calculate(taxOn: Amount): Amount =
table.getTaxRates.map {case (t, r) => doCompute(taxOn, r)}.sum
protected def doCompute(taxOn: Amount, rate: Amount): Amount = {taxOn * rate}
}
trait SingaporeTaxCalculation extends TaxCalculation { // 针对一个特定的地区,需要做额外税费征收逻辑
def calculateGST(tax: Amount, gstRate: Amount) = tax * gstRate
}
trait InterestCalculation { // 基于税费计算逻辑参数化的利息计算逻辑(扣税后的利息)
type C <: TaxCalculation
val taxCalculation: C
def interest(b: Balance): Option[Amount] = Some(b.amount * 0.05)
def calculate(balance: Balance): Option[Amount] = interest(balance).map { i => i - taxCalculation.calculate(i)}
}
// 下面是一个计算利息的具体模块
object InterestTaxCalculationTable extends TaxCalculationTable {
type T = TransactionType
val transactionType = InterestComputation
}
object TaxCalculation extends TaxCalculation {
type S = TaxCalculationTable
val table = InterestTaxCalculationTable
}
object InterestCalculation extends InterestCalculation {
type C = TaxCalculation
val taxCalculation = TaxCalculation
}
总结三个主要特性:
- 静态类型系统:富类型系统;类型推导;高阶类型支持(kinds);-- 这不都是Scala OO这部分的好处吗 。。。
- 函数化编程:代数数据类型;模式匹配;纯函数;高阶函数;组合性;高级函数设计模式; -- 最后这个不知道是指什么鬼哈
- 强有力的模块系统:头等模块支持;可组合的模块;参数化模块 -- 用trait、object来表示模块
2.6 用Scala使模型具备响应性
响应性两个需要满足的准则:
- 管理失败,即针对失败的设计;
- 将长时间运行的处理委托给后台线程,避免阻塞主线程,从而减少延迟
将异常和延迟作为 作用(effect) 来管理,它可以与领域模型的其他纯抽象组合在一起,不需要使用副作用来对他们进行建模
管理异常是响应式模型中的一个关键组件——确保一个失败组件不会拖垮整个应用。同时需要管理延迟,不能再阻塞调用导致不受控制的延迟
静态类型对于其他类型来说,作用的表现形式更有威力。比如有个类型A,想要增加集合的能力,就可以建立一个A的集合作为独立的类型,通过构建List[A]就在A上增加了集合能力。
类似的可用Option[A]来为类型A增加可选性的能力。还有Try和Future这种武器。第四章里边我们将讨论更高级的applicative和monad
2.6.1 管理作用
Try抽象提供一个和类型,然后提供一个函数化接口给用户。通常情况下Try就是一个monad
延迟是另外一个例子,也可以把它当做作用对待——不需要将模型暴露于无限延迟的异常行为,可以使用Future这样的结构来管理延迟
monad理论来自于类别理论(参考阮老师的教程)
我们会关注monad作为抽象计算来帮助我们模拟典型非纯粹的作用,比如异常、IO、延续等等
2.6.2 管理失败
将代码和错误检查混杂在一起不是好的解决方案,核心代码会被遮盖在大堆的错误检查代码中
Scala提供了两个策略:
- 明确一部分代码可以产生异常,用类型系统来协助处理
- 使用抽象,不讲异常管理细节泄漏到领域逻辑的内部,核心逻辑保持函数的组合性
下面是一个的例子,每个函数在特定情况下都会发生失败
def calculateInterest[A <: SavingsAccount](account: A, balance: BigDecimal): Try[BigDecimal] = { if (!validate()) Failure(new Exception("Interest Rate not found")) //
else Success(BigDecimal(10000))
}
def getCurrencyBalance[A <: SavingsAccount](account: A) :Try[BigDecimal] = {
Success(BigDecimal(1000L))
}
def calculateNetAssetValue[A <: SavingsAccount](account: A, ccyBalance: BigDecimal, interest: BigDecimal): Try[BigDecimal] = {
Success(ccyBalance + interest + 200)
}
for {
b <- getCurrencyBalance(s1)
i <- calculateInterest(s1, b)
v <- calculateNetAssetValue(s1, b, i)
} yield (s1, v)
2.6.3 管理延迟
Scala 的 Future 同样是一个 monad,也有flapMap方式来协助我们将领域逻辑绑定到计算的欢乐路径
下面展示了future的连续组合
val result = for { b <- getCurrencyBalance(s4)
i <- calculateinterest(s4, b)
v <- calculateNetAssetValue(s4, b, i)
} yield (v)
result onComplate {
case Success(v) => // ...
}
2.7 总结
- Scala的类型系统:有效地减少样板文件与非必要的测试代码(如case class、with等)
- 函数式的头等预言支持:包括高阶函数、标准库的组合器、函数是头等对象、引用透明、可组合等
- 代数数据类型和模式匹配:case class和模式匹配的完美结合,用简洁的方式表示领域逻辑
- 头等模块:Scala中的trait和object帮助我们定义了可组合的模块,用小型组件演进出更大型的组件。理想的领域建模方式是局部用函数,全局用对象
以上是 【函数响应式领域建模】笔记(一) 的全部内容, 来源链接: utcz.com/z/515597.html