5.2 Ruby语言实现的交易处理DSL
本节我们要开发一个完整的用例。我们要设计一种DSL用于建立新的证券交易,并根据可灵活组合的业务规则计算交易的现金价值。DSL执行之后,将产生一个Trade
抽象的实例供应用程序使用,具体用法因应用而异。我们会从一个简单的实现开始并逐步进行改进,充实其表现力和领域内涵。图5-4展示了DSL演进的迭代路线图。
图5-4 我们逐步丰富Ruby DSL的实现手段,完善交易处理DSL。每个阶段我们都投入更多Ruby语言的抽象能力,增加更多领域功能,用以充实DSL
在整个开发过程中,老鲍会担当我们的指导,负责指出所有不当之处和需要改进的地方,帮助我们打造一个表现力充沛的DSL设计。至于能不能满足老鲍的要求,就要看Ruby帮不帮忙了。
代码提示 后面几节含有大量代码片段,我会插入说明一些预备知识,解释必要的语言特性,以便读者理解实现中的细微之处。阅读之前也不妨先翻看本书附录中相应语言的速查表格。
请牢记我们的目标:DSL要让老鲍能看懂,并且能检查DSL是否违反了他的业务规则。
5.2.1 从API开始
最开始的API设计总是略显粗糙的。动态语言的开发历程就像制陶一样,你总是从一团黏土开始,然后有步骤地塑造其形态,逐渐增加其表现力。
Ruby知识点
Ruby中如何定义类和对象? Ruby是一种面向对象语言,类的定义形式与其他OO语言相仿。Ruby有其特殊的对象模型,允许你在运行时通过元编程机制修改、调查、扩展对象。
如何使用散列容器实现不定长的参数列表? Ruby语言允许你向方法传递一个散列容器作为参数,以此来模拟“关键字参数”(keyword arguments)的特性。
Ruby元编程基础知识。 Ruby的对象模型包含许多可以用于反射式和生成式元编程的元件材料,例如类、对象、实例方法、类方法、单例(singleton)方法,等等。Ruby元编程机制允许你在运行时探查其对象模型,也允许动态地改变行为或生成代码。
请思考以下代码片段,这是我们的API设计师想出来的第一版DSL:
instrument = Instrument.new('Google') 将被交易的新票据
instrument.quantity = 100
TradeDSL.new.new_trade 'T-12435', 将被创建的交易
'acc-123', :buy, instrument,
'unitprice' => 200,
'principal' => 120000, 'tax' => 5000
老鲍见了之后大声嚷嚷起来:“喂!这个东西太技术了。我想要一个票据对象还得调用那一串奇怪的构造?我平常可不是这么解读交易和票据的。”
老鲍的话有道理,我等一下再解释。你先跟我复诵一遍:DSL绝不会第一次就做对。DSL总是迭代演进的。上面的片段只是一套中规中矩的API,其易读性只达到Ruby的一般水平。它还没有形成连贯的句子,读起来不像老鲍平常处理交易业务时挂在口边的话语。不过,这段代码给我们奠定了一点基础,可以作为我们的出发点。
1.基本抽象
任何DSL设计都是从一组基本抽象开始,然后在上面建立符合领域习惯的语言。这样的过程我们称为自底向上的编程方式,大的抽象由小的抽象生长而成,最后形成领域专家想要的表现形态。
我们的DSL设计从针对SecurityTrade
、Instrument
等基本领域实体的一组API开始。下面的Ruby代码清单是API对应的一些基本抽象。
代码清单5-3
SecurityTrade
的Ruby实现(第一次迭代)
class SecurityTrade
attr_reader :ref_no,
:account,
:buy_sell,
:instrument,
:unitprice
attr_accessor :principal,
:tax,
:commission
def initialize(ref_no, account, buy_sell, instrument, unitprice)
@ref_no = ref_no
@account, @buy_sell, @instrument, @unitprice =
account, buy_sell, instrument, unitprice
end
def self.create(ref_no, account, buy_sell, instrument, h) ➊ 用于创建交易的类方法
tr = new(ref_no, account, buy_sell, instrument, h['unitprice'])
[:principal, :tax, :commission].each do |m|
tr.instance_eval("tr.#{m} = h['#{m}'] if h.has_key?('#{m}')") ➋ 填入来自hash容器的值
end
tr
end
end
create
类方法里面的h
是个hash容器➊,用来给unitprice
、 principal
和tax
提供命名参数。用hash容器来实现命名参数是Ruby的一种惯用法。位置➋还采用了一种有意思的技巧,即利用元编程建立被调用对象的隐式上下文,并用hash容器h
中取出的各参数值填充交易对象实例。我们在第4.2.1小节讨论过如何建立隐式上下文。
代码清单5-4实现了Instrument
类。这个类没有写成不可变的形式,除此之外没什么特别值得注意的地方。就当前版本的DSL而言,我们可以把它写成不可变的值对象。但之所以保留为可变的对象,到下一节你就能清楚知道其理由,到时候我们要利用它的可变性来创造一种票据DSL。
代码清单5-4 在Ruby中实现
Instrument
交易
class Instrument
attr_accessor :name, :quantity
def initialize(name)
@name = name
end
def to_s()
"(Name: " + @name.to_s +
"/Quantity: " + @quantity.to_s + ")"
end
end
本小节代码缺少的最后模块是TradeDSL
类,它只负责把前面的材料串在一起:
require 'security_trade'
class TradeDSL
def new_trade(ref_no, account, buy_sell, instrument, attributes)
SecurityTrade.create(ref_no, account, buy_sell, instrument, attributes)
end
end
我们的DSL迈出了第一步。你会在后续的迭代中观察到enter code here
TradeDSL的成长过程,它的表现力会越来越强,我们也会逐步加入更多的功能。
2.DSL门面
TradeDSL
类演示了一种让DSL语法与底层实现解耦的重要手法。一方面,这个类向用户呈现DSL表面语法;另一方面,它把基本抽象包装起来,在实现之上插入一个间接层。图5-5形象地描绘了DSL的这种结构。
图5-5 DSL门面既向用户提供表现力充沛的API,又保护核心实现结构不被暴露
切记,在设计DSL时,一定只向用户提供单一的交互点。本例中TradeDSL
类担当了DSL门面角色。目前这个门面仅包装了SecurityTrade
类的create
方法,我们将在后续的迭代中持续地充实、完善这个抽象,使它能够满足用户的需求。现在最紧迫的任务是解决老鲍对DSL中创建票据部分的不满。这种时候来点不按常规的办法反而管用。
5.2.2 来点猴子补丁
TradeDSL
类的下一步进化目标是让老鲍更轻松地创建票据。他想按照平常在交易台前的习惯那样,购买100股IBM的股票。下面是一段创建交易的DSL,里面说明了被交易的票据详情,我们就希望能把脚本写成这个样子。
TradeDSL.new.new_trade 'T-12435',
'acc-123’, :buy, 100.shares.of('IBM'),
'unitprice' => 200, 'principal' => 120000, 'tax' => 5000
之前那些老鲍看不懂的多余句法结构不见了,他可以用平常习惯的语言来设立票据:100.shares.of('IBM')
。老鲍很满意!那么我们费了哪些功夫才得到这样的脚本呢?
Ruby知识点
猴子补丁(monkey patching)指在已有的类中加入新的属性或方法。Ruby允许打开一个已经存在的类,引入新的方法和属性来扩增类的行为。这项特性极为强大,强大到你可能会忍不住滥用它。
代码清单5-5实现了shares
和of
两个方法,我们不动声色地把它们引入到Numeric
类。Numeric
是Ruby语言内建的类,任何Ruby类都可以被打开并引入新的属性和方法。这样的做法被称为猴子补丁(monkey patching)。很多批评者认为不应该鼓励使用这种特性,因为猴子补丁威力太大,使用的时候不得不注意其风险和陷阱。任何一本正经的Ruby教科书(第5.7节文献1)都会警告你不要过度使用。其实,只要谨慎使用,猴子补丁可以给DSL插上翅膀。
代码清单5-5 使用了猴子补丁的票据DSL
require 'instrument'
class Numeric ➊ 打开Numeric类
def shares ➋ 新方法shares
self
end
alias :share :shares
def of instrument ➌ 新方法of
if instrument.kind_of? String
instrument = Instrument.new(instrument)
end
instrument.quantity = self
instrument
end
end
写完这段代码,交易DSL的第一次迭代就告一段落。随着核心抽象的各部分逐渐延伸融合,我们DSL的表现力也越来越强。第5.2.1小节开头那段代码在创建票据时伴随的干扰现在已经被清理掉。不过与老鲍想要的自然语言表达相比,我们的实现还存在不少句法怪异的地方。Ruby有办法帮我们更进一步,而且我们的TradeDSL
门面也经得起折腾。下一节我们会用一点语法糖衣将它装点起来,打扮成老鲍满意的DSL。
5.2.3 设立DSL解释器
表现力要多强才算足够?这个问题没有固定的答案,因DSL用户的立场而异。对于熟悉Ruby的程序员来说,第一次迭代的DSL表现力已经足够。即使是不懂编程的领域专家,也大体知道写的是什么,只不过有些多出来的句法结构看起来可能不太舒服而已。因为精益求精,也因为Ruby语言有这样的能力,所以我们可以把DSL做得更完美一些,更接近老鲍在交易台前所说的语言。
Ruby知识点
如何利用Ruby的“嵌入文档”(here documents) 特性定义多行字符串。通过这个技巧,可在源代码中直接定义一段文本,而不必另行写到代码外部。
如何定义类方法。类方法(或单例方法)是Ruby单例类(singleton class)的实例方法。详情请查阅5.7节文献[1]。
evals
的一般用法及其元编程用途。在运行时动态地求解一串或一段内含代码的字符,是Ruby最强大的特性之一。Ruby的evals
有好几种形态,适用于不同的上下文。Ruby正则表达式的处理。 Ruby内置支持正则表达式,对模式匹配和文本处理有极大的帮助。
1.加入解释器
我们在第5.2.2小节已经为TradeDSL
开发了相当有表现力的语法,能出色地反映领域语义。不过对老鲍来说,还是技术味太浓了一点,他习惯于说更流畅的交易语言。
第二次迭代我们准备推出一个解释器来翻译老鲍的语言,把他的话去芜存菁,提取构建抽象所需的必要信息。本次迭代完成之后,DSL脚本看起来应该是这个样子:
str = <<END_OF_STRING
new_trade 'T-12435' for account 'acc-123'
to buy 100 shares of 'IBM',
at UnitPrice=100, Principal=12000, Tax=500
END_OF_STRING
puts TradeDSL.trade str
DSL的核心抽象上一次迭代时就已经准备就绪,我们现在开始动手制作语法糖衣。我们的交易处理语言正按照计划稳步前进。
要让代码变成上面的样子,我们需要往TradeDSL
类里加些什么东西呢?5.2.2节打造的门面TradeDSL
,经过第二次迭代变成代码清单5-6的样子。类中设立了一个小小的解释器,负责将用户输入处理之后再传递给SecurityTrade
。
代码清单5-6 在Ruby中将交易DSL做成解释器的样子(第二次迭代)
require 'security_trade'
require 'numeric'
class TradeDSL
class << self
def const_missing(sym) ➊ 拦截未定义的常量
sym.to_s.downcase
end
def trade(str)
TradeDSL.new.interpret(str)
end
end
def new_trade(ref_no, account, buy_sell, instrument, attributes)
SecurityTrade.create(ref_no, account, buy_sell, instrument, attributes)
end
def interpret(input)
instance_eval parse(input) ➋ 提供隐式上下文给new_trade
end
def parse(dsl_string) ➌ 处理用户输入
dsl = dsl_string.clone
dsl.gsub!(/=/, '=>')
dsl.sub!(/and /, '')
dsl.sub!(/at /, '')
dsl.sub!(/for account /, ',')
dsl.sub!(/to buy /, ', :buy, ')
dsl.sub!(/(\d+) shares of ('.*?')/, '\1.shares.of(\2)')
dsl.sub!(/(\d+) share of ('.*?')/, '\1.shares.of(\2)')
puts dsl
dsl
end
end
与其直接解释每一行代码做了什么,不如先看一幅示意图。图5-6描绘了老鲍的语言被解释的全部步骤。
图5-6 一段TradeDSL
脚本示例被代码清单5-6的程序解释并生成Ruby对象的全过程。经由DSL解释器生成了一个security_trade
实例
请把这幅图与代码清单5-6对照着来理解。你能发现其中用了第4章介绍过的哪些手法吗?还真不少,三不五时就改头换面地冒出来一个。下面的列表可以帮你发现几个:
const_missing
方法➊用了运行时元编程(见4.4节)手法,它将任何未定义的常量转换为字符串;interpret
方法中的instance_eval
➋将TradeDSL
的一个实例设立为执行new_trade
方法的隐式上下文(见4.2.1节);parse
方法使用正则表达式➌处理用户输入,将输入转换为符合new_trade
方法调用形式的字符串。
若想更深入了解Ruby元编程技术,请参阅第5.7节文献[5]。
2.像老鲍那样说话
现在从DSL用户的角度回顾一遍前面的工作。用户现在可以用他日常业务活动中的语言写出生成新交易的DSL脚本。我们在DSL中插入了一些无意义的词汇,以尽量符合一般领域用户的用语习惯。作为用户,老鲍可以把DSL文本写到一个文件内,文本经过加载、处理之后生成SecurityTrade
的实例。即使交易数据来自上游的营业系统,他照样可以用这DSL生成SecurityTrade
实例并存入数据库。
下一小节我们继续改进DSL,并纳入一些业务规则,一方面便利于程序员用户,另一方面其他的领域用户也可在老鲍生成的交易上增补规则,用于后续的交易环节。
5.2.4 以装饰器的形式添加领域规则
虽然老鲍满意目前的交易生成方式,但他对后续的交易环节仍有些担心,因为后面要在交易上补充业务规则。我们答应老鲍立即着手这个问题,一旦这部分语言的表现力过关就拿给他看。那么我们现在就开始新的迭代,目标是增加DSL功能并充实交易的业务规则。
Ruby知识点
如何定义、使用Ruby块。 Ruby语言的块(block)被用于实现lambda和闭包。
如何通过模块实现mixin。 Ruby模块(module)是一种组织代码制品的方式。类可以通过mixin的形式包含模块及其中的制品。
如何串联不同的mixin来设计装饰器。
鸭子类型。在Ruby语言中,只要对象实现了与某消息同名的方法,就可以响应该消息。对象是否实现了特定方法不作静态检查;可在运行时修改对象。只要能学鸭子叫,Ruby就认为那是一只鸭子。
1.交易DSL现状回顾
我们不能一味埋头改进,在着手实现交易充实功能之前,不妨先回顾一下现在所处的位置。图5-7一目了然地展现了目前的进展和后续的任务。我们开发的交易生成DSL会产生一个SecurityTrade
实例,下一步要把业务规则充实到交易中,可考虑将业务规则建模为DSL。
图5-7 我们已经开发了生成交易的DSL,现在要增加用来计算交易现金价值的业务规则。我们准备把业务规则也做成DSL
当交易被送到证券交易机构的后台时,将被附上其现金价值和静态数据资料,为进入处理流程的后续环节做好准备。第4.2.2小节已介绍过如何计算交易的现金价值,也叫做净结算价值。后台接收到交易之后,需要援用领域规则来计算交易的现金价值。领域规则因交易市场、票据类型等各种因素而异。为免讨论过于复杂,我们假设有一组固定的规则。为了在生成的交易上实施这组规则,我们需要扩展现有的DSL。
2.实现领域规则
以下规则适用于老鲍生成的交易:
交易的现金价值由基本价值总额、税费总额、佣金总额计算得出。
如果输入的交易流含有以上总额中的任意项,该项将按输入的数额计算;否则按照以下规则对每笔交易分别计算后汇总得出。
以下业务规则适用于所有交易:
- 基本价值总额为单价与数量的乘积,单价、数量都是交易对象的一部分。
- 税费按基本价值总额的固定比例计算。
- 佣金按基本价值总额的固定比例计算。
这几条规则实现之后,用户使用DSL充实交易的情形如下所示。
代码清单5-7 交易DSL的用法
require 'trade_dsl'
require 'cash_value_calculator'
require 'tax_fee'
require 'broker_commission'
str = <<END_OF_STRING
new_trade 'T-12435' for account 'acc-123'
to buy 100 shares of 'IBM',
at UnitPrice = 100
END_OF_STRING
TradeDSL.trade str do |t| ➊ 为了副作用而使用Ruby块
CashValueCalculator.new(t).with TaxFee, BrokerCommission do |cv| ➋ 以mixin手法实现的装饰器
t.cash_value = cv.value ➌ 块内发生的副作用
t.principal = cv.p
t.tax = cv.t
t.commission = cv.c
end
t
end
TradeDSL.trade(str)
生成的SecurityTrade
实例被传递到一个Ruby块➊,然后作为SecurityTrade
实例在块内被修改➌的副作用,完成了交易充实过程。以上写法是常规的、合乎语言习惯的Ruby编程方法。这段脚本的表现力是简洁的Ruby语言和我们加入的领域语义共同作用的结果。
上面的代码将TaxFee
和BrokerCommission
的计算逻辑单独抽象出来,做成可插拔的DSL部件,这一点也值得注意。用户需要什么部件,就把什么部件连接到CashValueCalculator
类➋。这样的设计手法就是我们在第4.2.2小节讨论过的,所谓的“基于mixin的编程”。一个个mixin组件就是附着在主体类CashValueCalculator
上的装饰器。
我们还要对TradeDSL.trade
方法做一点改动才能让它接受一个块作为参数。DSL的其他部分不变。
代码清单5-8 交易DSL的Ruby实现:为了副作用而使用Ruby块(第三次迭代)
require 'security_trade'
require 'numeric'
class TradeDSL
class << self
def const_missing(sym)
sym.to_s.downcase
end
def trade(str)
yield TradeDSL.new.interpret(str) if block_given? ➊ 处理块,得到副作用
end
end
end
代码清单5-7还留下了一点未解决的地方,CashValueCalculator
实例上很明显的附加了各种装饰器,但我们还没搞清楚它们的实现。
3.附加装饰器的Ruby DSL
代码清单5-7向你演示了怎样在核心抽象上装点像TaxFee
和BrokerCommission
那样的语法糖衣。而且与静态语言不同,我们的装点工作可以借助元编程的力量在运行时巧妙地完成。下面的代码清单完整地实现了对给定的交易计算其现金价值的DSL。
代码清单5-9 计算交易的现金价值
class CashValueCalculator
attr_reader :trade
attr_accessor :p, :t, :c
def initialize(trade) ➊ 简洁、含义清晰的句法
@trade = trade
@p = [@trade.principal,
@trade.unitprice * @trade.instrument.quantity].find do |m|
not m.nil?
end
@t = @trade.tax unless @trade.tax.nil?
@c = @trade.commission unless @trade.commission.nil?
end
def with(*args) ➋ 动态地合成各mixin
args.inject(self) { |acc, val| acc.extend val }
yield self if block_given?
end
def value
@p
end
end
module TaxFee
def value
@t = @p * 0.2 if @t.nil?
super + @t
end
end
module BrokerCommission
def value
@c = @p * 0.1 if @c.nil?
super + @c
end
end
成了!现在我们的DSL外有老鲍能理解的自然语法,内有领域语言表述的清晰实现。5.1节所述的动态类型语言三大特质都体现在我们的Ruby DSL上,表5-2对此作了简要总结。
表5-2 动态语言和Ruby DSL
特质 | 代码清单5-9中用到的Ruby特性 |
---|---|
易读性 | 灵活柔顺的语法、数组字面量、可选的圆括号,这几项特性使initialize 方法➊内的代码清晰简明。领域规则被直白地表达出来,易于读者理解,如果输入交易时一并输入了现金价值的构成要素,优先按照输入值计算,否则从交易中算出
交易的现金价值总额由混入到CashValueCalculator 实例的各模块隐含地计算得出。代码清单5-7的DSL脚本很好地抽象了现金净值的具体计算过程,同时又清楚地告诉用户计算中涉及哪些构成要素。实际上,用户希望最后的净值算入哪些要素,就提供哪些要素 |
鸭子类型 | 注意TaxFee 和BrokerCommission 的value 方法都是在没有任何静态继承关系的情况下使用了super 关键字。这是对鸭子类型的一次应用
只要插入任何一个拥有value 方法的模块,都能顺利完成计算 |
元编程 | with 方法➋起到了组合子的作用,通过在运行时扩展各参与模块,实现对各mixin的组合 |
交易DSL的Ruby实现至此全部完成。我在本节开头提出一个待解决的现实用例,然后向你演示基于DSL的问题解决方式。现在我们看到了结果,并为打算建模的领域功能找到了最自然的实现方式。借助Ruby语言的灵活语法、鸭子类型和元编程能力,最终创造出一种领域专家能完全领会的专用语言。我们一边改进实现,一边着重学习了那些使Ruby成为优秀内部DSL实现语言的特性。这样安排不是为了卖弄Ruby的能力,而是反复向你演示在基于DSL的开发方式下,如何配合强力的编程语言去创造具有扩展性的抽象。
下一节将探讨另一种DSL实现语言,它像Ruby一样具有动态类型和强大的元编程能力,而且它可以和JVM更紧密地集成。我们在第2章和第3章设计指令处理DSL的时候已经用过它——Groovy语言。现在我们准备再对之前的实现做一些改进。
你有没有想过既然平常的开发多半只会用一种语言,那么我们为什么要学习那么多种语言呢?在现实的开发中,最切合解答域需要的语言才是最理想的DSL实现语言。请记住DSL的语法和语义才是最重要的决定因素;选择哪种实现语言只是手段而已。你学到手的套路越多,设计DSL的时候能使的招式就越多。