6.7 组合多种DSL

能表明不同意图的各种抽象聚在一起,构成应用的领域模型。DSL层作为领域模型之上的一层门面,只有它的抽象级别正确时,才表现出功用和可扩展性。本节把DSL整体作为一个抽象单元,讨论不同DSL之间的组合方法。以后遇到需要按不同方式将多个Scala DSL组合在一起的情况,将会用到这方面的技巧。作为讨论用的案例,我们从交易系统领域内挑选了市场规则DSL和交易处理的核心业务规则作为集成对象。

设计好DSL的抽象之后可以通过Scala的子类型化手段来扩展它。子类型化会产生一个层级关系的关联结构,针对相同的核心语言,有各种特化抽象分别提供不同的实现。这不就是所谓的多态吗?没错,6.7.1节将利用DSL的多态关系来组合它们。6.7.2节则讨论怎样组合那些没有亲缘关系的DSL。毕竟不同的DSL一般有着各自独立的发展轨迹,DSL的发展往往也不受应用生命周期的约束。应用架构必须提供良好的环境,让各式各样的DSL结构能无缝地组合起来。

6.7.1 扩展关系的组合方式

交易输入系统之后,将经过一系列常规的交易处理流程,其中第一个步骤是交易充实。该步骤向交易记录补充一些上游系统没有直接提供的衍生信息。信息包括交易的现金价值、应缴税费等,不同类型的交易证券还会有其他各式各样的信息。

1. DSL的扩展

下面的脚本骨架看上去还不是DSL应该有的样子,我们会在后续的讨论中为它添加血肉。为交易处理流程定义方法时,还会用到之前实现的一些组件。

  1. package dsl
  2. trait TradeDsl {
  3. type T <: Trade
  4. def enrich: PartialFunction[T, T] 抽象方法
  5. }

我们的专用语言现在的语义还很单薄,它仅仅定义了一个enrich方法,用于为输入系统的交易充实信息➊。

现在分别为FixedIncomeTradeEquityTrade两种交易定义具体的TradeDsl实现,如代码清单6-13和代码清单6-14所示。FixedIncomeTrade对应的DSL要用到我们之前设计的FixedIncomeTradingService抽象。

代码清单6-13 针对FixedIncomeTrade的交易DSL

  1. package dsl
  2. import api._
  3. trait FixedIncomeTradeDsl extends TradeDsl {
  4. type T = FixedIncomeTrade
  5. import FixedIncomeTradingService._
  6. override def enrich: PartialFunction[T, T] = {
  7. case t =>
  8. t.cashValue = cashValue(t)
  9. t.taxes = taxes(t)
  10. t.accruedInterest = accruedInterest(t) 固定收益交易的应计利息
  11. t
  12. }
  13. }
  14. object FixedIncomeTradeDsl extends FixedIncomeTradeDsl DSL的具体实例

EquityTrade对应的DSL要用到EquityTradingService抽象。

代码清单6-14 针对EquityTrade的交易DSL

  1. package dsl
  2. import api._
  3. trait EquityTradeDsl extends TradeDsl {
  4. type T = EquityTrade
  5. import EquityTradingService._
  6. override def enrich: PartialFunction[T, T] = {
  7. case t =>
  8. t.cashValue = cashValue(t)
  9. t.taxes = taxes(t)
  10. t
  11. }
  12. }
  13. object EquityTradeDsl extends EquityTradeDsl

代码清单6-13的FixedIncomeTradeDsl和代码清单6-14的EquityTradeDsl,为同样的核心语言TradeDsl分别提供具体的语言实现。它们的交易充实语义,分别用6.6节的两个TradingService具体类来实现。从图6-6的类图可以看出这两个语言抽象之间的关系。

enter image description here

图6-6 TradeDSL有一个抽象类型成员T <: Trade,但EquityTradeDSL在相应的位置上指定了具体的类型T = EquityTradeFixedIncomeTradeDSL指定了具体类型T = FixedIncomeTradeEquityTradeDSLFixedIncomeTradeDSLTradeDSL的特化抽象

