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应该有的样子,我们会在后续的讨论中为它添加血肉。为交易处理流程定义方法时,还会用到之前实现的一些组件。
package dsl
trait TradeDsl {
type T <: Trade
def enrich: PartialFunction[T, T] ➊ 抽象方法
}
我们的专用语言现在的语义还很单薄,它仅仅定义了一个enrich
方法,用于为输入系统的交易充实信息➊。
现在分别为FixedIncomeTrade
和EquityTrade
两种交易定义具体的TradeDsl
实现,如代码清单6-13和代码清单6-14所示。FixedIncomeTrade
对应的DSL要用到我们之前设计的FixedIncomeTradingService
抽象。
代码清单6-13 针对
FixedIncomeTrade
的交易DSL
package dsl
import api._
trait FixedIncomeTradeDsl extends TradeDsl {
type T = FixedIncomeTrade
import FixedIncomeTradingService._
override def enrich: PartialFunction[T, T] = {
case t =>
t.cashValue = cashValue(t)
t.taxes = taxes(t)
t.accruedInterest = accruedInterest(t) 固定收益交易的应计利息
t
}
}
object FixedIncomeTradeDsl extends FixedIncomeTradeDsl DSL的具体实例
EquityTrade
对应的DSL要用到EquityTradingService
抽象。
代码清单6-14 针对
EquityTrade
的交易DSL
package dsl
import api._
trait EquityTradeDsl extends TradeDsl {
type T = EquityTrade
import EquityTradingService._
override def enrich: PartialFunction[T, T] = {
case t =>
t.cashValue = cashValue(t)
t.taxes = taxes(t)
t
}
}
object EquityTradeDsl extends EquityTradeDsl
代码清单6-13的FixedIncomeTradeDsl
和代码清单6-14的EquityTradeDsl
,为同样的核心语言TradeDsl
分别提供具体的语言实现。它们的交易充实语义,分别用6.6节的两个TradingService
具体类来实现。从图6-6的类图可以看出这两个语言抽象之间的关系。
图6-6 TradeDSL
有一个抽象类型成员T <: Trade
,但EquityTradeDSL
在相应的位置上指定了具体的类型T = EquityTrade
,FixedIncomeTradeDSL
指定了具体类型T = FixedIncomeTrade
。EquityTradeDSL
和FixedIncomeTradeDSL
是TradeDSL
的特化抽象
因为FixedIncomeTradeDSL
和EquityTradeDSL
扩展自同一个父抽象,平常那些围绕继承关系的多态用法都可以套用上去。但如果我们需要定义这么一个TradeDSL
子类型,它并不针对特定的交易类型,同时它所建模的业务规则需要结合EquityTradeDSL
和FixedIncomeTradeDSL
的语义,又应该怎么做呢?下一节的例子演示了Scala的另一种组合手段。
2. 通过可插拔替换的语义来组合
业务规则会随市场条件、监管规则,还有其他各种因素而改变。假设券商组织为了促进高价值的交易,宣布了这样一条新的市场规则:
“对于任何在纽约证券交易所进行的交易,如果基本价值 > 1000 USD,在计算其净现金价值时,应对基本价值进行10%的折扣。”
现在不管交易的类型是EquityTrade
还是FixedIncomeTrade
,都需要在交易充实算法中实现以上规则。这条规则不应该成为核心现金价值计算的一部分,毕竟让短期促销用的市场规则影响系统的核心逻辑是不合理的。类似的领域规则,我们希望实现成洋葱一样的层叠结构,既可灵活地增减,又不影响核心抽象(请比照装饰器模式来理解)。代码清单6-15对TradeDsl
的语义进行扩充,以反映上述针对个别市场的新规则。
代码清单6-15 为
TradeDsl
扩充新语义——新增业务规则
package dsl
import api._
trait MarketRuleDsl extends TradeDsl {
val semantics: TradeDsl ➊ 被嵌入的内层语义
type T = semantics.T
override def enrich: PartialFunction[T, T] = {
case t =>
val tr = semantics.enrich(t) 调用内层语义
tr match { 给内层DSL披挂上附加规则
case x if x.market == NYSE && x.principal > 1000 =>
tr.cashValue = tr.cashValue - tr.principal * 0.1
tr
case x => x
}
}
}
这段代码是讨论DSL组合的一个小高潮。注意semantics
抽象val的作用,内层DSL被嵌入到这个位置,等待与新领域规则进行组合➊。内部DSL的另一种叫法正好是“内嵌式DSL”。大多数时候,DSL的语义是和实现硬性绑定在一起的。但这个例子的情况不一样,我们希望待组合DSL的语义是可以插拔替换的。那样的话,就能在被组合的各DSL之间保持松散的耦合度。除此之外,运行时的插拔机制有利于推迟确定具体的实现。代码清单6-16为EquityTradeDsl
、FixedIncomeTradeDsl
分别与新规则MarketRuleDsl
的组合体定义具体对象。
代码清单6-16 组合DSL
package dsl
object EquityTradeMarketRuleDsl extends MarketRuleDsl {
val semantics = EquityTradeDsl
}
object FixedIncomeTradeMarketRuleDsl extends MarketRuleDsl {
val semantics = FixedIncomeTradeDsl
}
稍后你会看到各种DSL组合成果的汇总。在此之前,我们要用前面学过的函数式组合子知识,再扩充一下TradeDsl
的功能。组合子在函数式层面和对象层面都可以表现出它的组合性语义。
3. 通过函数式组合子来组合
本小节开头曾经提到交易的处理流程,还记得吗?在执行交易充实步骤之前,首先要验证交易的有效性。而在充实之后,交易还要被登记到会记系统的账簿之中。现实中的交易处理过程其实不止这几步,但作为演示,我们姑且让例子简单一些。应该怎样用Scala DSL建模这个流程序列呢?
组成流程序列的步骤适合用PartialFunction
组合子来建模,而模式匹配可以把规则表达得清晰直白。代码清单6-17把原先的TradeDsl
骨架变得丰满了一些,增加了一套控制结构来表示对流程步骤的规定。
代码清单6-17 在DSL中建模交易的处理流程
package dsl
import api._
trait TradeDsl {
type T <: Trade
def withTrade(trade: T)(op: T => Unit): T = { ➊ 加入自定义操作
if (validate(trade))
(enrich andThen journalize andThen op)(trade) ➋ 基于组合子的中缀运算
trade
}
def validate(trade: T): Boolean = //..
def enrich: PartialFunction[T, T]
def journalize: PartialFunction[T, T] = {
case t => //..
}
}
为什么要把enrich
定义成PartialFunction
呢?Scala的偏函数具有令人赞叹的组合能力,特别适合用来构建高阶结构。
withTrade
被定义成一个控制结构,交给它一笔交易,它会从头到尾执行完交易处理的全部流程。这个控制结构还具有向流程中插入自定义操作的能力,自定义操作通过(op: T => Unit)
参数➊传入withTrade
。传入的参数应该是一个作用于交易,且没有返回值的函数。日志、发送邮件、审计跟踪等函数就具备这样的特点,有副作用,但不影响操作后的返回值。类似的具有副作用的操作很适合通过这个途径插入到交易处理流程。
withTrade
代码中的模式匹配块值得一说,它只用四行代码就将全部领域规则囊括在内。另外andThen
组合子➋也出色地表达了各步骤的既定顺序。
4. 使用组合完毕的DSL
DSL组合完毕后的实际表现请看代码清单6-18。这段脚本用我们的“交易创建DSL”建立一笔交易,然后执行充实、验证等各个步骤,完成交易处理的全过程,最后联合市场规则DSL算出交易最后的现金价值。
代码清单6-18 交易处理流程DSL
import FixedIncomeTradeMarketRuleDsl._
withTrade(
200 discount_bonds IBM
for_client NOMURA
on NYSE
at 72.ccy(USD)) {trade =>
Mailer(user) mail trade
Logger log trade
} 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里面呈现什么样的关系:
- trait Portfolio {
- def currentPortfolio(account: Account): Balance
- }
待完成工作:
结余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
package dsl
import api._
import api.Util._
trait Portfolio {
type Balance = BigDecimal ➊ 内嵌的实现
def currentPortfolio(account: Account): Balance
}
trait ClientPortfolio extends Portfolio {
override def currentPortfolio(account: Account) =
BigDecimal(1200) ➋ 现实中此处逻辑会很复杂
}
哈!才要实现第一个特化的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
package dsl
import java.util.Date
import api._
import api.Util._
import api.Currency._
trait Balances {
type Balance 抽象类型
def balance(amount: BigDecimal,
ccy: Currency, asOf: Date): Balance
def inBaseCurrency(b: Balance): (Balance, Currency)
def getBaseCurrency: Currency = USD
def getConversionFactor(c: Currency) = 0.9
}
class BalancesImpl extends Balances { ➊ 具体实现
case class BalanceRep(amount: BigDecimal,
ccy: Currency, asOfDate: Date)
type Balance = BalanceRep
override def balance(amount: BigDecimal,
ccy: Currency, asOf: Date)
= BalanceRep(amount, ccy, asOf)
override def inBaseCurrency(b: Balance)
= (BalanceRep(b.amount * getConversionFactor(getBaseCurrency),
b.ccy, b.asOfDate), getBaseCurrency)
}
object Balances extends BalancesImpl
客户可以根据喜好指定报告结余金额时采用的货币类型,而监管机构往往要求一律按照基本货币来计算。基本货币是投资者记账时采用的货币。外汇市场上一般用美元充当基本货币。在代码清单6-20的DSL实现中,inBaseCurrency
方法负责按基本货币报告结余。我们在Balances
的示例实现BalancesImpl
里面,将抽象类型Balance
落实为由金额、货币、日期(结余总是针对具体日期进行计算)构成的三元组。
3. 结余DSL与资产组合DSL的组合
Portfolio
DSL需要为组合做一些准备,安排一个数据成员的位置给Balances
类型的抽象val➊,如代码清单6-21所示。
代码清单6-21 资产组合DSL的接口契约
package dsl
import api._
trait Portfolio {
val bal: Balances ➊ 为结余DSL预留的抽象val
import bal._ ➋ 对象导入语法
def currentPortfolio(account: Account): Balance
}
为了在类的内部访问bal
对象的所有成员,我们运用了Scala的对象导入(object import)语法➋。定义完接口,我们再看看具体实现。代码清单6-22实现了一个计算客户账户结余的Portfolio
。
代码清单6-22 资产组合DSL的一个具体实现
trait ClientPortfolio extends Portfolio {
val bal = new BalancesImpl 落实到具体的实现
import bal._
override def currentPortfolio(account: Account) =
val amount = //.. 实现细节略
val ccy = //..
val asOfDate = //..
balance(amount, ccy, asOfDate)
}
object ClientPortfolio extends ClientPortfolio
例中的ClientPortfolio
DSL已经落实到Balances
的一个具体实现。下一步需要保证,当ClientPortfolio
与其他同样用到Balances
的DSL组合时,双方拥有相同的Balances
实现。
我们用另一个例子来说明怎样做到这一点。代码清单6-23给出了一个起装饰器作用的Portfolio
实现——Auditing
,它可以为其他Portfolio
实现增加审计功能。
代码清单6-23 资产组合DSL的另一个实现
trait Auditing extends Portfolio {
val semantics: Portfolio ➊ 内嵌的资产组合DSL
val bal: semantics.bal.type ➋ Scala单例类型
import bal._
override def currentPortfolio(account: Account) =
inBaseCurrency(
semantics.currentPortfolio(account))._1 按基本货币报告结余
}
Auditing
不但能与其他Portfolio
DSL进行组合➊,还保证被组合的Portfolio
(即semantics
)与它嵌入到自身的Balances
DSL➋使用同一个实现。(Balances
嵌入到Auditing
的父类Portfolio
。)我们为了施加这样的约束,将Auditing
的成员bal
声明为semantics.bal
,从而将它定义为一个Scala单例类型。接下来,可以指定semantics
和bal
指定具体的实现,创建一个支持Auditing
功能的ClientPortfolio
抽象。请看下面的代码片段:
object ClientPortfolioAuditing extends Auditing {
val semantics = ClientPortfolio
val bal: semantics.bal.type = semantics.bal
}
通过层级方式来组合多个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操作的串联机制。