6.4 制作一种创建交易的DSL

我一向认为应该先看到实物,再去研究它是怎么形成的。所以暂时别管具体怎么实现,先看看我们的交易DSL怎么创建新交易:

  1. val fixedIncomeTrade =
  2. 200 discount_bonds IBM
  3. for_client NOMURA on NYSE at 72.ccy(USD)
  4. val equityTrade =
  5. 200 equities GOOGLE
  6. for_client NOMURA on TKY at 10000.ccy(JPY)

第一项定义fixedIncomeTrade创建了一个FixedIncomeTrade的实例,为客户帐号NOMURA在纽约证券交易所(NYSE)按72美元的单价买入200张IBM的折价债券(DiscountBond)。

第二项定义equityTrade创建了一个EquityTrade的实例,为客户帐号NOMURA在东京证券交易所(TKY)按10 000日元的单价卖出200股Google的股票。

6.4	制作一种创建交易的DSL - 图1Scala知识点

  • 隐含参数(implicit parameter)。用户没有明确指定时,隐含参数由编译器自动提供。特别适用于设计精简的DSL语法。

  • 隐式类型转换是实现“限制了词法作用域的开放类”的秘诀。这种开放类近似于Ruby的猴子补丁,但比猴子补丁更好用。

  • 命名参数和默认参数用在Builder模式的实现当中,可以省略不少拖泥带水的代码。

如果不走DSL的路线,而是按照一般的API设计方式,通过某个具体类的构造函数来完成交易创建过程,那么代码差不多会是下面这样。代码清单6-5先给出FixedIncomeTrade的具体实现,然后演示了它的实例化过程。

代码清单6-5 FixedIncomeTrade的实现和实例化示例

  1. package api
  2. import java.util.Date
  3. import Util._
  4. case class FixedIncomeTradeImpl( 实现FixedIncomeTrade trait
  5. val tradingAccount: Account,
  6. val instrument: FixedIncome,
  7. val currency: Currency,
  8. val tradeDate: Date = TODAY,
  9. val market: Market,
  10. val quantity: BigDecimal,
  11. val unitPrice: BigDecimal) extends FixedIncomeTrade
  12. val t1 = 实例化示例
  13. FixedIncomeTradeImpl(
  14. tradingAccount = NOMURA,
  15. instrument = IBM,
  16. currency = USD,
  17. market = NYSE,
  18. quantity = 100,
  19. unitPrice = 42)

DSL与一般API的差异明显。DSL版的创建过程更自然,更适合领域用户阅读;API版的编程味道比较浓,有许多语法细节需要注意,例如分隔参数项的逗号,实例化时要写出类名等。稍后你会发现,可读性强的DSL版为了实现操作的顺序执行,同样要满足许多约束条件。如果看重顺序的灵活性,可以选择Builder模式(参见6.10节文献[3]),但那样会带来Builder对象的可变性问题和方法链的收尾问题(参见6.10节文献[4])。

本节开头介绍了DSL脚本的未来发展趋势,现在我们进入实现环节。

6.4.1 实现细节

请你再好好对比一下6.4节开头的DSL脚本和代码清单6-5中普通的构造函数调用写法。DSL脚本中几乎没有与领域语义无关的语法结构,这就是两者最明显的区别。前面说过,Scala允许省略表示方法调用的点运算符和方法参数使用的圆括号。就算在这样的有利条件之下,如果没有一种足够灵活的手段,还是不能把各种成分合理地结合起来,成为符合语言逻辑的脚本。那么,连接各语言成分的秘诀是什么?

1. 隐式转换

秘诀是Scala语言的implicits特性!我们以创建FixedIncomeTrade为例说明:

  1. val fixedIncomeTrade =
  2. 200 discount_bonds IBM
  3. for_client NOMURA on NYSE at 72.ccy(USD)

如果去掉各种语法糖,并且补上原来省略的点符号和圆括号,那么代码将变成下面这样的规范形式:

  1. val fixedIncomeTrade =
  2. 200.discount_bonds(IBM)
  3. .for_client(NOMURA)
  4. .on(NYSE)
  5. .at(72.ccy(USD))

当所有的方法调用和参数都披挂上它们应有的符号,看上去就和2.1.2节Java版指令处理DSL所用的Builder模式相差无几了。当前实现可以看做Builder模式的一种隐式实现。而我们也确实在实现中运用了Scala的隐式转换特性,令各部分以正确的次序组织起来,最终铺陈为有意义的DSL语句。

