写点什么

用 Erlang 实现领域特定语言

2008 年 11 月 25 日

人们对 Erlang 谈得很多,但话题往往集中在并发方面,很少涉及 Erlang 平台的其他强大特性。本文正是打算讨论其中一项没有得到足够重视的特性—— Erlang 是打造领域特定语言的极佳平台。我在这里选择了投资金融领域作为例子,向你展示在 Erlang 运行时系统里,翻译并执行平直的英文语句是多么简单的一件事情。你顺带还会学到一星半点函数式编程的知识。如果在学习当中有什么不明白的, Erlang 参考手册是你的好帮手。

我们首先高屋建瓴地看一下这种 DSL 的使用情况,然后再一步步详细讨论它的实现。

复制代码
$ # 首先定义业务规则
$ echo "buy 9000 shares of GOOG when price is less than 500" > biz_rules.txt
$ echo "sell 400 shares of MSFT when price is greater than 30" >> biz_rules.txt
$ echo "buy 7000 shares of AAPL when price is less than 160" >> biz_rules.txt
$ erl # 启动 Erlang 仿真器(类似于 irb 或者 beanshell)
1> c(dsl). % compile and load, assumes dsl.erl is in the current directory
2> Pid = spawn(fun() -> dsl:broker() end). % start a broker in parallel
3> Functions = dsl:load_biz_rules(Pid, "biz_rules.txt").
4> MarketData = [{'GOOG', 498}, {'MSFT', 30}, {'AAPL', 158}].
5> dsl:apply_biz_rules(Functions, MarketData).
Order placed: buying 9000 shares of 'GOOG'
Order placed: buying 7000 shares of 'AAPL'

实现

前三行 echo 命令创建 biz_rules.txt 文件,并向其中写入三条规则。这些规则的逻辑很直白;其表达形式与直接从用户口中说出来的话相差无几。

复制代码
buy 9000 shares of GOOG when price is less than 500
sell 400 shares of MSFT when price is greater than 30
buy 7000 shares of AAPL when price is less than 160

我们的 DSL 放在一个名为“dsl”的 Erlang 模块中,模块只有一个文件 dsl.erl 。在 erl 代码中,第一条命令是用内建的 c 函数编译并加载 dsl 模块。

复制代码
1>c(dsl). % compiles and loads, assumes dsl.erl is in the current directory

产生一个 Broker

dsl 模块有个公共函数名为 broker(译注:股票经纪人)。

复制代码
broker() <span color="#0000ff">-></span>
receive
{buy, <span color="#800000">Quantity, Ticker</span>} <span color="#0000ff">-></span>
<span color="#808000">% 向外部系统下单的具体代码放在这里 <br></br> %</span>
<span color="#800000">Msg</span> = <span color="#800080">"Order placed: buying ~p shares of ~p"</span>,
io:format(<span color="#800000">Msg, [Quantity, Ticker</span>]),
broker();
{sell, Quantity, Ticker} <span color="#0000ff">-></span>
<span color="#808000">% 向外部系统下单的具体代码放在这里 <br></br> %</span>
<span color="#800000">Msg</span> = <span color="#800080">"Order placed: selling ~p shares of ~p"</span>,
io:format(<span color="#800000">Msg, [Quantity, Ticker]</span>),
broker()
end.

broker 函数等待消息并反复调用 receive 块。它只接收两类消息:卖出股票的消息和买入股票的消息。由于这里只是做一个演示,所以具体的下单操作就省略了。

请注意 broker 是尾递归的。在命令型(imperative)编程语言里,一般会用循环来达到相同目的。而在 Erlang 中不需要使用循环,因为尾递归函数会被自动优化在固定的空间中运行。Erlang 开发者不需要承担手工管理内存的责职,可以远离“for”、“while”、“do”这些关键字。这三个关键字之于栈,就像是“malloc”和“dealloc”之于堆……多余。

给仿真器的第二行命令是产生一个匿名函数的 Erlang 进程,并且返回进程 ID。进程 ID 的值绑定到变量 Pid。

复制代码
2> Pid = spawn(fun() -> dsl:broker() end). % call broker in parallel

在 Erlang 里,匿名函数以 “fun” 关键字起头,“end” 关键字结尾。看到这些字眼的时候请留心,因为本文中将出现非常多的匿名函数。上面的这个匿名函数很简单,它仅仅包装了一下 broker。

我们不打算深入讲解 Erlang 的众多有趣特性。暂且请将上述代码看作是产生了一条单独的执行路径,下文将称之为 broker 进程。内建的 spawn 函数返回了一个进程 ID,我们将通过这个进程 ID 向该进程发出买卖单。

加载业务规则的第一种途径