因为FixedIncomeTradeDSLEquityTradeDSL扩展自同一个父抽象,平常那些围绕继承关系的多态用法都可以套用上去。但如果我们需要定义这么一个TradeDSL子类型,它并不针对特定的交易类型,同时它所建模的业务规则需要结合EquityTradeDSLFixedIncomeTradeDSL的语义,又应该怎么做呢?下一节的例子演示了Scala的另一种组合手段。

2. 通过可插拔替换的语义来组合

业务规则会随市场条件、监管规则,还有其他各种因素而改变。假设券商组织为了促进高价值的交易,宣布了这样一条新的市场规则:

“对于任何在纽约证券交易所进行的交易,如果基本价值 > 1000 USD,在计算其净现金价值时,应对基本价值进行10%的折扣。”

现在不管交易的类型是EquityTrade还是FixedIncomeTrade,都需要在交易充实算法中实现以上规则。这条规则不应该成为核心现金价值计算的一部分,毕竟让短期促销用的市场规则影响系统的核心逻辑是不合理的。类似的领域规则,我们希望实现成洋葱一样的层叠结构,既可灵活地增减,又不影响核心抽象(请比照装饰器模式来理解)。代码清单6-15对TradeDsl的语义进行扩充,以反映上述针对个别市场的新规则。

代码清单6-15 为TradeDsl扩充新语义——新增业务规则

  1. package dsl
  2. import api._
  3. trait MarketRuleDsl extends TradeDsl {
  4. val semantics: TradeDsl 被嵌入的内层语义
  5. type T = semantics.T
  6. override def enrich: PartialFunction[T, T] = {
  7. case t =>
  8. val tr = semantics.enrich(t) 调用内层语义
  9. tr match { 给内层DSL披挂上附加规则
  10. case x if x.market == NYSE && x.principal > 1000 =>
  11. tr.cashValue = tr.cashValue - tr.principal * 0.1
  12. tr
  13. case x => x
  14. }
  15. }
  16. }

这段代码是讨论DSL组合的一个小高潮。注意semantics抽象val的作用,内层DSL被嵌入到这个位置,等待与新领域规则进行组合➊。内部DSL的另一种叫法正好是“内嵌式DSL”。大多数时候,DSL的语义是和实现硬性绑定在一起的。但这个例子的情况不一样,我们希望待组合DSL的语义是可以插拔替换的。那样的话,就能在被组合的各DSL之间保持松散的耦合度。除此之外,运行时的插拔机制有利于推迟确定具体的实现。代码清单6-16为EquityTradeDslFixedIncomeTradeDsl分别与新规则MarketRuleDsl的组合体定义具体对象。

代码清单6-16 组合DSL

  1. package dsl
  2. object EquityTradeMarketRuleDsl extends MarketRuleDsl {
  3. val semantics = EquityTradeDsl
  4. }
  5. object FixedIncomeTradeMarketRuleDsl extends MarketRuleDsl {
  6. val semantics = FixedIncomeTradeDsl
  7. }

稍后你会看到各种DSL组合成果的汇总。在此之前,我们要用前面学过的函数式组合子知识,再扩充一下TradeDsl的功能。组合子在函数式层面和对象层面都可以表现出它的组合性语义。

3. 通过函数式组合子来组合

本小节开头曾经提到交易的处理流程,还记得吗?在执行交易充实步骤之前,首先要验证交易的有效性。而在充实之后,交易还要被登记到会记系统的账簿之中。现实中的交易处理过程其实不止这几步,但作为演示,我们姑且让例子简单一些。应该怎样用Scala DSL建模这个流程序列呢?

组成流程序列的步骤适合用PartialFunction组合子来建模,而模式匹配可以把规则表达得清晰直白。代码清单6-17把原先的TradeDsl骨架变得丰满了一些,增加了一套控制结构来表示对流程步骤的规定。

代码清单6-17 在DSL中建模交易的处理流程

  1. package dsl
  2. import api._
  3. trait TradeDsl {
  4. type T <: Trade
  5. def withTrade(trade: T)(op: T => Unit): T = { 加入自定义操作
  6. if (validate(trade))
  7. (enrich andThen journalize andThen op)(trade) 基于组合子的中缀运算
  8. trade
  9. }
  10. def validate(trade: T): Boolean = //..
  11. def enrich: PartialFunction[T, T]
  12. def journalize: PartialFunction[T, T] = {
  13. case t => //..
  14. }
  15. }