我们以200 discount_bonds IBM为例说明其原理。只要掌握这个词组的构建机制,例子的其他部分都不在话下,看看完整的代码,就知道每个零件的位置和作用。请看下面的代码片段:

  1. type Quantity = Int
  2. class InstrumentHelper(qty: Quantity) {
  3. def discount_bonds(db: DiscountBond) = (db, qty)
  4. }
  5. implicit def Int2InstrumentHelper(qty: Quantity) =
  6. new InstrumentHelper(qty)

我们定义的InstrumentHelper类接受一个Int作为输入,类中定义了discount_bonds方法。discount_bonds方法的参数是一个DiscountBond实例,返回由给定债券及其数量组成的一个Tuple2。接着我们定义了从IntInstrumentHelper类的implicit转换。其作用自然是将输入的Int不动声色地转换为输出的InstrumentHelper实例,我们才得以在实例上调用discount_bonds方法。由于Scala允许省略点符号和圆括号,调用过程可以写成中缀形式,即200 discount_bonds IBM,这样看上去更自然。

只要用户定义好转换,Scala会在脚本的调用点插入必要的语义结构。脚本的其余部分也是同样的原理,在重重转换之际,构造FixedIncomeTrade实例所需的参数也收集到位,最终传入一个能生成FixedIncomeTrade实例的方法。隐式转换有一些需要掌握的惯用法,等看到完整代码时一并讲解。现在先看看图6-4,图中详细说明了完整的脚本执行过程。

enter image description here

图6-4 一连串的隐式转换,最终构造出FixedIncomeTrade实例。从左至右阅读此图,跟随着箭头注意观察每一次隐式转换和创建辅助对象的子过程

为了真正理解图6-4,需要展示一个藏在API身后悄然发挥神秘作用的对象,详细分析它的全部代码。

2. 一连串的隐式转换

仔细观察图6-4,不难发现此起彼伏的隐式转换其实在默默扮演着builder的角色。这些转换行为逐渐拼合出最后的FixedIncomeTrade对象。代码清单6-6定义了执行各个转换的辅助函数。

代码清单6-6 TradeImplicits定义的转换函数

  1. package dsl
  2. import api._
  3. object TradeImplicits {
  4. type Quantity = Int
  5. type WithInstrumentQuantity = (Instrument, Quantity)
  6. type WithAccountInstrumentQuantity =
  7. (Account, Instrument, Quantity)
  8. type WithMktAccountInstrumentQuantity =
  9. (Market, Account, Instrument, Quantity)
  10. type Money = (Int, Currency)
  11. class InstrumentHelper(qty: Quantity) {
  12. def discount_bonds(db: DiscountBond) = (db, qty)
  13. }
  14. class AccountHelper(wiq: WithInstrumentQuantity) {
  15. def for_client(ca: ClientAccount) = (ca, wiq._1, wiq._2)
  16. }
  17. class MarketHelper(waiq: WithAccountInstrumentQuantity) {
  18. def on(mk: Market) = (mk, waiq._1, waiq._2, waiq._3)
  19. }
  20. class RichInt(v: Int) {
  21. def ccy(c: Currency) = (v, c)
  22. }
  23. class PriceHelper(wmaiq: WithMktAccountInstrumentQuantity) {
  24. def at(c: Money) = (c, wmaiq._1, wmaiq._2, wmaiq._3, wmaiq._4)
  25. }
  26. //..
  27. }

代码清单6-7继续处理TradeImplicits对象,它把代码清单6-6列出的转换函数都定义成Scala的implicit

代码清单6-7 TradeImplicits定义的implicits

  1. object TradeImplicits {
  2. // 续代码清单6-6
  3. implicit def quantity2InstrumentHelper(qty: Quantity) =
  4. new InstrumentHelper(qty)
  5. implicit def withAccount(wiq: WithInstrumentQuantity) =
  6. new AccountHelper(wiq)
  7. implicit def withMarket(waiq: WithAccountInstrumentQuantity) =
  8. new MarketHelper(waiq)
  9. implicit def withPrice(wmaiq: WithMktAccountInstrumentQuantity) =
  10. new PriceHelper(wmaiq)
  11. implicit def int2RichInt(v: Int) = new RichInt(v)
  12. import Util._
  13. implicit def Tuple2Trade(
  14. t: (Money, Market, Account, Instrument, Quantity)) =
  15. {t match {
  16. case ((money, mkt, account, ins: DiscountBond, qty)) =>
  17. FixedIncomeTradeImpl(
  18. tradingAccount = account,
  19. instrument = ins,
  20. currency = money._2,
  21. tradeDate = TODAY,
  22. market = mkt,
  23. quantity = qty,
  24. unitPrice = money._1)
  25. }
  26. }
  27. }