接下来我们调用 load_biz_rules,它是 dsl 模块的另一个公开函数。

复制代码
3> Functions = dsl:load_biz_rules(Pid, "biz_rules.txt").

传给 load_biz_rules 的参数是 broker 进程的 ID 和业务规则所在的文件;返回的是一个 Erlang 函数列表。本文将出现许多返回函数的函数,不熟悉函数式编程的人可能觉得理解起来有些障碍。请联想一下在面向对象的世界里,一个对象创建另一个对象并通过方法返回对象,是很寻常的事情——甚至还有专门针对该情形的设计模式,比如抽象工厂模式。

load_biz_rules 所返回的列表中每一个函数元素,都代表着先前写入到 biz_rules.txt 的一条业务规则。在面向对象编程语言里,我们大概会用一组对象实例来给这些规则建模;而在 Erlang 里,我们用函数。

复制代码
load_biz_rules(<span color="#800000">Pid, File</span>) <span color="#0000ff">-></span>
{ok, <span color="#800000">Bin</span>} = file:read_file(<span color="#800000">File</span>),
Rules = string:tokens(erlang:binary_to_list(Bin), "\n"),
[rule_to_function(Pid, Rule) || Rule <- Rules].

load_biz_rules 函数首先将文件读入内存。文件的内容被分割放入一个字符串列表,然后绑定到名为 Rules 的变量。在 Erlang 中,函数的最后一行默认成为函数的返回值(和 Ruby 一样),并且总是以句号结尾。load_biz_rules 的最后一行执行了一次列表推导(list comprehension),构建并返回一个函数列表。

熟悉列表推导的读者会知道<span color="#800000">Rule >- Rules</span>部分是发生器(generator),而rule_to_function(<span color="#800000">Pid, Rule</span>)部分是表达式模板(expression template)。这是什么意思?它的意思是我们创建一个新的列表,然后用 Rules 列表里的元素经过变换之后填充到新列表。 rule_to_function 函数完成实际的变换工作。换句话说,最后一行的意思是“把 broker 进程的 ID 和 Rules 中的每一条 Rule 传递给 rule_to_function……然后把 rule_to_function 返回的函数列一个表给我”。

复制代码
rule_to_function(<span color="#800000">Pid, Rule</span>) <span color="#0000ff">-></span>
{ok, <span color="#800000">Scanned</span>, _} = erl_scan:string(<span color="#800000">Rule</span>),
[{<span color="#800000">_,_,Action},{_,_,Quantity},_,_|Tail] = Scanned,</span>
[{<span color="#800000">_,_,Ticker},_,_,_,{_,_,Operator},_,{_,_,Limit}] = Tail,</span>
to_function(<span color="#800000">Pid, Action, Quantity, Ticker, Operator, Limit</span>).

第一行代码将传给 rule_to_function 的 Rule 字符串扫描进一个元组(tuple)。接下来两行用模式匹配摘出实施规则所必需的数据。摘出来的值被绑定到 Quantity、Ticker(译注:股票代码)等变量。接着 broker 进程的 ID 和摘出的 5 个值被传给 to_function 函数。 to_function 将构建并返回代表着一条业务规则的函数。

我们将讨论 to_function 的两种实现,首先看一个比较偏向实用的版本。

复制代码
to_function(<span color="#800000">Pid, Action, Quantity, Ticker, Operator, Limit</span>) <span color="#0000ff">-></span>
fun(<span color="#800000">Ticker_, Price</span>) <span color="#0000ff">-></span>
if
<span color="#800000">Ticker</span> =:= <span color="#800000">Ticker_</span> andalso
( ( <span color="#800000">Price < Limit</span> andalso <span color="#800000">Operator</span> =:= less ) orelse
( <span color="#800000">Price > Limit</span> andalso <span color="#800000">Operator</span> =:= greater ) ) <span color="#0000ff">-></span>
<span color="#800000">Pid</span> ! {<span color="#800000">Action, Quantity, Ticker</span>}; % place an order
true <span color="#0000ff">-></span>
erlang:display(<span color="#800080">"no rule applied"</span>)
end
end.

这个 to_function 实现做了一件事——返回一个匿名函数。匿名函数的参数是两项市场数据:股票代码和价格。传给该函数的股票代码和价格,将与业务规则中指定的股票代码及价格相比较。如果匹配上了,就用发送操作符(即! 符号)向 broker 进程发送一则消息,告诉它下单。

加载业务规则的第二种途径

to_function 的第二种实现学院味比较浓一些。它构造一种抽象形式的 Erlang 表达式,并返回一个匿名函数,让它以后再动态地求解。