为什么要把enrich定义成PartialFunction呢?Scala的偏函数具有令人赞叹的组合能力,特别适合用来构建高阶结构。

withTrade被定义成一个控制结构,交给它一笔交易,它会从头到尾执行完交易处理的全部流程。这个控制结构还具有向流程中插入自定义操作的能力,自定义操作通过(op: T => Unit)参数➊传入withTrade。传入的参数应该是一个作用于交易,且没有返回值的函数。日志、发送邮件、审计跟踪等函数就具备这样的特点,有副作用,但不影响操作后的返回值。类似的具有副作用的操作很适合通过这个途径插入到交易处理流程。

withTrade代码中的模式匹配块值得一说,它只用四行代码就将全部领域规则囊括在内。另外andThen组合子➋也出色地表达了各步骤的既定顺序。

4. 使用组合完毕的DSL

DSL组合完毕后的实际表现请看代码清单6-18。这段脚本用我们的“交易创建DSL”建立一笔交易,然后执行充实、验证等各个步骤,完成交易处理的全过程,最后联合市场规则DSL算出交易最后的现金价值。

代码清单6-18 交易处理流程DSL

  1. import FixedIncomeTradeMarketRuleDsl._
  2. withTrade(
  3. 200 discount_bonds IBM
  4. for_client NOMURA
  5. on NYSE
  6. at 72.ccy(USD)) {trade =>
  7. Mailer(user) mail trade
  8. Logger log trade
  9. } cashValue

本节用了装饰器(Decorator)模式(参见6.10节文献[3])作为组合手段。我们将semantics作为待装饰的DSL,在它的外面包裹装点其他必要逻辑。通过装饰器模式,对象可以动态地增减其职责。它的这种能力在我们需要组合有亲缘关系的DSL时,正好能派上用场。

要是待组合的几种语言之间没有亲缘关系,又会出现什么情况呢?日期和时间、货币、几何图形等领域的DSL,常常作为辅助工具穿插于更大型DSL的舞台间隙。下一节学习如何平稳掌控这类语言的成长变化。

6.7.2 层级关系的组合方式

在大型DSL脚本里面嵌入小型DSL是相当常见的做法。就以金融交易系统来说,例如处理货币换算,管理日期时间,投资组合报表中管理客户收支结余等场合,都不难发现DSL的身影。

现在假设我们需要为客户投资组合报表实现一种DSL,用于报告到给定日期为止,客户账户下持有的各类证券和现金结余。注意,“客户投资组合”和“结余”这两个词代表着两个重要的领域概念,值得我们用DSL的方式建模它们的抽象。它们虽然是两个互相独立的抽象,却时常发生一些密切的联系。

1. 避免与实现发生耦合

表6-7能帮我们理清这两个抽象之间的关联,只有掌握它们的关系,才能保证概念上独立的DSL在实现上也能保持独立。

表6-7 按照层级关系组合多个DSL

需要各自独立发展的两个关联抽象
“结余”指的是: - 客户持有的现金和证券数量 - 一个具有确切语义的重要领域概念,可以被建模为DSL - 一个数额,在实现中可以用BigDecimal来表示,但BigDecimal对于领域用户来说不具有任何意义 “客户资产组合”指的是: - 反映客户拥有的各类资产结余的报表 - 一个具有确切语义的重要领域概念,可以被建模为DSL

注意:

你应该时刻注意,不要让对外公开的语言结构暴露了背后的实现。能做到这一点的话,不仅DSL的可读性更好,还便于在不影响客户代码的前提下,完美地更改内部实现。关于如何隐藏内部实现的话题,请参考附录A中的讨论。

对关系建模:

下面这段脚本清楚地说明,在领域用户眼中,结余抽象和资产组合抽象应该在领域API里面呈现什么样的关系:

  1. trait Portfolio {
  2. def currentPortfolio(account: Account): Balance
  3. }

