3.2 内部DSL的集成模式
设计成库形式的内部DSL,其实现语言要么和应用程序主体相同,要么可以与之无缝互操作。不管哪种情况,集成都无需借助任何外部设施来完成;无非是在DSL与核心应用程序之间发生的API调用而已。因为在同一的VM约束下,各种语言的互操作融洽无间,我把这种情况称为同质集成。请看图3-4,可以看到分别用Java、Groovy和Spring配置语言实现的不同DSL可无差别地集成于JVM之上。每种DSL都可以做成jar文件来部署,主体应用程序只需引用jar文件即可。
图3-4 3种DSL均与核心应用程序同质集成。每种DSL都可部署为jar文件,以便与核心Java应用程序在JVM上无缝地互操作
假设你主要用编程语言Java开发应用程序,但因为自己有多语言能力,所以打算利用Groovy的优势实现XML Builder功能。(这种Builder是在Groovy下处理XML的绝佳方式,参见http://www.ibm.com/developerworks/java/library/j-pg04125/。)没多久你又发现,有个第三方的JRuby DSL很适合用来加载Spring bean以管理应用程序配置。这时候,应该怎样把好几种DSL和核心应用程序集成在一起呢?集成不可以让用户面临太多复杂性;同时,DSL要与核心应用程序保持足够的独立性,以便独立掌握其演变步调和生命周期。JVM语言所写的DSL可以通过多种方式与Java应用程序集成。
众多JVM语言大都提供了与Java集成的多种途径。表3-2列出的每一种方式都能达到集成目的,但你必需了解每一种方式的优缺点,从中选出最适合的。
表3-2 内部DSL的集成入口
内部DSL的集成模式 | 集成入口 |
---|---|
Java 6的脚本引擎(参见3.2.1节) | 用Groovy等脚本语言编写的DSL可通过Java 6提供的相应脚本引擎来集成 |
DSL包装器(参见3.2.2节) | 利用JRuby、Scala、Groovy等语言把Java对象包装成更灵巧的API,这些语言本身就有Java集成功能 |
语言特有的集成功能(参见3.2.3节) | 通过实现一种直接加载并解析DSL脚本的程序抽象直接与Java集成。Groovy具有这样的直接集成能力 |
基于Spring的集成(参见3.2.4节) | 通过Spring的声明式配置直接加载用动态语言编写的各种bean到应用程序 |
接下来讨论集成的时候,我们都假定核心应用程序是以Java语言开发的,这种情况当今最为普遍。另外请注意,虽然我们提到的几种语言全都具备不同程度的Java集成能力,但它们彼此之间的集成还不成熟。现在还没有人在Groovy应用程序里面内嵌Ruby写的DSL。
下面我们就来一一详述表3-2所列的模式,看看一些JVM语言是怎么利用它们集成到Java应用程序的。
3.2.1 通过Java 6的脚本引擎进行集成
Java平台普及程度非常高,所以早有人考虑为Java平台上的所有语言建立统一的互操作平台。Java 6的脚本特性允许通过相应的引擎在Java应用程序中嵌入脚本语言。通过javax.script
包内定义的API,完全可以用于嵌入Groovy、JRuby等语言实现的DSL。来看这种集成方式的一个示例,其中还是沿用第2章中的交易单处理DSL。
1. 准备Groovy DSL
在2.2.2节,我们写了一段Groovy脚本来执行创建订单的DSL。现在还是同样的DSL,但这次要在Java应用程序里面集成和调用。这个例子将让你认识Java脚本特性开启DSL集成通道的能力。
代码清单3-1中就是我们用来处理客户交易单的DSL的Groovy实现(ClientOrder.groovy
,其内容与2.2.2节中的相同)。
代码清单3-1
ClientOrder.groovy
:Groovy语言编写的交易单处理DSL
ExpandoMetaClass.enableGlobally()
class Order {
def security
def quantity
def limitPrice
def allOrNone
def value
def bs
def buy(su, closure) {
bs = 'Bought'
buy_sell(su, closure)
}
def sell(su, closure) {
bs = 'Sold'
buy_sell(su, closure)
}
def getTo() {
this
}
private buy_sell(su, closure) {
security = su[0]
quantity = su[1]
closure()
}
}
def methodMissing(String name, args) {
order.metaClass.getMetaProperty(name).setProperty(order, args)
}
def getNewOrder() {
order = new Order()
}
def valueAs(closure) {
order.value = closure(order.quantity, order.limitPrice[0])
order
}
Integer.metaClass.getShares = { -> delegate }
Integer.metaClass.of = { instrument -> [instrument, delegate] }
我们在上面的Groovy代码中实现了一个Order
抽象来反映用户输入的交易单详情。而在另一个脚本文件order.dsl(代码清单3-2)中,DSL用户利用代码清单3-1中的实现把用户下单的操作写成脚本——这个脚本也是Groovy代码。注意,用户脚本完全基于我们在代码清单3-1中设计的DSL,用户只需要具备最低限度的编程知识。除了建立交易单,脚本还将交易单收集到一个集合,然后返回给调用者。但调用者是谁呢?别急,你马上就会知道。
代码清单3-2 order.dsl:执行下单操作的一段Groovy脚本
orders = []
newOrder.to.buy(100.shares.of('IBM')) {
limitPrice 300
allOrNone true
valueAs {qty, unitPrice -> qty * unitPrice - 500}
}
orders << order ❶将交易单加入集合
newOrder.to.buy(150.shares.of('GOOG')) {
limitPrice 300
allOrNone true
valueAs {qty, unitPrice -> qty * unitPrice - 500}
}
orders << order
newOrder.to.buy(200.shares.of('MSOFT')) {
limitPrice 300
allOrNone true
valueAs {qty, unitPrice -> qty * unitPrice - 500}
}
orders << order
orders ❷将集合返回给调用者
在代码清单3-2中,用户写下newOrder
建立一个新的Order
抽象,然后填入各种属性,比如买入卖出、交易数量、限价、定价策略等。所有新建立的交易单都被放入一个集合❶,该集合在在❷的位置被返回。
至此铺垫工作都已完成,重头戏就要上场了:我们要把DSL实现和用户脚本都集成到Java应用程序主体。
2. 集成DSL实现及用户脚本
代码清单3-3是从应用程序主体中截取的代码片段,它等着DSL脚本执行后返回的一个交易单集合,然后对交易单进行后续处理。这里用到的脚本引擎是Groovy语言的;还有JRuby、Clojure、Rhino和Jyphon等其他JVM语言的引擎,也可以像Groovy的一样无缝整合到Java应用程序里(详见https://scripting.dev.java.net/)。
代码清单3-3 调用Groovy DSL的Java程序代码
ScriptEngineManager factory = new ScriptEngineManager(); ❶取得脚本引擎的工厂
ScriptEngine engine = factory.getEngineByName("groovy"); ❷取得Groovy脚本引擎
List<?> orders = (List<?>) ❸返回交易单的列表
engine.eval(new InputStreamReader(
new BufferedInputStream(
new SequenceInputStream(
new FileInputStream("ClientOrder.groovy"),
new FileInputStream("order.dsl"))))); ❹执行DSL脚本
System.out.println(orders.size());
for(Object o : orders) {
System.out.println(o); ❺处理交易单
}
对照代码清单3-3和图3-5,我们不难看清集成的每一个步骤。图3-5的顺序图上标注了代码清单3-3中对DSL脚本及实现所进行的操作。
图3-5 通过Java 6的脚本引擎集成Groovy DSL。这幅交互图反映了在ScriptEngine
的沙盒内执行Groovy DSL脚本的全部步骤
显然,Java 6的脚本API几乎能够集成任何JVM语言编写的DSL到Java应用程序。javax.script
包内的API还能用于设置各种作用域的变量绑定,以便在DSL与Java组件之间交换信息。
3. Java 6脚本特性的不足
Java 6脚本特性是实现JVM语言互操作的一种极通用方式。但有所谓通用策略,就说明有专门针对某种语言的更好选择。由于DSL脚本被一个单独的ClassLoader
加载,又在独立的沙盒中运行,Groovy抽象与Java抽象之间存在互操作问题。注意,在代码清单3-3中,Groovy DSL脚本返回的Order
列表,到了Java一侧就成了Object
列表。要在这些对象上调用Order
抽象定义的方法,只好利用反射。另外,由于脚本在ScriptEngine
的沙盒中执行,当出现异常时,栈跟踪信息中显示的行号无法对应到源文件中的行号。因此,DSL脚本抛出的异常调试起来比较困难。因为Java 6脚本这样那样的不足,我们有必要继续探索更好的内部DSL集成方案。
脚本引擎是从Java 6开始引入的,是一种在Java程序内部执行脚本的通用方法。按照
ScriptEngine
相关API的设计原则,任何JVM语言只要实现了JSR 233规范要求的设施,就能获得该特性的支持。如果你打算用于实现DSL的语言有专门的Java集成途径,应该优先考虑,仅将JSR 233兼容的方案作为退而求其次的选择。语言特有的方案一般较为简单,也更符合语言习惯,所以往往效果最好。
Java 6的脚本API成就了JVM上多语言并用的现象。我们举了Groovy的例子,这纯粹是因为第2章刚好实现了一段Groovy DSL,它很容易无缝插入到Java应用程序。同样的集成手段完全适用于其他JVM语言(例如JRuby、Clojure和Rhino)编写的DSL。
即使在单个解答域中,多语言并用现象也鼓励使用多种语言。并用的语言需要有良好的互操作性,还要有明晰的集成入口。通常并用的语言享有共同的运行时平台,如JVM,其上的语言有Java、Scala、Ruby、Groovy等。DSL一个根本的设计思路就是选择最适合的语言设计领域API,然后通过共同的运行时平台将之与核心应用程序集成在一起。
DSL可以集成到不同层次。3.2.1节中探讨的Java脚本方案允许将DSL嵌入到ScriptEngine
执行框架,然后调用DSL脚本。它的优点是DSL完全与应用主体解耦,在ScriptEngine
的沙盒中执行;缺点是DSL组件不容易和应用程序的主体环境交互,操作不直观。
下面我们来看另一种DSL集成方式,它的工作层次不同于脚本引擎,而且与应用程序的宿主语言结合得更为紧密。
3.2.2 通过DSL包装器集成
在这种集成方式下,我们利用其宿主语言的丰富特性,将DSL构建成主体应用程序组件外面的包装层。遗留系统很适合采用这种方式将其对外接口改造成更灵巧的API。JVM语言大多提供了相当丰富的语言特性,完全有能力将遗留抽象“装饰”成更具表现力的领域组件。对于改造结果,不仅领域用户会很满意,而且开发者中的API用户也会乐见其成。
1. 示例
这次的示例中我们使用Scala语言,它是JVM上的静态类型语言,有完善的Java互操作能力。假设你的应用程序主体是用Java写成的,而且所有领域对象都已包含其中。这时客户因为受了“蛊惑”想试试基于DSL的开发,所以要求你在现有的Java交易系统上增加一些光鲜的DSL特性。这种场景正好适合包装式集成。
为了便于解释包装式集成概念,我会采用另一个证券交易的例子进行讲解。图3-6大体展示了交易过程。别忘了看一下补充内容“金融中介系统:客户账户”,你需要知道里面介绍的背景信息。
图3-6 交易账户和结算账户在交易过程中的作用
金融中介系统:客户账户
为了完成交易,客户需要在STO(Stock Trading Organization,证券交易组织)开设一个账户(称为交易账户)。客户参与的所有交易都被记入这个账户,并在STO留有交易记录。一旦成交,结算过程就必须启动。结算过程核算出交易双方各自结余的证券和现金数量。
来看例子。客户XXX通过STO野村证券买入100股索尼的股票,每股为50美元。STO从交易所获得某中介卖出的证券。成交后有个结算过程,交易双方在此过程中互换100股索尼股票和约5000美元。结算在一个账户上进行(称为结算账户),这个账户可以和客户的交易账户相同,也可以是另一个账户。
总之,交易账户用来交易,结算账户用来结算交易。两个账户可以相同也可以不同。图3-6是交易、结算过程的概略图。
来看Account
(账户)领域模型。账户是证券交易领域的一个实体,券商、客户、中介都通过它来管理交易和结算活动。刚刚的补充内容简单介绍了交易和结算操作中出现的不同账户类型和它们的功用。
要是记不清楚“交易”和“结算”在此领域中的确切含义,请回顾第1章和第2章的补充内容。
代码清单3-4就是Account
实体的领域模型,我们用它来讨论包装式DSL集成。代码中略为删减了一些不重要的细节。我们将要实现的Scala包装器就建立在Account
这个Java类基础之上。看过这个例子,你将看到包装器模式对客户API的改造成效,由API组织起来的语句结构将更为精干和具有表现力。
代码清单3-4 Java语言编写的
Account
领域模型
public class Account {
public enum STATUS { OPEN, CLOSED }
public enum TYPE { TRADING, SETTLEMENT, BOTH }
private String number;
private String firstName;
private List<String> names = new ArrayList<String>();
private STATUS status = STATUS.OPEN;
private TYPE accountType = TYPE.TRADING;
private double interestAccrued = 0.0;
public Account(String number, String firstName) {
this.number = number;
this.firstName = firstName;
}
public Account(String number, String firstName, TYPE accountType) {
this(number, firstName);
this.accountType = accountType;
}
//省略了获取方法
public double calculate(final Calculator c) {
interestAccrued = c.calculate(this);
return interestAccrued;
}
public boolean isOpen() {
return status.equals(STATUS.OPEN);
}
public Account addName(String name) {
names.add(name);
return this;
}
}
如果你曾经做过Java编程,肯定已经对代码清单3-4所示模型中的种种累赘、死板的内容见怪不怪了。让我们试着把抽象变得更灵巧一点,给客户准备一套领域味道更浓、表达更自如的API。到时候,我们的DSL和Java应用程序将真正血脉相连,犹如一体。
2. 建造DSL
首先,我们建立一个Scala抽象AccountDSL
充当Java类Account
的适配器,实现所谓的灵巧领域API。我们必须给Account
类披上极其灵巧的外衣,不管DSL如何设计,让用户都可以把DSL用于现有的Account
类实例;这是最终目标。后续几段代码将向你演示如何逐步强化AccountDSL
抽象。随后我们还会讨论可能出现的DSL使用情形,让你感受一下领域抽象的强化成果。
代码清单3-5是用Scala编写的DSL层,它将与Java类Account
无缝集成使用。
代码清单3-5 用Scala语言编写的
AccountDSL
class AccountDSL(value: Account) {
import scala.collection.JavaConversions._
def names = ❶将Java集合转换为Scala集合
value.getNames.toSeq.toList ::: List(value.getFirstName)
def belongsTo(name: String) = { ❷领域API
(name == value.getFirstName) || (names exists(_ == name))
}
def <<(name: String) = { ❸作用于集合的新运算符
value.addName(name)
this
}
//...
}
代码中用到一些典型的Scala惯用法,请参阅补充内容“Scala基础知识”里的简要介绍。欲详细了解Scala知识,请参阅3.7节文献1。
Scala基础知识
在
belongsTo
方法里面,我们写了一句断言:
- >> (names exists( == name))
它其实是一种简略写法,意思等同于以下Scala代码:
- >> (names.exists(n => n == name))
1. 在Scala语言里,调用方法的时候方法和接收者之间的点符号(
.
)是可选的。
在Scala语言里,下划线符号(
\
)可以作为简写记号代表别的东西。代码清单3-5中的_
是当做占位符使用的,提供参数给exists
所接受的高阶函数。
exists
所接受函数的参数类型由Scala类型推断器进行推断。在Scala语言里,运算符也是方法。我们可以定义一个给账户对象添加客户名字的
<<
方法。有些人觉得像<<
这样的运算符号比较好看,但我必须提醒一下,好看与否完全属于个人喜好,如果用得太多反而可能降低代码的可读性。
代码清单3-5中的AccountDSL
是Java类Account
的适配器,Account
被包裹起来,成为了AccountDSL
的底层实现。在❶的位置,我们为了方便后面的高阶函数,把Java集合转换成了Scala集合。(Scala集合上可以施用高阶函数,所以更能表达清楚一些操作的意思,在这个意义上说,Scala集合的语义总是比Java集合更丰富。)这里用到了Scala 2.8才有的Java、Scala集合隐式(implicit
)转换功能,如果你用的Scala版本比较低,可以改用jcl
转换API:
def names =
(new BufferWrapper[String] {
def underlying = value.getNames
}).toList ::: List(value.getFirstName)
我们还定义了一个领域API belongsTo
❷,其中用到了高阶函数和刚刚转换而来的Scala集合。这个地方充分体现了Scala紧凑的特点。最后,我们为了DSL的表现力和简洁性,特意定义出像<<
这样类似运算符号的语法❸。
新的Scala API包装了原来的Java实现,不久我们也将见识到客户怎样用新的简洁语法表达其领域意图。表现力和简洁性是DSL驱动开发的主要优点,我们的例子清楚地展现了DSL的力量。
3. 利用Scala的隐式特性
在将视角转向客户之前,我们还有一件事情需要解释。只有Account
和AccountDSL
之间能互操作,AccountDSL
上面建立的各种机巧才能应用到Account
实例。通过Scala的隐式特性,我们可以让AccountDSL
具备的任何功能同时对Account
类的实例生效。我们所要做的,只是在词法作用域内定义一个隐式转换:
implicit def enrichAccount(acc: Account): AccountDSL =
new AccountDSL(acc)
这样就可以透明地从Account
转换成AccountDSL
,之后你就可以在Account
实例上使用新的DSL API了。现在,我们创建几个Java类Account
的实例:
val acc1 = new Account("acc-1", "David P.")
val acc2 = new Account("acc-2", "John S.")
val acc3 = new Account("acc-3", "Fried T.")
Scala的隐式特性
enrichAccount
方法定义前面有个implicit
修饰符。在Scala语言中,implicit
修饰符用于方法表示定义从一个类型到另一个类型的自动转换。在这里,enrichAccount
方法将一个Account
实例转换成AccountDSL
实例。使用中并不需要写成:
- scala> enrichAccount(acc1) belongsTo("David P."),
你可以直接在
Account
实例上调用AccountDSL
的方法:
- scala> acc1 belongsTo("David P.")
就好像
AccountDSL
的全部方法都注入到了Account
类一样。是不是有点象Ruby的猴子补丁(monkey patching)?我们可以用Ruby的猴子补丁打开任意类,并向里面添加方法。
不过Scala的情况有些不一样:implicit
被限制了词法作用域。Account
和AccountDSL
之间的自动转换只存在于enrichAccount
方法的词法作用域内。而Ruby的开放类允许在全局作用域内修改现有类,这是很大的不同。3.7节的参考文献[3]深入分析了Scala语言中隐式特性的优点。
现在,我们使用新的<<
运算符把将几个账户所有人的名字加到账户acc1
:
acc1 << "Mary R." << "Shawn P." << "John S."
留意上面代码片段表现出来的言简意赅的特点,同样的意思如果用原本的Java API写出来是这样的:
acc1.addName("Mary R.").addName("Shawn P.").addName("John S.");
接着我们把几个账户组成集合,对于账户所有人中包含“John S.”的,都打印出账户所有人的名字(firstName
):
val accounts = List(acc1, acc2, acc3)
accounts filter(_ belongsTo "John S.") map(_ getFirstName) foreach(println)
表现力真好,跟原本使用Java API时简直不可同日而语。这么大的区别应该归功于Scala语言的丰富表达手段,它使我们能用一组更紧凑的API表达出更充沛的语义。上面的代码片段运用filter
、map
、foreach
组合子来操作高阶函数,比命令式的Java语法利落得多。有没有感觉到一点兴奋?我们继续吧!
取得属于John S.的账户列表,对于其中累计利息已超过阈值(threshold
)的所有账户合计其应付利息(accruedInterest
):
accounts.filter(_ belongsTo "John S.")
.map(_.calculate(new CalculatorImpl))
.filter(_ > threshold)
.foldLeft(0.0)(_ + _)
上面的代码片段运用了之前补充内容中讲解过的一种Scala惯用法。filter
后面的断言有个需要推断类型的参数,_
就是代表这个参数的占位符。类似Java那样语法烦琐的语言就没办法把领域问题表达得像这里一样清楚。第1章就说过,这一切都归功于Scala等更强大语言丰富的抽象设计能力,它们减少了代码中的非本质复杂性(accidental complexity)。
注意作为calculate()
方法输入使用的CalculatorImpl
对象。Calculator
是在Java中定义的接口,而CalculatorImpl
是其实现:
public interface Calculator {
double calculate(Account account);
}
public class CalculatorImpl implements Calculator {
@Override
public double calculate(Account account) {
//具体实现
}
}
大多数时候,传递给Account#calculate()
方法的参数总是Calculator
接口的同一个实现,这时可以利用DI在运行时动态注入实现以避免代码重复。不过,Scala可以提供更好的办法:把这一参数隐含在所有的calculate
调用中。
class AccountDSL(value: Account) {
//同上一段
def calculateInterest(
implicit calc: Calculator): Double = { ➊隐含的Calculator实例
value.calculate(calc)
}
}
上面的代码给calculateInterest
方法定义了一个implicit
参数,并且在DSL的执行作用域内设置该implicit
参数的默认值➊。现在,我们给Calculator
规定了一个隐含的默认实现,不再需要每次调用calculateInterest
方法都传递一个Calculator
实例。最后,计算John S.名下所有账户的应付利息就变成这个样子:
implicit val calc = new CalculatorImpl
accounts.filter(_ belongsTo "John S.")
.map(_.calculateInterest)
.filter(_> threshold)
.foldLeft(0.0)(_ + _)
有了闭包、高阶函数等特性的协助,Scala能够定义非常自然的控制抽象,甚至给人感觉就像语言内建的语法一样。即使底层实现是Java对象,也不妨碍我们设计出强力的控制结构,成就简洁明了的DSL。
4. 带给用户的利益
我们在第1章就说过,设计DSL的目的并不是让不懂编程的领域用户用它写代码。所谓设计得当的DSL,重点是API要突显其沟通作用。上一段代码中出现的map
、filter
、foldLeft
等函数式编程的组合子,其实对于领域人员来说并不好理解。但领域人员不难在上述代码片段中看到以下几个要点:
- 过滤出属于John S.的账户;
- 对其计算利息(
calculateInterest
); - 过滤出大于阈值的值;
- 合计所有的利息值。
当你在代码局部集中呈现这些信息要点,领域专家就比较容易理解并验证业务逻辑。如果采取命令式的写法,同样的逻辑很可能散布在较大范围的代码片段里,不懂编程的人很难理清其逻辑。
我们接下来就用Java对象Account
和代码清单3-5中实现的AccountDSL
定义一个控制抽象:
object AccountDSL {
def withAccount(trade: Trade)(operation: Account => Unit) = {
val account = trade.account
//初始化
try {
operation(account)
} finally {
//清理
}
}
}
这段代码用到的两个抽象(Account
和Trade
)都是可能经过Scala包装器强化的Java类。现在轮到DSL用户拿这些抽象来做点有用的领域操作了。有刚才的withAccount
控制抽象,再加上把Scala和Java整合到一起的包装器,用户可以写出下面的DSL代码片段。这段代码照旧比纯Java方案的最好结果表现力更强,更接近于领域用语。
withAccount(trade) {
account => {
settle(
trade using
account.getClient
.getStandingRules
.filter(_.account == account)
.first)
andThen journalize
}
}
上面一连串的API调用做了些什么,看看图3-7就清楚了。
图3-7 前面代码片段的流程图解,分步说明从取得账户到事务结束的全过程
只要几行非常清楚易懂的领域专用代码,withAccount
就能做这么多事情。如果把这一小段代码拿给领域专家看,他肯定能给你解释其功能。我就拿给老鲍看了。(还记得他吗?这位蹦蹦高证券公司的领域专家从1.4节开始,就一直在给我们帮忙。)你猜怎么着?老鲍看了代码,然后和我进行了下面这么一番对话。
- 老鲍:你在过滤之后,从结算常设规则里面选其中的第一条,对吧?
- 我:没错!
- 老鲍:但有时候可能有好几条规则适用于同一个账户。
- 我:那你怎么决定应该选哪条规则?
- 老鲍:在这种情况下,每条规则都有对应的优先级。你应该选优先级最高的那一条。
- 我:好!
下回你的经理再说DSL的前期投入太大,你就把刚刚学到的告诉他吧。不见得每一种DSL集成都需要一大笔开支。本小节讨论的包装器方式就是实实在在的例证。这种方式是对当前Java领域模型投资的增值,回报给你的是更灵巧、对领域专家更有用的代码。
只要在Java应用程序中选择Scala作为DSL实现语言,你就可以采用DSL包装器手法。Scala的类型系统有办法把Java对象变得更灵巧,而隐式特性是其秘诀。接下来,我们学习如何利用一些语言特有的集成手段在Java之上实现DSL。这次我们又回到Groovy语言并重温3.2.1节讨论过的DSL示例。
3.2.3 语言特有的集成功能
我们重温3.2.1节集成到核心Java应用程序的那个交易单处理DSL。但这次不用ScriptEngine
来集成,而是在Java应用程序中动态载入Groovy类的技巧。动态类加载保证Groovy对象即使内嵌于核心Java应用程序,仍然有很好的可操纵性。
元编程、闭包、委托等Groovy知识详见3.7节参考文献[2]。
1. Java代码与Groovy DSL之间的通信
假设Java交易程序已经用Java 6的ScriptEngine
集成了交易单处理DSL。一切都运行得很好,直到有一天客户拿来了新的需求:从脚本返回给Java应用程序主体的交易单集合还需要一些额外的处理。具体来说,我们需要计算当前所下全部交易单的总值,还要为客户定制交易单上显示的项目。
在之前的方案里,DSL实现(ClientOrder.groovy
)和用户脚本(order.dsl
)被合成一段Groovy脚本,放进ScriptEngine
的沙盒中执行。Groovy DSL对于Java代码完全不透明;Groovy脚本和Java类分别由不同的类装载器载入,所以脚本内容对于应用程序主体是不可见的。为了满足客户的需求,我们必须找一种办法让Java应用程序接触Groovy类,这就要费一番功夫更换DSL集成方案。
2. 利用Groovy类装载器进行更好的集成
这里,作为DSL实现环节的Groovy类将成为Java应用程序内可重用的抽象,而作为DSL使用环节、由用户编写的交易单处理脚本才由GroovyClassLoader
加载。代码清单3-6展示的几处修改可令DSL更符合Groovy风格。
代码清单3-6 RunScript.java:利用
GroovyClassLoader
集成DSL
public class RunScript {
public static void main(String[] args)
throws CompilationFailedException, IOException,
InstantiationException, IllegalAccessException {
final ClientOrder clientOrder = new ClientOrder();
clientOrder.run(); ❶ 设置元类
final Closure dsl = ❷ 加载Groovy类
(Closure)((Script) new GroovyClassLoader().parseClass(
new File("order.dsl")).newInstance()).run();
dsl.setDelegate(clientOrder); ❸ 给闭包设置委托
final Object result = dsl.call(); ❹ 执行DSL
List<Order> r = (List<Order>) result;
int val = 0;
for(Order x : r) { ❺ 处理结果集合
val += (Integer)(x.getValue());
}
System.out.println(val);
}
}
我们一步步解释这段代码怎样增强DSL集成的Groovy味道。我们分离出ClientOrder.groovy
抽象,预编译,使Order
类可用于Java应用程序。在上面的Java类中,我们运行ClientOrder
的一个实例,以设置元类❶。DSL脚本order.dsl
返回一个内含DSL代码的Closure
❷。接着,我们设置ClientOrder
为Closure
的委托,以解析脚本中的符号❸。然后,我们执行DSL脚本,获得一个Order
对象的列表❹。最后,我们遍历所有的Order
对象,求得交易单总值❺。
DSL脚本一执行完,我们就得到一个Order
对象的列表,十分方便后续的业务处理。而按照之前代码清单3-3中的方案,通过Java 6脚本API进行集成,就做不到这一点。客户应该满意这样的结果,而你也学到了一种集成Groovy到Java应用程序的新方法。
3. 最终结果
DSL脚本order.dsl
现在变成下面的样子,改为向Java应用程序返回一个Closure
。
代码清单3-7
order.dsl
:DSL脚本改为返回一个Closure
{->
orders = []
ord1 =
newOrder.to.buy(100.shares.of('IBM')) {
limitPrice 300
allOrNone true
valueAs {qty, unitPrice -> qty * unitPrice - 500}
}
orders << ord1
ord2 =
newOrder.to.buy(150.shares.of('GOOG')) {
limitPrice 300
allOrNone true
valueAs {qty, unitPrice -> qty * unitPrice - 500}
}
orders << ord2
ord3 =
newOrder.to.buy(200.shares.of('MSOFT')) {
limitPrice 300
allOrNone true
valueAs {qty, unitPrice -> qty * unitPrice - 500}
}
orders << ord3
println "Orders ..."
orders.each { println it }
}
现在的Groovy交易单处理DSL与第2章中的相比又进步了一些,而且它与Java集成的效果也比3.2.1节的ScriptEngine
方案更出色。
介绍完语言特有的集成功能,下面介绍一种基于框架的内部DSL集成方案。Spring提供了这样的平台,我们来看看它的用法。
3.2.4 基于Spring的集成
表3-2总结的集成手法就剩下最后一种未介绍了。本节要介绍的集成方案通过框架来实现,论抽象层次,它比前面那些语言层面的集成方案更高。要是Java应用程序里面的业务规则可以动态修改,而且不需要重新启动程序,那该多好;你会不会常常这样想?
1. Spring的动态语言支持
从2.0版开始,Spring即支持用Ruby、Groovy等表现力强的动态语言所实现的bean。(欲详细了解Spring,请访问http://www.springframework.org。)这类bean有所谓的“可刷新”性质,即当其底层实现发生变化的时候,可以动态地重新装载它们。来看一个金融中介领域的例子。假设有个TradingService
实现,为了计算附息债券的应付利息,需要查找一些计算规则。
public class TradingServiceImpl implements TradingService {
private AccruedInterestCalculationRule accIntRule; ❶ 由Spring注入的计算规则
@Override
public void doTrade(Trade trade) {
//具体实现
}
}
在上面的代码片段中,应付利息的计算规则经由Spring DI在运行时注入❶。利用Spring对动态语言的支持,我们可以选择JRuby、Groovy、Jython等表现力好的语言来实现这些规则。此处的场景正好适合一种小巧、内涵丰富的DSL发挥作用。这样有两个好处:
- 因为语言本身内涵丰富,代码的表达更到位;
- 当bean的底层实现发生变化时,其运行时实例可以自动重新加载。
就当前的例子而言,我们用个Java接口来定义计算规则的契约:
public interface AccruedInterestCalculationRule {
BigDecimal calculate(Trade trade);
}
然后规则的具体实现可以用Ruby DSL来写:
require 'java'
class RubyAccruedInterestCalculationRule {
def calculate(trade)
//具体实现
end
end
RubyAccruedInterestCalculationRule.new
现在就剩最后一件事情了。
2. 接通实现
用下面的Spring XML配置片段就可以把整个实现连接起来。现在,当Java程序需要一个AccruedInterestCalculationRule
实例的时候,就会得到由Ruby DSL编写而成的实例。
<lang:jruby
id="accIntCalcRule"
refresh-check-delay="5000"
script-interfaces=
"org.springframework.scripting.AccruedInterestCalculationRule "
script-source="classpath:RubyAccruedInterestCalculationRule.rb">
</lang:jruby>
恭喜你,你成功利用Spring在Java应用程序中集成了Ruby DSL。这种非侵入式的DSL集成模型使DSL组件与使用它的上下文解耦。如果你的应用程序正好将Spring用作DI框架,不妨考虑用这种集成模式解决业务规则DSL动态重新加载问题。
针对内部DSL的同质集成模式至此介绍完毕,我们接着学习外部DSL的集成模式。外部DSL形态各异,其语言设施也可能是专门设计的。下一节,我们会回顾2.3.2节讨论过的所有外部DSL实现模式,看看不同的实现方案会在核心应用程序上留下怎样的集成入口。注意,外部DSL都是特别为了某个应用程序而定制的;我们对于外部DSL集成模式的讨论受限于几种常见的使用手法。