复制代码
to_function(Pid, Action, Quantity, Ticker, Operator, Limit) ->
Abstract = rule_to_abstract(Action, Quantity, Ticker, Operator, Limit),
fun(Ticker_, Price) ->
TickerBinding = erl_eval:add_binding('Ticker', Ticker_, erl_eval:new_bindings()),
PriceBindings = erl_eval:add_binding('Price', Price, TickerBinding),
Bindings = erl_eval:add_binding('Pid', Pid, PriceBindings),
erl_eval:exprs(Abstract, Bindings)
end.

函数的第一行将脏活都委托给了 rule_to_abstract 函数。你不应该花太多时间研究 rule_to_abstract,除非你是觉得 Perl 很对胃口的那种人。

复制代码
rule_to_abstract(Action, Quantity, Ticker, Operator, Limit) ->
Comparison = if Operator =:= greater -> '>'; true -> '<' end,
[{'if',1,
[{clause,1,[],
[[{op,1,
'andalso',
{op,1,'=:=',{atom,1,Ticker},{var,1,'Ticker'}},
{op,1,Comparison,{var,1,'Price'},{integer,1,Limit}}}]],
[{op,1,
'!',
{var,1,'Pid'},
{tuple,1,[{atom,1,Action},
{integer,1,Quantity},
{atom,1,Ticker}]}}]},
{clause,1,[],
[[{atom,1,true}]],
[{call,1,
{remote,1,{atom,1,erlang},{atom,1,display}},
[{string,1,"no rule applied"}]}]}]}].

rule_to_abstract 函数构造并返回一个抽象形式的 Erlang 控制结构。这个控制结构是由一系列具体限制条件组成的一个“if”语句。顺便一提,上面的抽象形式用了后缀运算符的写法,不同于一般 Erlang 语法的中缀写法。如果把它套用到单条规则上面,我们实际上是用编程的方式构建以下代码的抽象形式:

复制代码
if
Ticker =:= ‘GOOG’ andalso Price < 500 ->
Pid ! {sell, 9000, ‘GOOG’}; % place order with broker
true ->
erlang:display("no rule applied")
end

这个版本的 to_function 取得业务逻辑的抽象形式之后,会返回一个匿名函数(与前一个版本的 to_function 一样)。在匿名函数里,股票代码、价格条件、broker 进程 ID 这三个变量被从执行作用域中抓出来,经由内建函数 erl_eval:add_binding 动态绑定到构建出来的表达式中。最后由内建的 erl_eval:exprs 库函数执行构建好的表达式。

应用业务规则

现在我们加载了业务规则,也为每条规则构造好了 Erlang 函数,是时候把市场数据参数传给它们了。市场数据用一个元组列表来表示。每个元组代表一对股票代码和股价。

复制代码
4> MarketData = [{'GOOG', 498}, {'MSFT', 30}, {'AAPL', 158}].
5> dsl:apply_biz_rules(Functions, MarketData).

把函数形式的业务规则,以及市场数据交给 apply_biz_rules 函数。

复制代码
apply_biz_rules(Functions, MarketData) ->
lists:map(fun({Ticker,Price}) ->
lists:map(fun(Function) ->
Function(Ticker, Price)
end, Functions)
end, MarketData).

幸亏我们只有三条业务规则,因为 apply_biz_rules 的运行时是指数增长的。不熟悉 Erlang 语法或者不留心听讲的话,上面的算法读起来有点难懂。apply_biz_rules 函数将一个内部函数映射到市场数据中的每一对股票代码 / 股价。内部函数又将第二个内部函数映射到每一个业务规则函数。第二个内部函数再将股票代码和股价传递给业务规则函数!

执行 apply_biz_rules,broker 进程确认它收到买入 9000 股 Google 和 7000 股 Apple 的指令。

复制代码
5> dsl:apply_biz_rules(Functions, MarketData).
Order placed: buying 9000 shares of 'GOOG'
Order placed: buying 7000 shares of 'AAPL'

执行结果中没有买入或卖出 Microsoft 的股票。回头检查一下业务规则,对比一下市场数据可以确定程序的行为是正确的。

复制代码
buy 9000 shares of GOOG when price is less than 500
sell 400 shares of MSFT when price is greater than 30
buy 7000 shares of AAPL when price is less than 160
4> MarketData = [{'GOOG', 498}, {'MSFT', 30}, {'AAPL', 158}].

如果 Google 的股价涨了 7 块,同时我们改变了卖出 Microsoft 的条件,那么将会观察到不同的结果。

复制代码
sell 400 shares of MSFT when price is greater than 27
6> UpdatedFunctions = dsl:load_biz_rules(Pid, "new_biz_rules.txt").
7> UpdatedMarketData = [{'GOOG', 505}, {'MSFT', 30}, {'AAPL', 158}].
8> dsl:apply_biz_rules(UpdatedFunctions, UpdatedMarketData).
Order placed: selling 400 shares of 'MSFT'
Order placed: buying 7000 shares of 'AAPL'