待完成工作:

结余DSL可以有多种实现,资产组合DSL也一样,我们设计的组合方式,要保证两者的关联关系不因为任何一方的实现变化而受到影响。定义一个Portfolio DSL实现时,应该能够灵活地插入任意一种Balance DSL实现。

Balance是盖在实现外面的抽象接口。Scala允许定义“类型同义词”(type synonym)。我们只要规定type Balance = BigDecimal,就可以用Balance这个名字来称呼客户名下的资产净值。但是这种便利会产生别的影响吗?抽象的Portfolio DSL会根据需要被特化为各种具体类型,形成6.7.1节TradeDsl那样的大家族。在这种情况下,直接在Portfolio DSL基类型中嵌入Balance的实现,将导致整棵Portfolio家族树都与内嵌的Balance具体实现耦合在一起。就算以后真的有需要,也绝无可能更改内嵌的实现。所以,一定不要将一方直接内嵌到另一方,而应该从层次化的角度去设计组合形态。最终让两个DSL既能完美契合,又不至于密不可分,随时能换上你想要的实现。

代码清单6-19的DSL用于对客户资产组合建模,想想看它有什么问题。

代码清单6-19 存在实现耦合的DSL

  1. package dsl
  2. import api._
  3. import api.Util._
  4. trait Portfolio {
  5. type Balance = BigDecimal 内嵌的实现
  6. def currentPortfolio(account: Account): Balance
  7. }
  8. trait ClientPortfolio extends Portfolio {
  9. override def currentPortfolio(account: Account) =
  10. BigDecimal(1200) 现实中此处逻辑会很复杂
  11. }

哈!才要实现第一个特化的Portfolio DSL,被内嵌绑住手脚的Balance抽象➊就支持不住了➋。我们试一试维持它们的层级关系不变,但是将Balance DSL的具体实现留在Portfolio DSL之外。虽然按照层级关系来组合,必然意味着一方要包含在另一方里面,但我们计划中的组合方式有一个地方不同于代码清单6-19,那就是我们嵌入的是DSL的接口,而非实现。答案一想便知,没错,正是抽象val!我们让Portfolio DSL包含Balance DSL的一个实例,不妨称为Balances

2. 对结余的建模

太简单的DSL示例很难让你深刻理解DSL。你有当你看到DSL将底层复杂性用容易理解的语法表述,才能切实感受到DSL的强大表现力。前面我们简单地用BigDecimal来建模“结余”概念。但是对于熟悉证券交易操作的业内人士来说,客户账户下的结余实际上表示,客户在特定日期按照指定货币计算的现金头寸。例子将省略从客户的资产组合算出结余的具体过程。作为DSL契约的Balances如代码清单6-20所示,同时列出的还有它的一个具体实现BalancesImpl

代码清单6-20 建模账户结余的DSL

  1. package dsl
  2. import java.util.Date
  3. import api._
  4. import api.Util._
  5. import api.Currency._
  6. trait Balances {
  7. type Balance 抽象类型
  8. def balance(amount: BigDecimal,
  9. ccy: Currency, asOf: Date): Balance
  10. def inBaseCurrency(b: Balance): (Balance, Currency)
  11. def getBaseCurrency: Currency = USD
  12. def getConversionFactor(c: Currency) = 0.9
  13. }
  14. class BalancesImpl extends Balances { 具体实现
  15. case class BalanceRep(amount: BigDecimal,
  16. ccy: Currency, asOfDate: Date)
  17. type Balance = BalanceRep
  18. override def balance(amount: BigDecimal,
  19. ccy: Currency, asOf: Date)
  20. = BalanceRep(amount, ccy, asOf)
  21. override def inBaseCurrency(b: Balance)
  22. = (BalanceRep(b.amount * getConversionFactor(getBaseCurrency),
  23. b.ccy, b.asOfDate), getBaseCurrency)
  24. }
  25. object Balances extends BalancesImpl