TradeImplicits对象属于dsl包,而所有的领域模型抽象都属于api包。这种看似不必要的划分有其深意。我们曾讨论过用基本领域模型作为底层基础,然后在上面建立DSL门面的设计惯例,想起来了吗?这个例子也按照同样的思路,把所有的领域模型抽象放入api包,语言层的抽象则放在dsl包里。另外,让这两个抽象层保持分离,这样有利于以后对同一个领域模型建立多种DSL。设计DSL时也应该始终遵循这种惯例。

enter image description here利用Scala语言的implicits特性可以构造出开放类,类似于Ruby语言的猴子补丁和Groovy语言的ExpandoMetaClass。而且对于打开进行修改的类,Scala提供了控制其可见范围的方法。只要针对需要用到新增方法的局部词法作用域导入相应的模块,编译器会处理好其他事情。这种特性不会像Ruby的猴子补丁那样影响全局命名空间。

3. Implicits和词法作用域

我们利用implicits特性,通过将Int隐式转换为RichInt类,在Int身上添加了一个ccy方法。如果上述隐式转换在全局命名空间进行,则所有线程都能看这一修改。我们早在介绍Ruby猴子补丁时就已经讨论过这样做的明显弊端。因此我们遵循一条黄金准则:implicits必须被限制在适当的作用域之内。也就是说,你应该划定隐式转换的词法作用域,然后在前面明确声明import TradeImplicits._,以免影响其他线程。

尽管隐式转换优点突出,可是使用它时不能直观地表现在代码的字面上,调试时又难以捉摸其来龙去脉。为此,Scala编译器特别提供了若干编译参数作为调试工具,方便你检查隐式转换(详见6.10节文献2)。

你刚刚完成平生第一段Scala DSL,它的表现力有没有给你惊艳的感觉?你一定要掌握本例实现的来龙去脉,否则请多看几遍图6-4,跟着箭头走,你对代码的理解也会越来越深入。

好了,现在你被Scala激起的兴奋心情也差不多该平复了。我们换个冷静的心态,考虑几个创建Scala DSL时需要注意的关键问题。

6.4.2 DSL实现模式的变化

仔细观察我们在领域模型上搭建的DSL层代码,可以辩认出一种模式的轮廓,再看看图6-4,这种模式显而易见。按照DSL脚本解析的方向,从左至右浏览示意图。我们连续运用Scala隐式转换,逐步构建一个n元组。6.4.1节提到,这样的构造方式其实就是Builder模式。只不过传统的实现方案会单独设立一个可变的builder抽象,负责构建实体,而此处采用了一种不可变的Builder模式变体。传统实现呈现一种命令式风格,builder对象连续发出方法调用,被一连串的方法调用所更新,同时每次方法调用都返回builder对象自身。相比之下,在这个不可变方案中,各个方法分属不同的类,要靠Scala的隐式转换把所有的调用粘合到一起。一次调用产生一个多元组,然后该多元组经过隐式转换,作为输入送到下一环节,生成下一个多元组。

你完全可以选择传统的实现方案。那样的话,会有什么不同吗?传统Builder模式的方法调用序列写起来十分灵活方便,但问题是用户最后必须调用一个收尾方法来结束构建过程(参见6.10节文献[4])。相比之下,本例当前的实现方式已经将调用序列固定在DSL里面,如果用户没构造完交易对象就提早终止调用序列,那么编译器会发出提醒。两种方案并没有绝对的优劣之分,无非是一个设计决策。

传统的Builder模式有一个可变的builder对象,用户通过它的连贯接口发起链式的方法调用,完成对该builder对象的修改。本例中的Builder模式实现由一系列隐式转换构成,每一步转换产生的对象都是不可变的。尽量采用不可变性的抽象设计是一种好习惯。在领域抽象上面建立DSL门面离不开几项Scala特性,表6-3总结了这些特性。

表6-3 交易创建DSL用到的Scala特性

Scala语言特性 作用
灵活的语法。由于省略点符号(.)和圆括号,形成了中缀表示法 使DSL更容易阅读,表达更清晰
隐式转换 通过限制了词法作用域的开放类,可以向Int等内建类加入新方法 对象链接
命名参数和默认参数 使DSL更容易阅读

创建交易对象的DSL至此告一段落。下一节要为更多的业务规则建立DSL,而且DSL写成的每一条规则都能让领域专家看懂并把关。基于DSL的开发,其出发点正是促进与领域专家的沟通,协助专家查验由开发者实现的业务规则。下一步的DSL开发需要我们再准备一些领域抽象作为底层的实现模型。