在 CLR 之上的构建领域特定语言

阅读数:836 2008 年 5 月 26 日

话题:.NET语言 & 开发架构

最近领域特定语言(DSL,Domain Specific Languages)这个话题比较热门。这可以从 Rails 现象中看到。Rails 的流行以及 Rails 上广泛使用的领域特定语言(从现在起叫 DSL),已经引起了对 DSL 的广泛兴趣。

到现在为止开发人员有这样的印象,建立一个 DSL,你需要专业的编译器理论知识,理解 Lex 和 Yacc 的内部工作原理并需要投入大量的时间来构建 DSL。结果是极少数人愿意去尝试,他们都是从头开始构建自己的语言。

这往往是成本高昂。

同时,动态语言的爱好者可以毫不费力的利用他们喜欢的动态语言的动态特性来构建领域特定语言。事实上,他们中的许多以这种方式构建的任何应用程序,都有着显著的复杂性。

这两种方法的差别有重要意义。第一种方式是创建属于自己的语言,就是所谓外部的 DSL(External DSL)。这是一个耗资巨大的项目,因为一切都要从头开始构建,需要考虑运算符的优先级规则、运行时类库、执行代码、错误处理和 I/O。第二种方法是利用和修改宿主语言,就是所谓内部的 DSL(Internal DSL)。这些都容易构建和维护。你只需要考虑如何修改,所有的其它东西(通常是你不用关心的)都已经被宿主语言处理了。

另一种做法是构建连贯接口(Fluent Interface),把它叫做 DSL。我认为这不是一种 DSL,这种方法往往在语言的自由性方面受到很大的限制。Java 和 C# 就是很好的例子,包括 Java 6 和 C# 3。你可以列举许多语言方面的 API,但这不能让我觉得这是一个 DSL。

在任何情况下,我的个人偏好是使用具有很高语法灵活性的内部 DSL。因为我基本上都是在 CLR 上工作,我想利用运行在这个平台上的宿主语言。它可以让我重用大部分的使用 CLR 的知识,不要低估这方面的好处。在你的手中有一个熟悉的环境是非常重要的。

在深入语言之前,看看究竟什么是“高语法灵活性的语言”,怎么样?为内部 DSL 提供一个良好的宿主环境的语言需要具有哪些特性?

我需要有合适的手段来表达我的想法。这可以通过有启发性的命名,表达特定域的概念,并通常和通用语言的做法不一样。你希望能够创建一个第四代语言,这很容易做到。让我们从一个我们电子表格所使用的脚本这样简单的 DSL 开始如何?

你的任务就是创建乘法网格。

for x in range(100):

for y in range(100):

cell[ x+1 , y+1 ] = x * y

formula x, 100, sum( x1, x100 )

这是不是真的令人印象深刻呢?这和编程语言几乎完全一样,代码也是微不足道。除了和用 Excel 的自动化 API 做一样的工作外,更简短。

注意到这就是我们所用到的所有代码。我们不需要一个类的定义,或者是一个主方法。这是一个没有任何语法包袱的可执行的 DSL 脚本。

如果前面的例子没有给你留下深刻的印象,看看如何定义订单折扣的业务规则:

apply_discount_of 5.percent:

when order.Total > 1000 and customer.IsPreferred

when order.Total > 10000

suggest_registered_to_preferred:



when order.Total > 100 and not customer.IsPreferred

这看起来和编程语言有很大不同,它更像业务分析师在 Word 文档中定义的业务规则。

从我的角度来看,上面两个例子都是领域特定语言。他们只是表达领域的方法和风格不同。这两个例子,我们实际上已经从语言中移除了和我们的领域没有直接关系的东西。这使得我们可以专注于域,并希望有良好的工具来处理。

除了域概念以外,没有任何东西可以和具有与域相匹配的语法是一样重要的。

当我们开始在 CLR 上研究高语法灵活性的语言的时候, 我们有很多的选择。我们来评估几个语言。我们将从几个来自微软的语言开始。

C# —— 这是一个非常刚性的语言,类型定义,没有独立的方法 / 代码块,僵硬的语言。所有这些特性使得 C# 不是一个 DSL 宿主语言的好选择。他也可以做到的,但它不如其他方法。

VB.Net —— 其实 VB.Net 更适合面向对象的语言,因为它使用了许多英文单词作为关键字和操作符。令人遗憾的是它也是一个非常冗长的语言,我们要减少冗余性适合我们的域概念。

JScript —— 这可能引来一片笑声,但是 JScript 是一个非常灵活的语言,为许多事情提供了较好的语法。只要去看看所提供的所有 Javascript 类库。JScript 提供了和 Javascript 相同的基础功能。虽然这样,有一点不得不考虑的是你可以做到像 JQuery 或 Prototype 那样多大的灵活性。然而它不够成熟,我不确定将来是什么样子的。虽然它在很多方面有灵活的语法,给人有种编程语言的感觉,这会让我在一个 DSL 中觉得分心。