客户可以根据喜好指定报告结余金额时采用的货币类型,而监管机构往往要求一律按照基本货币来计算。基本货币是投资者记账时采用的货币。外汇市场上一般用美元充当基本货币。在代码清单6-20的DSL实现中,inBaseCurrency方法负责按基本货币报告结余。我们在Balances的示例实现BalancesImpl里面,将抽象类型Balance落实为由金额、货币、日期(结余总是针对具体日期进行计算)构成的三元组。

3. 结余DSL与资产组合DSL的组合

Portfolio DSL需要为组合做一些准备,安排一个数据成员的位置给Balances类型的抽象val➊,如代码清单6-21所示。

代码清单6-21 资产组合DSL的接口契约

  1. package dsl
  2. import api._
  3. trait Portfolio {
  4. val bal: Balances 为结余DSL预留的抽象val
  5. import bal._ 对象导入语法
  6. def currentPortfolio(account: Account): Balance
  7. }

为了在类的内部访问bal对象的所有成员,我们运用了Scala的对象导入(object import)语法➋。定义完接口,我们再看看具体实现。代码清单6-22实现了一个计算客户账户结余的Portfolio

代码清单6-22 资产组合DSL的一个具体实现

  1. trait ClientPortfolio extends Portfolio {
  2. val bal = new BalancesImpl 落实到具体的实现
  3. import bal._
  4. override def currentPortfolio(account: Account) =
  5. val amount = //.. 实现细节略
  6. val ccy = //..
  7. val asOfDate = //..
  8. balance(amount, ccy, asOfDate)
  9. }
  10. object ClientPortfolio extends ClientPortfolio

例中的ClientPortfolio DSL已经落实到Balances的一个具体实现。下一步需要保证,当ClientPortfolio与其他同样用到Balances的DSL组合时,双方拥有相同的Balances实现。

我们用另一个例子来说明怎样做到这一点。代码清单6-23给出了一个起装饰器作用的Portfolio实现——Auditing,它可以为其他Portfolio实现增加审计功能。

代码清单6-23 资产组合DSL的另一个实现

  1. trait Auditing extends Portfolio {
  2. val semantics: Portfolio 内嵌的资产组合DSL
  3. val bal: semantics.bal.type Scala单例类型
  4. import bal._
  5. override def currentPortfolio(account: Account) =
  6. inBaseCurrency(
  7. semantics.currentPortfolio(account))._1 按基本货币报告结余
  8. }

Auditing不但能与其他Portfolio DSL进行组合➊,还保证被组合的Portfolio(即semantics)与它嵌入到自身的Balances DSL➋使用同一个实现。(Balances嵌入到Auditing的父类Portfolio。)我们为了施加这样的约束,将Auditing的成员bal声明为semantics.bal,从而将它定义为一个Scala单例类型。接下来,可以指定semanticsbal指定具体的实现,创建一个支持Auditing功能的ClientPortfolio抽象。请看下面的代码片段:

  1. object ClientPortfolioAuditing extends Auditing {
  2. val semantics = ClientPortfolio
  3. val bal: semantics.bal.type = semantics.bal
  4. }

通过层级方式来组合多个DSL,其优点如表6-8所示。

表6-8 通过层级方式来组合DSL的优点

优点 理由
不受抽象内部表达的影响 耦合松散 静态类型安全 对多个DSL进行组合时,语句中不会出现内嵌实现的任何细节 参与组合的DSL之间耦合松散,各自可以独立演进 Scala拥有强大的类型系统,可以由编译器来实施所有的约束

DSL组合这一主题在“Polymorphic embedding of DSLs” (6.10节文献[7])这篇论文中有详细介绍。如果希望详细了解在Scala语言中组合DSL的其他方法,请参阅该论文。

发挥Scala语言面向对象编程和函数式编程的双重能力,灵活组合各种抽象的技巧,本章至此已经展示了很多示例,但我们对组合这个话题的讨论还缺一角,那就是Monad化抽象。Monad化抽象广泛用于构建具备组合性的计算结构。Monad概念源自范畴论(category theory)(别紧张,这本书不会涉及Monad背后的数学理论;感兴趣的话,你可以自行研究)。下一节将展示怎样利用Scala语言的Monad化结构去实现一些语法糖。这种技术可用于设计DSL操作的串联机制。