6.5 用DSL建模业务规则

业务规则是DSL的一个应用热点。业务规则属于领域模型中可配置的部分,正是最需要领域专家过目的环节。如果DSL非常容易上手,连领域专家都能(像老鲍那样)写上几行测试,那简直是锦上添花的好事。我们的DSL准备针对交易的税费计算进行建模,图6-4说明了计算的详情。

表6-4 DSL将要建模的业务规则:计算交易的税费

步骤 说明
1 执行交易 买卖双方在证券交易所产生交易
2 计算税费 已发生的交易需要计算相应的税费。计算逻辑由交易类型、交易的证券、进行交易的证券交易所等因素决定 买卖双方按照交易净值进行结算,税费是交易净值的核心组成部分

我们的DSL要求能被领域专家老鲍看明白,理解其中的业务规则,然后检查规则的完备性,验明规则的正确性。那么,第一步应该做什么呢?还用问吗!当然是建立税费的领域抽象,不然DSL层就成空中楼阁了。

不过,聪明的读者肯定急着想看下一轮的DSL实现,没耐心再听我介绍一遍领域建模。为了提起你的兴趣,不如我们打个岔,先在手头已有领域模型的基础上,构建一个实现业务规则的DSL。这个小练习除了能提神,还能演示Scala语言一项重要的函数式特性,它应用广泛,并能大大改善一种最常用的面向对象设计模式。

6.5	用DSL建模业务规则 - 图1Scala知识点

  • 模式匹配可用于实现函数式的抽象,还可实现一种可扩展的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抽象及其具体实现ClientAccountBrokerAccount。(客户账户的讨论见3.2.2节的补充内容。中介账户是中介方在证券交易组织开设的账户。)实现上述规则的要点,一是从系统内的所有帐户中找出客户帐户,二是从客户帐户中找出需要修改额度的帐户。具体实现请看下面的raiseCreditLimits函数。

  1. def raiseCreditLimits(accounts: List[Account]) {
  2. accounts foreach {acc =>
  3. acc match { 模式匹配
  4. case ClientAccount(_, _, openDate) if (openDate before TODAY) =>
  5. acc.creditLimit = acc.creditLimit * 1.1
  6. case _ =>
  7. }
  8. }
  9. }

业务规则被表达为对case类进行模式匹配的规则,请注意体会这种写法直观、清晰的特点。编译器会在后台将case语句➊展开为偏函数,偏函数的定义范围只限于case子句中指定的取值。我们的领域规则只针对客户帐户,所以就在第一条case子句里面把定义范围限定为ClientAccount实例——用模式匹配来建模领域规则就是这么轻松。第二条case子句是“不关心”子句,里面的下划线符号(_)代表了我们不关心的其他类型账户。关于模式匹配和偏函数这两项Scala语言特性的详细介绍,请查阅6.10节文献[2]。

这段代码能算DSL吗?算。它对领域规则的表述,达到了领域专家能理解的直观程度。它把实现浓缩在很短的篇幅里,领域专家不需要来回翻查代码就能掌握规则语句的语义。最后,它的表述只落墨在规则中明确提及、有重要意义的属性上,其他无关紧要的部分都用一句“不关心”一带而过。

DSL的表达能力只要满足用户需要即可

DSL不一定非要向自然语言靠拢。我重申:对DSL表达能力的要求是,足够满足用户需要。本例的代码片段由程序员使用,所以只要把规则的意图表达清楚,让程序员能维护、领域用户能看懂,这样就足够了。

上面的DSL片段应该能让你初步了解业务规则建模,接下来我们要继续完成本节开头搁置的任务——建立税费的领域模型。下一节有许多新的Scala建模技巧在等着你,绝不会辜负你的学习热情。所以,快来杯咖啡提提神,马上要开始了。

6.5.2 充实领域模型

问题域的几个基本抽象,TradeAccountInstrument都已经准备就绪,可以开始考虑税费组件的设计了。计算交易的现金价值这项功能,需要若干税费计算方面的组件与Trade组件共同完成。

税费计算的职责应该设立一个单独的抽象去承担。税费的计算方法是模型中必不可少的业务规则,它会因为业务所处的国家、交易所而变化。根据前面的学习,想必你已经总结出规律:凡是会变化的业务规则,DSL可以让规则的表述直白、清晰、易于维护,进而减轻你的工作负担。

图6-5是我们的解决方案的总体组件模型,说明了税费抽象与Trade组件的交互情况。

enter image description here

图6-5 交易领域模型中的税费组件。此类图反映了TaxFeeCalculationComponent与其他协作抽象之间的静态关系

除了Account,图6-5中列出的抽象都被建模为Scala trait的形式。因此它们可以灵活地关联在一起,并在运行时与合适的实现组合在一起,构成恰当的具体对象。请看代码清单6-8中TaxFeeCalculatorTaxFeeCalculationComponent的实现代码。