F# —— 这是一门由微软开发的,将来会发布的函数式编程语言。F# 支持面向对象编程。我已经简略的浏览过这门语言。虽然 F# 的强大功能令人印象深刻,从我的角度来看,它看起来是 BNF【译者注:BNF, Backus-Naur Form 的缩写,巴科斯范式一种使用形式化符号来描述给定语言的语法。】定义,其他什么都不像。毫无疑问这是由于作者缺乏函数式编程语言经验方面的问题,但是我不只是考虑它的可读性。

我们已经看完微软开发的语言。让我们看得更远些。据统计去年 CLR 上运行的语言超过了一百种,所以我选择了两种我认为是 DSL 宿主语言的候选语言。

Nemerle是一个多范型的语言(面向对象和函数式),完全支持编译器宏(后来更多的是 Lisp 的变种,而不是 C++),以及许多其他的东西,这使得它是一个 DSL 很好的宿主语言。这不是我阅读 Nemerle 代码的简单理由(经常是这样)。

Boo是一个基于 Python 语法的静态类型的面向对象的语言。它支持宏(也是 Lisp 变种),有一个开放的编译器管道和更容易构建 DSL 的特性。Boo 是我首选的用于构建 DSLs 的语言,但是为了保证客观性,我们需要在讨论这个主题之前证明 Boo 有多么的强大。

动态语言运行时(DLR)怎么样呢?

到目前为止我还没有讨论动态语言运行时,这是一个在 CLR 之上支持动态语言的微软项目(目前支持 Ruby,Python 和 EcmaScript)。

更具体的来说,当人们讨论 DLR 的时候,他们是在讨论 IronRuby 和 IronPython。

Ruby 是一门被证明非常适合写内部 DSL 的语言,在 CLR 上运行可以使我们在熟悉的环境下工作。

使用 DLR 作为一个 DSL 的平台当然是可能的,但是我至少在一段时间内不使用它。DLR 和 IronRuby 本身都还是在开发之中。我不认为微软对发布日期会有任何的承诺,此外我没有发现 Ruby 能做的 Boo 做不了,我觉得 Boo 的元编程基础功能非常自然和强大。

“自然和非常强大”是什么意思?

让我们深入一点考查 Boo。我说它有一个开放的编译器,我并不是指它是开放源代码的(它是,但是和这个无关),我的意思你有办法深入到编译器的内部和在编译的时候打乱编译器的内部对象模型。这意味着我们可以以一种有趣的方式改变编译器的行为。

上面的两个代码示例都是 Boo 的 DSL 代码。

全面深入 Boo 的元编程基础功能超出了本文的范围,但是我可以用一个简单的例子来展示它的威力。

CLR 已有 IDisposable 的概念并配合 using 语句使用。现在我定义一个 ITransactionable,将用它来定义一个事务的声明。

public interface ITransactionable:

def Dispose():

pass

def Commit():

pass

def Rollback():

pass

macro transaction:



return [|

tx as ITransactionable = $(transaction.Arguments[0])

try:

$(transaction.Body)

tx.Commit()

except:

tx.Rollback()

raise

finally:

tx.Dispse()

|]

只需要这个代码,我们就可以作为一个头等语言要素来使用该事务的声明了(实际上,这也正是 using 语句在 Boo 的实现方式)。

transaction GetNewDatabaseTransaction():

DoSomethingWithTheDatabase()

现在,如果代码里面的事务抛出了一个异常,事务将自动回滚。如果它执行是成功的,事务就自动提交。

不过这只是使用 Boo 的一个示例。并注意这里我介绍的唯一一个概念就是宏和有趣的符号 [||]。没有更深入的讨论,这指示编译器在事务块内部的代码用宏的内容做了一个代码的替换。

很重要的是这已经超越文本替换,我们直接修改 AST(Abstract Syntax Tree,抽象语法树 --- 编译器对象模型)。这是一个微不足道(但很强大)的例子。我们下面将探讨一个更复杂的场景,这将告诉我们为什么这样的区分是重要的。

为了构建一个 DSL,这个级别的功能还是不够的。你可以只使用 Boo 语言的语法而不使用元编程功能,类似于 Ruby,有很多可选的语法,这在很多场景是非常有用的。举个例子来说,我们可以不通过元编程创建相同的语法,但是利用 Boo 的这一特性,如果方法的最后一个参数是一个委托(闭包,块等等),可以给方法传递一个代码块。

比如:

def transaction(tx as ITransactionable, transactionalAction as ActionDelegate):

try:

transactionalAction()

tx.Commit()

except:

tx.Rollback()

raise

finally:

tx.Dispse()

我们仍然可以使用这代码,正如我们前面所用的:

transaction GetNewDatabaseTransaction():

DoSomethingWithTheDatabase()

从语法上来看,是没有差别的。 不过这两个版本还是有微小的差别。CLR 确保了如果 try 程序块能够成功执行, 就进入 try 程序块执行。这是 using() 语句正确执行的关键,其他的场景也是一样的。

