6.5 用DSL建模业务规则
业务规则是DSL的一个应用热点。业务规则属于领域模型中可配置的部分,正是最需要领域专家过目的环节。如果DSL非常容易上手,连领域专家都能(像老鲍那样)写上几行测试,那简直是锦上添花的好事。我们的DSL准备针对交易的税费计算进行建模,图6-4说明了计算的详情。
表6-4 DSL将要建模的业务规则:计算交易的税费
步骤 | 说明 |
---|---|
1 执行交易 | 买卖双方在证券交易所产生交易 |
2 计算税费 | 已发生的交易需要计算相应的税费。计算逻辑由交易类型、交易的证券、进行交易的证券交易所等因素决定 买卖双方按照交易净值进行结算,税费是交易净值的核心组成部分 |
我们的DSL要求能被领域专家老鲍看明白,理解其中的业务规则,然后检查规则的完备性,验明规则的正确性。那么,第一步应该做什么呢?还用问吗!当然是建立税费的领域抽象,不然DSL层就成空中楼阁了。
不过,聪明的读者肯定急着想看下一轮的DSL实现,没耐心再听我介绍一遍领域建模。为了提起你的兴趣,不如我们打个岔,先在手头已有领域模型的基础上,构建一个实现业务规则的DSL。这个小练习除了能提神,还能演示Scala语言一项重要的函数式特性,它应用广泛,并能大大改善一种最常用的面向对象设计模式。
Scala知识点
模式匹配可用于实现函数式的抽象,还可实现一种可扩展的Vistor模式。
高阶函数是Scala语言代表性的函数式编程特性。可用于实现组合子。
抽象val和抽象类型用于设计开放的抽象。开放抽象可适时组合为具体的抽象。
自类型标注(self-type annotation)可以用来建立抽象间的关联。
偏函数(partial function)是可对其定义域的一个子集进行求值的表达式。
6.5.1 模式匹配如同可扩展的Visitor模式
Scala语言的case类除了构造函数的调用语法较为简单,还可以对析构对象进行模式匹配(Scala模式匹配的用法详见6.10节文献[2])。Haskell等函数式语言的代数数据类型(algebraic data types,详见http://en.wikipedia.org/wiki/Algebraic_data_type)一般都要用到模式匹配特性。对case类使用模式匹配,是为了实现一种通用的、可扩展的Visitor模式(参见6.10节文献[3])。
Visitor模式用于我们的DSL设计,可以改善领域规则的表达,使规则看上去更清晰和直观。模式匹配这种函数式实现范式,配合case类的用法,能大大提高DSL的表达能力和扩展能力,并且不会像一般面向对象的Visitor实现那样,容易出现领域规则被深埋在对象层次里面的问题。与传统的面向对象Visitor实现相比,模式匹配结合Scala中的case类能够打造出更多可扩展的解决方案,欲了解这方面的更多详细信息,参加6.10节文献[5]。
我们考虑为这样一条业务规则建立DSL:对于在今天之前开户的所有账户,将其额度提高10%。
领域模型沿用代码清单6-3的Account
抽象及其具体实现ClientAccount
和BrokerAccount
。(客户账户的讨论见3.2.2节的补充内容。中介账户是中介方在证券交易组织开设的账户。)实现上述规则的要点,一是从系统内的所有帐户中找出客户帐户,二是从客户帐户中找出需要修改额度的帐户。具体实现请看下面的raiseCreditLimits
函数。
def raiseCreditLimits(accounts: List[Account]) {
accounts foreach {acc =>
acc match { ➊ 模式匹配
case ClientAccount(_, _, openDate) if (openDate before TODAY) =>
acc.creditLimit = acc.creditLimit * 1.1
case _ =>
}
}
}
业务规则被表达为对case类进行模式匹配的规则,请注意体会这种写法直观、清晰的特点。编译器会在后台将case语句➊展开为偏函数,偏函数的定义范围只限于case子句中指定的取值。我们的领域规则只针对客户帐户,所以就在第一条case子句里面把定义范围限定为ClientAccount
实例——用模式匹配来建模领域规则就是这么轻松。第二条case子句是“不关心”子句,里面的下划线符号(_)代表了我们不关心的其他类型账户。关于模式匹配和偏函数这两项Scala语言特性的详细介绍,请查阅6.10节文献[2]。
这段代码能算DSL吗?算。它对领域规则的表述,达到了领域专家能理解的直观程度。它把实现浓缩在很短的篇幅里,领域专家不需要来回翻查代码就能掌握规则语句的语义。最后,它的表述只落墨在规则中明确提及、有重要意义的属性上,其他无关紧要的部分都用一句“不关心”一带而过。
DSL的表达能力只要满足用户需要即可
DSL不一定非要向自然语言靠拢。我重申:对DSL表达能力的要求是,足够满足用户需要。本例的代码片段由程序员使用,所以只要把规则的意图表达清楚,让程序员能维护、领域用户能看懂,这样就足够了。
上面的DSL片段应该能让你初步了解业务规则建模,接下来我们要继续完成本节开头搁置的任务——建立税费的领域模型。下一节有许多新的Scala建模技巧在等着你,绝不会辜负你的学习热情。所以,快来杯咖啡提提神,马上要开始了。
6.5.2 充实领域模型
问题域的几个基本抽象,Trade
、Account
和Instrument
都已经准备就绪,可以开始考虑税费组件的设计了。计算交易的现金价值这项功能,需要若干税费计算方面的组件与Trade
组件共同完成。
税费计算的职责应该设立一个单独的抽象去承担。税费的计算方法是模型中必不可少的业务规则,它会因为业务所处的国家、交易所而变化。根据前面的学习,想必你已经总结出规律:凡是会变化的业务规则,DSL可以让规则的表述直白、清晰、易于维护,进而减轻你的工作负担。
图6-5是我们的解决方案的总体组件模型,说明了税费抽象与Trade
组件的交互情况。
图6-5 交易领域模型中的税费组件。此类图反映了TaxFeeCalculationComponent
与其他协作抽象之间的静态关系
除了Account
,图6-5中列出的抽象都被建模为Scala trait的形式。因此它们可以灵活地关联在一起,并在运行时与合适的实现组合在一起,构成恰当的具体对象。请看代码清单6-8中TaxFeeCalculator
和TaxFeeCalculationComponent
的实现代码。
代码清单6-8 税费计算组件的Scala实现
package api
sealed abstract class TaxFee(id: String)
case object TRADE_TAX extends TaxFee("Trade Tax") ➊ 各单例对象
case object COMMISSION extends TaxFee("Commission")
case object SURCHARGE extends TaxFee("Surcharge")
case object VAT extends TaxFee("VAT")
trait TaxFeeCalculator { ➋ 计算给定交易的税费
def calculateTaxFee(trade: Trade): Map[TaxFee, BigDecimal]
}
trait TaxFeeCalculationComponent { this: TaxFeeRulesComponent => ➌ 自类型标注
val taxFeeCalculator: TaxFeeCalculator ➍ 抽象val
class TaxFeeCalculatorImpl extends TaxFeeCalculator {
def calculateTaxFee(trade: Trade): Map[TaxFee, BigDecimal] = {
import taxFeeRules._ ➎ 对象导入语法
val taxfees =
forTrade(trade) map {taxfee =>
(taxfee, calculatedAs(trade)(taxfee))
}
Map(taxfees: _*)
}
}
}
深入分析这段代码可以帮助你理解整个组件模型是怎样联系起来的。请看表6-5的详细分析。
表6-5 剖析税费计算组件的Scala DSL实现模型
抽象 | 在DSL实现中的作用 |
---|---|
TaxFee | TaxFee 抽象是值对象(参见6.10节文献[6]),代表一个税费品种。不同的税费品种用不同的Scala单例对象➊来表示
注意,作为值对象,各税费类型对象不可变
|
TaxFeeCalculator | TaxFeeCalculator 抽象负责计算给定交易应承担的全部税费➋ |
TaxFeeCalculationComponent | 它是联系起整组税费相关抽象的中心,其他抽象围绕着它形成交易税费的计算核心
TaxFeeCalculationComponent 通过自类型标注的与TaxFeeRulesComponent 协作➌,通过抽象val与TaxFeeCalculator 协作➍
本设计的优点: 抽象与实现解耦。你可以为TaxFeeCalculationComponent 的两个协作抽象提供任意实现 实现可推迟到创建TaxFeeCalculationComponent 的具体实例时
|
Scala语言的自类型标注
自类型标注可赋予组件内的自指对象
this
额外的类型。这种用法含蓄地声明trait TaxFeeCalculationComponent extends TaxFeeRulesComponent
。只不过我们并不立即创建这条编译时依赖关系,而是用自类型标注的方式承诺,在具体
TaxFeeCalculationComponent
对象实例化时,一定会混入TaxFeeRulesComponent
。代码清单6-10以及后续创建具体对象的代码清单6-11和代码清单6-12履行了上述承诺。注意,在
TaxFeeCalculatorImpl#calculateTaxFee
内部有一处针对taxFeeRules
的导入➎,taxFeeRules
也是TaxFeeRulesComponent
内的抽象val。
TaxFeeRulesComponent
被声明为自类型标注, 即向Scala编译器宣告它是this
的有效类型之一。自类型标注的原理详情,请参阅6.10节文献[2]。
寥寥几行代码就已经串起多个组件。省代码是Scala带来的好处,也是运用高级抽象编程的结果。下一节将完成TaxFeeRulesComponent
实现,并且设计一种DSL来定义税费计算的领域规则。
6.5.3 用DSL表达税费计算的业务规则
我们首先搭建规则组件的领域模型,它是一个trait,对外公开税费计算方面的主要契约。为简单起见,假设了一种简化的情况,现实中的规则要复杂烦琐得多。
package api
trait TaxFeeRules {
def forTrade(trade: Trade): List[TaxFee] ➊ 适用于给定交易的TaxFee
def calculatedAs(trade: Trade): PartialFunction[TaxFee, BigDecimal] ➋ 具体的计算方法
}
第一个方法forTrade
➊返回给定交易应缴纳的税费品种列表。第二个方法calculatedAs
➋针对给定交易计算具体某一项税费。
现在看看TaxFeeRulesComponent
组件,它除了建立税费计算DSL,还提供了TaxFeeRules
的一个具体实现。该组件如代码清单6-9所示。
代码清单6-9 表达税费计算业务规则的DSL
package api
trait TaxFeeRulesComponent {
val taxFeeRules: TaxFeeRules
class TaxFeeRulesImpl extends TaxFeeRules {
override def forTrade(trade: Trade): List[TaxFee] = {
(forHKG orElse
forSGP orElse
forAll)(trade.market) ➊ 用组合子把几个TaxFee列表连接起来
}
val forHKG: PartialFunction[Market, List[TaxFee]] = { ➋ 针对中国香港市场的专门列表
case HKG =>
List(TradeTax, Commission, Surcharge)
}
val forSGP: PartialFunction[Market, List[TaxFee]] = { ➌ 针对新加坡市场的专门列表
case SGP =>
List(TradeTax, Commission, Surcharge, VAT)
}
val forAll: PartialFunction[Market, List[TaxFee]] = { ➍ 针对其他国家/地区的通用列表
case _ => List(TradeTax, Commission)
}
import TaxFeeImplicits._
override def calculatedAs(trade: Trade):
PartialFunction[TaxFee, BigDecimal] = { ➎ 税费计算的领域规则
case TradeTax => 5. percent_of trade.principal
case Commission => 20. percent_of trade.principal
case Surcharge => 7. percent_of trade.principal
case VAT => 7. percent_of trade.principal
}
}
}
TaxFeeRulesComponent
是对TaxFeeRules
抽象的发展,而且提供了TaxFeeRules
的实现,当然你也可以自己提供一个实现来代替它。TaxFeeRulesComponent
仍然是一个抽象组件,因为里面的taxFeeRules
只有抽象的声明。最后组装各部分组件时才提供所有的具体实现,构建出具体的TradingService
。现在先来仔细研究这段实现代码,看看DSL怎么判断应缴税费品种,又怎么算出税费金额。
1. 选出合适的应缴税费品种列表
请看TaxFeeRulesImpl
里面的DSL实现。forTrade
方法只有一行,它是用Scala组合子和函数式风格组织起来的。附录A将介绍,组合子是高阶函数的优秀组织手段。(不读附录A,你可错过了好东西。)
组合子有让DSL语言精炼的效果。它是函数式编程最有吸引力的部分之一。既然Scala给了你函数式编程的强大工具,该用组合子组织语言时就别犹豫。为给定交易找到适当税费集合的业务规则,用自然语言描述就是下面这样:
“为在中国香港市场进行的交易提供专门场的列表,或者为在新加坡市场进行的交易提供专门的列表,或者为在其他市场进行的交易提供通用列表。”
Scala语言的偏函数
偏函数只是为其参数的部分取值而定义的。Scala的偏函数形式上是由一组模式匹配
case
语句构成的代码块。请看下面的例子:
val onlyTrue: PartialFunction[Boolean, Int] = {
case true => 100
}
onlyTrue
是一个PartialFunction
。它只对其定义域(全体Boolean
值)的一部分,即取值为true
的情况做了定义。PartialFunction
trait内含isDefinedAt
方法,可以判断某个领域值是否属于PartialFunction
的定义范围。例子如下所示:
scala> onlyTrue isDefinedAt(true)
res1: Boolean = true
scala> onlyTrue isDefinedAt(false)
res2: Boolean = false
现在对比着以上规则读一下forTrade
➊里面那一句实现。你会发现代码中的规则表达严丝合缝地对应了上面自然的叙述形式,而且浓缩在一个非常紧凑的API界面上。代码中用到了orElse
组合子,它的作用是连接多个Scala偏函数,然后选取第一个对给定参数取值有定义的偏函数。
按照代码清单6-9的定义,forTrade
方法只有在market
不是中国香港,也不是新加坡时,才返回一个通用的TaxFee
对象列表。理解了forTrade
的原理,掌握了Scala偏函数的组合方法,也就知道了forHKG
➋、forSGP
➌、forAll
➍这几个高阶函数的工作原理。
2. 计算税费
现在该说具体的税费计算了,这是DSL要解决的第二部分业务规则。请看代码清单6-9的calculatedAs
➎方法。你能看出它实现了什么规则吗?
calculatedAs
方法把领域规则表达得十分清楚,这又是Scala模式匹配的功劳。它的几条case
子句都经过implicits
的妙手润色。通过implicits
手法给Double
类增加percent_of
方法,然后写成中缀形式,就得到代码清单6-9中的结果。隐式转换使用前需要先将其定义导入当前作用域,也就是下面的TaxFeeImplicits
对象:
package api
object TaxFeeImplicits {
class TaxHelper(factor: Double) {
def percent_of(c: BigDecimal) = factor * c.doubleValue / 100
}
implicit def Double2TaxHelper(d: Double) = new TaxHelper(d)
}
导入TaxFeeImplicits
对象之后,我们就可以像calculatedAs
方法那样,把句子写成符合领域语法的形式,业务专家们看了一定会高兴的。
3. DSL和API有什么区别
6.5节主要介绍了两件事,一是学习在底层实现模型上搭建创建领域实体的DSL脚本,其次学习为业务规则构建DSL。Scala提供了几种手段,可在面向对象的领域模型上实现表意清晰的API。这些手段前面已经介绍过。我在两部分的实现中都多走了一步,运用Scala的隐式转换提供的开放类去改善语言的表达效果。但即使不利用这种贴心的implicits
特性,只要综合运用面向对象和函数式两方面的特性,已经足够为领域模型建立一套表达力充分的API。
既然如此,你肯定会问:到底内部DSL和API有什么区别呢?坦白讲,区别不大。如果一套API具有充分的表达能力,能向用户清楚揭示领域语义,同时又不增加额外的非本质复杂性,那么它就可以算作一种内部DSL。纵观书中被挂上DSL名号的代码片段,我总是根据它们面向领域用户的表现力来决定它们的称呼。DSL实现者需要维护代码,领域专家需要理解语义;要想不多做语法上的包装同时满足这两方面的需要,你选择的实现语言必须拥有建立并组合高阶抽象的能力。我建议你再次重温附录A中树立的抽象设计原则。
前面提到,图6-5列出的组件都是抽象的,我们有意把组件设计成trait。不过你还没见识过trait的真正实力,把抽象的trait组合成可实例化的具体领域实体时,你才知道这种组合威力有多大。下一节会将各种交易组件组合起来,构建一些具体的交易服务,然后以交易服务为根基建立DSL的语言抽象。