代码清单6-8 税费计算组件的Scala实现

  1. package api
  2. sealed abstract class TaxFee(id: String)
  3. case object TRADE_TAX extends TaxFee("Trade Tax") 各单例对象
  4. case object COMMISSION extends TaxFee("Commission")
  5. case object SURCHARGE extends TaxFee("Surcharge")
  6. case object VAT extends TaxFee("VAT")
  7. trait TaxFeeCalculator { 计算给定交易的税费
  8. def calculateTaxFee(trade: Trade): Map[TaxFee, BigDecimal]
  9. }
  10. trait TaxFeeCalculationComponent { this: TaxFeeRulesComponent => 自类型标注
  11. val taxFeeCalculator: TaxFeeCalculator 抽象val
  12. class TaxFeeCalculatorImpl extends TaxFeeCalculator {
  13. def calculateTaxFee(trade: Trade): Map[TaxFee, BigDecimal] = {
  14. import taxFeeRules._ 对象导入语法
  15. val taxfees =
  16. forTrade(trade) map {taxfee =>
  17. (taxfee, calculatedAs(trade)(taxfee))
  18. }
  19. Map(taxfees: _*)
  20. }
  21. }
  22. }

深入分析这段代码可以帮助你理解整个组件模型是怎样联系起来的。请看表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,对外公开税费计算方面的主要契约。为简单起见,假设了一种简化的情况,现实中的规则要复杂烦琐得多。

  1. package api
  2. trait TaxFeeRules {
  3. def forTrade(trade: Trade): List[TaxFee] 适用于给定交易的TaxFee
  4. def calculatedAs(trade: Trade): PartialFunction[TaxFee, BigDecimal] 具体的计算方法
  5. }

第一个方法forTrade ➊返回给定交易应缴纳的税费品种列表。第二个方法calculatedAs ➋针对给定交易计算具体某一项税费。

现在看看TaxFeeRulesComponent组件,它除了建立税费计算DSL,还提供了TaxFeeRules的一个具体实现。该组件如代码清单6-9所示。

代码清单6-9 表达税费计算业务规则的DSL

  1. package api
  2. trait TaxFeeRulesComponent {
  3. val taxFeeRules: TaxFeeRules
  4. class TaxFeeRulesImpl extends TaxFeeRules {
  5. override def forTrade(trade: Trade): List[TaxFee] = {
  6. (forHKG orElse
  7. forSGP orElse
  8. forAll)(trade.market) 用组合子把几个TaxFee列表连接起来
  9. }
  10. val forHKG: PartialFunction[Market, List[TaxFee]] = { 针对中国香港市场的专门列表
  11. case HKG =>
  12. List(TradeTax, Commission, Surcharge)
  13. }
  14. val forSGP: PartialFunction[Market, List[TaxFee]] = { 针对新加坡市场的专门列表
  15. case SGP =>
  16. List(TradeTax, Commission, Surcharge, VAT)
  17. }
  18. val forAll: PartialFunction[Market, List[TaxFee]] = { 针对其他国家/地区的通用列表
  19. case _ => List(TradeTax, Commission)
  20. }
  21. import TaxFeeImplicits._
  22. override def calculatedAs(trade: Trade):
  23. PartialFunction[TaxFee, BigDecimal] = { 税费计算的领域规则
  24. case TradeTax => 5. percent_of trade.principal
  25. case Commission => 20. percent_of trade.principal
  26. case Surcharge => 7. percent_of trade.principal
  27. case VAT => 7. percent_of trade.principal
  28. }
  29. }
  30. }

TaxFeeRulesComponent是对TaxFeeRules抽象的发展,而且提供了TaxFeeRules的实现,当然你也可以自己提供一个实现来代替它。TaxFeeRulesComponent仍然是一个抽象组件,因为里面的taxFeeRules只有抽象的声明。最后组装各部分组件时才提供所有的具体实现,构建出具体的TradingService。现在先来仔细研究这段实现代码,看看DSL怎么判断应缴税费品种,又怎么算出税费金额。

1. 选出合适的应缴税费品种列表

请看TaxFeeRulesImpl里面的DSL实现。forTrade方法只有一行,它是用Scala组合子和函数式风格组织起来的。附录A将介绍,组合子是高阶函数的优秀组织手段。(不读附录A,你可错过了好东西。)

组合子有让DSL语言精炼的效果。它是函数式编程最有吸引力的部分之一。既然Scala给了你函数式编程的强大工具,该用组合子组织语言时就别犹豫。为给定交易找到适当税费集合的业务规则,用自然语言描述就是下面这样:

“为在中国香港市场进行的交易提供专门场的列表,或者为在新加坡市场进行的交易提供专门的列表,或者为在其他市场进行的交易提供通用列表。”

Scala语言的偏函数

偏函数只是为其参数的部分取值而定义的。Scala的偏函数形式上是由一组模式匹配case语句构成的代码块。请看下面的例子:

  1. val onlyTrue: PartialFunction[Boolean, Int] = {
  2. case true => 100
  3. }

onlyTrue是一个PartialFunction。它只对其定义域(全体Boolean值)的一部分,即取值为true的情况做了定义。PartialFunction trait内含isDefinedAt方法,可以判断某个领域值是否属于PartialFunction的定义范围。例子如下所示:

  1. scala> onlyTrue isDefinedAt(true)
  2. res1: Boolean = true
  3. scala> onlyTrue isDefinedAt(false)
  4. 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对象:

  1. package api
  2. object TaxFeeImplicits {
  3. class TaxHelper(factor: Double) {
  4. def percent_of(c: BigDecimal) = factor * c.doubleValue / 100
  5. }
  6. implicit def Double2TaxHelper(d: Double) = new TaxHelper(d)
  7. }

导入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的语言抽象。