第一个版本可以利用这个能力,第二个版本不能。(原因是第二个版本是在运行时调用方法,而第一个版本只是替换事务代码块得到修改后的结果。)

我们还可以利用 Boo 的元编程做些什么呢?相当多,关于这个内容可以写一本书(实际上我已经写了这方面的一本书:-) )。作为一个简单的例子,而不一定是良好设计的的最好例子,你可以修改语言的 if 语句的语义。

有一次我不得不这样做,我把 if 语句修改下面模式:

if foo == null:

# do something

为这个模式:

if foo == null or foo isa NullObject:

# do something

现在,当我们检查 null 的时候,我们也检查这个对象是否是 NullObject 的实例,NullObject 是我的应用程序中的一个自定义类型。这使得在我的应用程序以一种自然的方式使用 NullObject 模式。

val = NullObject() # set val to a new instance of NullObject

if val == null: # will be compiled as val == null or val isa NullObject

print "Value is null"

else:

print "Value is not null"

我们已经扩展了语言认为所有继承自 NullObject 的对象作为 null。

从长远来看,有能力去修改语言的基本组成部分是我的工作(和语言的使用)容易得多。

在继续下一步之前来看最后一个例子。我想告诉你如何在 Boo 应用程序中使用不到 20 行代码添加一个(简单的)‘契约式设计’(类不变式)。这是代码:

[AttributeUsage(AttributeTargets.Class)]

class EnsureAttribute(AbstractAstAttribute):

expr as Expression

def constructor(expr as Expression):



self.expr = expr

def Apply(target as Node):



type as ClassDefinition = target

for member in type.Members:

method = member as Method

continue if method is null

block = method.Body

method.Body = [|

block:

try:

$block

ensure:

assert $expr

|].Block

用法如下:

[ensure(name is not null)]

class Customer:

name as string

def constructor(name as string):



self.name = name

def SetName(newName as string):



name = newName

现在任何把名字设置为 null 都将导致一个断言异常。这个技术相当强大和容易使用。我将前置条件的标记的实现留给读者。

这个例子也示范了直接使用编译器的对象模型(AST)的强大。我们不局限于 C++ 宏的文本替换,我们可以查询对象模型并以非常自然的方式修改它。那么现在我想你相信 Boo 是一个非常适合构建 DSL 的语言。我只是从表面上浏览了一下它的潜力,还有很多有待于你的探索 。

其他几项优势:Boo 是静态编译类型的语言,这意味着你的 DSL 拥有标准 CLR 代码的所有优势(即时编译器、垃圾回收、调试等等)。从性能角度来看,你的应用程序代码和 DSL 代码没有任何区别。

因此基于 Boo 的 DSL 对于经常需要修改和需要高性能的代码是理想的选择。在产品中不得不改变的这样的共同的需求往往推动人们使用基于 XML 的系统,规则引擎等。即使没有考虑完全“用 XML 编程”这样的辩论,这些选择都遭遇了低性能的问题。

建立一个使用一系列 DSL 脚本的系统很容易,从长远来说,是要提供高性能和高度可维护性的系统。 他还需要配合领域驱动设计,因为有一个领域特定语言更容易表达自然的域概念。

有几个公开的 Boo DSL。

我个人喜欢的是 Binsor。Binsor 是一个 Caste Windsor IoC 容器的配置 DSL,使得使用 IoC 的高级概念易如反掌。你需要了解 Binsor 的更多信息可以通过访问Binsor 2.0发布说明。其它用 Boo 的 DSL 是:

Specter是一个行为驱动开发(BDD)框架,提供了一个非常自然的方式书写规格并将规格转换为 NUnit 测试用例。

Brail是一个文本模板语言。

还有几个,但是使用它的人很少,并没有广为人知。

写一个 DSL 要求有一些初步知识,但是知识是很简单和容易获取的。一旦你有了这个基础知识,你就可以开始写一个 DSL 就像制作一个表单那么简单。

实际上我已经写了一个后端处理系统,大部分是从各种来源处理消息的 DSL 组成的。在这个系统中,我写 DSL 就像在我的表现层上写表单一样的。

总体来说,Boo 是一个用于构建 DSL 非常好的语言。使用 Boo 写 DSL 有助于降低成本,没有性能和灵活性方面的妥协。此外,他还提供了自由的语法和自然的方式表达域的概念。

在最后结束时说一句,Boo 也可以在 Java 上运行

关于作者

Oren Eini,也叫 Ayende Rahien,是一个经验丰富的.NET 开发者和架构师。他也是好几个开源项目的贡献者,例如 NHibernate 和 Castle。此外,Ayende 是 Rhino Mocks、Rhino Commons 和 NHibernate Query Analyzer 的创始人。关于 Boo,Ayende 创建了 Castle MonoRail 的模板语言 Brail,配置 Castle Windsor IoC 容器的 DSL,他还写了一本标题为《Building Domain Specific Languages in Boo》的书。

查看英文原文:Building Domain Specific Languages on the CLR》。