6.4 制作一种创建交易的DSL
我一向认为应该先看到实物,再去研究它是怎么形成的。所以暂时别管具体怎么实现,先看看我们的交易DSL怎么创建新交易:
val fixedIncomeTrade =
200 discount_bonds IBM
for_client NOMURA on NYSE at 72.ccy(USD)
val equityTrade =
200 equities GOOGLE
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的股票。
Scala知识点
隐含参数(implicit parameter)。用户没有明确指定时,隐含参数由编译器自动提供。特别适用于设计精简的DSL语法。
隐式类型转换是实现“限制了词法作用域的开放类”的秘诀。这种开放类近似于Ruby的猴子补丁,但比猴子补丁更好用。
命名参数和默认参数用在Builder模式的实现当中,可以省略不少拖泥带水的代码。
如果不走DSL的路线,而是按照一般的API设计方式,通过某个具体类的构造函数来完成交易创建过程,那么代码差不多会是下面这样。代码清单6-5先给出FixedIncomeTrade
的具体实现,然后演示了它的实例化过程。
代码清单6-5
FixedIncomeTrade
的实现和实例化示例
package api
import java.util.Date
import Util._
case class FixedIncomeTradeImpl( 实现FixedIncomeTrade trait
val tradingAccount: Account,
val instrument: FixedIncome,
val currency: Currency,
val tradeDate: Date = TODAY,
val market: Market,
val quantity: BigDecimal,
val unitPrice: BigDecimal) extends FixedIncomeTrade
val t1 = 实例化示例
FixedIncomeTradeImpl(
tradingAccount = NOMURA,
instrument = IBM,
currency = USD,
market = NYSE,
quantity = 100,
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
为例说明:
val fixedIncomeTrade =
200 discount_bonds IBM
for_client NOMURA on NYSE at 72.ccy(USD)
如果去掉各种语法糖,并且补上原来省略的点符号和圆括号,那么代码将变成下面这样的规范形式:
val fixedIncomeTrade =
200.discount_bonds(IBM)
.for_client(NOMURA)
.on(NYSE)
.at(72.ccy(USD))
当所有的方法调用和参数都披挂上它们应有的符号,看上去就和2.1.2节Java版指令处理DSL所用的Builder模式相差无几了。当前实现可以看做Builder模式的一种隐式实现。而我们也确实在实现中运用了Scala的隐式转换特性,令各部分以正确的次序组织起来,最终铺陈为有意义的DSL语句。
我们以200 discount_bonds IBM
为例说明其原理。只要掌握这个词组的构建机制,例子的其他部分都不在话下,看看完整的代码,就知道每个零件的位置和作用。请看下面的代码片段:
type Quantity = Int
class InstrumentHelper(qty: Quantity) {
def discount_bonds(db: DiscountBond) = (db, qty)
}
implicit def Int2InstrumentHelper(qty: Quantity) =
new InstrumentHelper(qty)
我们定义的InstrumentHelper
类接受一个Int
作为输入,类中定义了discount_bonds
方法。discount_bonds
方法的参数是一个DiscountBond
实例,返回由给定债券及其数量组成的一个Tuple2
。接着我们定义了从Int
到InstrumentHelper
类的implicit
转换。其作用自然是将输入的Int
不动声色地转换为输出的InstrumentHelper
实例,我们才得以在实例上调用discount_bonds
方法。由于Scala允许省略点符号和圆括号,调用过程可以写成中缀形式,即200 discount_bonds IBM
,这样看上去更自然。
只要用户定义好转换,Scala会在脚本的调用点插入必要的语义结构。脚本的其余部分也是同样的原理,在重重转换之际,构造FixedIncomeTrade
实例所需的参数也收集到位,最终传入一个能生成FixedIncomeTrade
实例的方法。隐式转换有一些需要掌握的惯用法,等看到完整代码时一并讲解。现在先看看图6-4,图中详细说明了完整的脚本执行过程。
图6-4 一连串的隐式转换,最终构造出FixedIncomeTrade
实例。从左至右阅读此图,跟随着箭头注意观察每一次隐式转换和创建辅助对象的子过程
为了真正理解图6-4,需要展示一个藏在API身后悄然发挥神秘作用的对象,详细分析它的全部代码。
2. 一连串的隐式转换
仔细观察图6-4,不难发现此起彼伏的隐式转换其实在默默扮演着builder的角色。这些转换行为逐渐拼合出最后的FixedIncomeTrade
对象。代码清单6-6定义了执行各个转换的辅助函数。
代码清单6-6
TradeImplicits
定义的转换函数
package dsl
import api._
object TradeImplicits {
type Quantity = Int
type WithInstrumentQuantity = (Instrument, Quantity)
type WithAccountInstrumentQuantity =
(Account, Instrument, Quantity)
type WithMktAccountInstrumentQuantity =
(Market, Account, Instrument, Quantity)
type Money = (Int, Currency)
class InstrumentHelper(qty: Quantity) {
def discount_bonds(db: DiscountBond) = (db, qty)
}
class AccountHelper(wiq: WithInstrumentQuantity) {
def for_client(ca: ClientAccount) = (ca, wiq._1, wiq._2)
}
class MarketHelper(waiq: WithAccountInstrumentQuantity) {
def on(mk: Market) = (mk, waiq._1, waiq._2, waiq._3)
}
class RichInt(v: Int) {
def ccy(c: Currency) = (v, c)
}
class PriceHelper(wmaiq: WithMktAccountInstrumentQuantity) {
def at(c: Money) = (c, wmaiq._1, wmaiq._2, wmaiq._3, wmaiq._4)
}
//..
}
代码清单6-7继续处理TradeImplicits
对象,它把代码清单6-6列出的转换函数都定义成Scala的implicit
。
代码清单6-7
TradeImplicits
定义的implicits
object TradeImplicits {
// 续代码清单6-6
implicit def quantity2InstrumentHelper(qty: Quantity) =
new InstrumentHelper(qty)
implicit def withAccount(wiq: WithInstrumentQuantity) =
new AccountHelper(wiq)
implicit def withMarket(waiq: WithAccountInstrumentQuantity) =
new MarketHelper(waiq)
implicit def withPrice(wmaiq: WithMktAccountInstrumentQuantity) =
new PriceHelper(wmaiq)
implicit def int2RichInt(v: Int) = new RichInt(v)
import Util._
implicit def Tuple2Trade(
t: (Money, Market, Account, Instrument, Quantity)) =
{t match {
case ((money, mkt, account, ins: DiscountBond, qty)) =>
FixedIncomeTradeImpl(
tradingAccount = account,
instrument = ins,
currency = money._2,
tradeDate = TODAY,
market = mkt,
quantity = qty,
unitPrice = money._1)
}
}
}
TradeImplicits
对象属于dsl
包,而所有的领域模型抽象都属于api
包。这种看似不必要的划分有其深意。我们曾讨论过用基本领域模型作为底层基础,然后在上面建立DSL门面的设计惯例,想起来了吗?这个例子也按照同样的思路,把所有的领域模型抽象放入api
包,语言层的抽象则放在dsl
包里。另外,让这两个抽象层保持分离,这样有利于以后对同一个领域模型建立多种DSL。设计DSL时也应该始终遵循这种惯例。
利用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开发需要我们再准备一些领域抽象作为底层的实现模型。