结论

再重申一下我在文章开头所说的观点——Erlang 是构建 DSL 的极佳平台。其优点远远不止匿名函数、正则表达式支持、模式匹配这几样。Erlang 还允许我们编程访问经过词法分析(tokenized)、语法分析之后、抽象形式的表达式。Debasish Ghosh 写的《 string lambdas in Erlang 》是另一个很好的例证。我希望本文能帮助一些读者走出自己熟悉的安乐窝,了解一点新的语法和编程范型。我还希望人们在给 Erlang 贴上专家语言的标签之前能够三思。

关于作者

Dennis Byrne 就职于 ThoughtWorks ,一家全球性的咨询公司,专注于关键系统的全程敏捷软件开发。Dennis 是开源社区的活跃分子,他于 2008 年 6 月为 Erlang eXchange 作了题为“Using Jinterface to bridge Erlang and Java”的演讲。

查看英文原文: Domain Specific Languages in Erlang


志愿参与 InfoQ 中文站内容建设,请邮件至 editors@cn.infoq.com 。也欢迎大家到 InfoQ 中文站用户讨论组参与我们的线上讨论。

2008 年 11 月 25 日 02:523568
用户头像

发布了 225 篇内容, 共 46.6 次阅读, 收获喜欢 24 次。

关注

评论

发布
暂无评论
发现更多内容

LeetCode题解:155. 最小栈,单个栈+对象存储,JavaScript,详细注释

Lee Chen

LeetCode 前端进阶训练营

linux入门系列17--邮件系统之Postfix和Dovecot

黑马腾云

Linux centos Dovecot Postfix 邮件系统

视频AI第一步-动作识别数据集

flow

如何开成功一个回顾会

技术管理Jo

敏捷教练 回顾会 引导者

linux入门系列16--文件共享之Samba和NFS

黑马腾云

Linux centos linux运维 Samba NFS

第 0 期架构师训练营第5 周作业1

傅晶

初识Druid——实时OLAP系统

justskinny

大数据处理 大数据技术 Apache Druid

安全系列之——数据传输的完整性、私密性、源认证、不可否认性

诸葛小猿

加密解密 rsa 签名验签 数字证书 CA

区块链技术服务于税收治理的深圳实践

CECBC区块链专委会

区块链 电子发票 税收

Luajit字节码解析之KNUM

whosemario

lua

当“基本功”数据结构与算法被图形分解,要还不会就真的没办法了

周老师

Java 编程 程序员 架构 面试

linux入门系列20--Web服务之LNMP架构实战

黑马腾云

php MySQL Linux centos ngnix

求索十五载:百度地图绘就的时代浪漫

脑极体

第 0 期架构师训练营第 6 周作业2-总结

傅晶

增量了两个私有网络之后的对比

孙朝辉🐢

linux入门系列18--Web服务之Apache服务1

黑马腾云

Linux centos apche linux运营 centos网站部署

【Elasticsearch 技术分享】—— 十张图带大家看懂 ES 原理 !明白为什么说:ES 是准实时的!

程序员小航

Java elasticsearch 搜索 ES Lucene Elastic Search

揭开数组的真面目

Java旅途

Java 数据结构 数组

第 0 期架构师训练营第 5 周作业 2-总结

傅晶

避免栽坑之掌握Jenkins工作原理

清菡

jenkins

Python作业留底--《菜鸟教程》Python 练习和习题

Geek_f6bfca

并发杂谈系列0 序与目录

八苦-瞿昙

随笔杂谈

年薪80万难觅技术人才 杭州区块链人才需求旺盛

CECBC区块链专委会

区块链 新基建 大学专业

第 0 期架构师训练营第 6 周作业1

傅晶

区块链技术发展面临七大关键挑战以及未来的五大展望

CECBC区块链专委会

区块链 新基建 数字型资产

800页PPT搞懂阿里技术及生态全貌,“未入阿里,知根知底”

周老师

Java 编程 程序员 架构 面试

linux入门系列18--Web服务之Apache服务2

黑马腾云

Apache Linux centos linux运维

linux入门系列19--数据库管理系统(DBMS)之MariaDB

黑马腾云

MySQL Linux centos linux运维 MariaDB

菜市场和房屋中介

escray

学习 面试 面试现场

Mysql探索之索引详解

不才陈某

MySQL

一篇文章搞懂前端学习方法与构建知识体系

三钻

学习 前端

InfoQ 极客传媒开发者生态共创计划线上发布会

InfoQ 极客传媒开发者生态共创计划线上发布会

用Erlang实现领域特定语言-InfoQ