我对Erlang的兴趣是从去年就已经开始了。但是在断断续续的学习过程中,一个问题一直困扰着我:Erlang究竟能干什么。就像我在6月的一篇blog里所写的:
什么时候才需要这样“rocks”的进程管理能力?什么时候才需要如此清晰的进程建模?当然高性能服务器需要。但高性能服务器不是谁都会拿着玩玩的。Ruby on Rails会走红,因为谁都可以拿它做个网站然后做创业梦。那么Erlang呢?它带来的联想是什么?
有必要向对Erlang感到陌生的读者做一点背景介绍。作为爱立信在多年前发明的编程语言,Erlang一直主要被应用在电信领域,并且因为它强大的并行程序设计能力而为人称道。但在很长的时间里,Erlang一直被视为电信行业的专用工具(它的基础库名叫OTP,那是Open Telecom Platform的缩写,由此可见Erlang与电信领域的渊源)。直到前两年,像我这样从事企业应用开发的程序员还是只能通过一篇篇零散而枯燥的论文来了解Erlang。但随着多核时代的到来,并行计算突然就成了热门话题,Erlang于是逐渐步入了大众的视野。先是一些函数式编程的爱好者越来越频繁地在各种场合介绍Erlang,然后以“实用主义”著称的Pragmatic Programmers出版了《Programming Erlang》一书。今年这阵风潮也影响到了国内,Erlang中文社区的成立和两届CN Erlounge的召开让我们越来越清晰地感觉到这种被昵称为“二郎”的编程语言发出的热度。我之所以投入时间来学习Erlang,主要就是想知道企业软件开发的社区为何对它产生了如此浓厚的兴趣。
尽管买了那本《Programming Erlang》,但因为找不到一个适当的项目来练习,学Erlang的进展还是不大。在随后的几个月里,我一直努力保持着对Erlang社区的关注度。我尝试了ErlyWeb,也想过在Ruby或者Python之类的脚本语言里借鉴Erlang的好处,还在Erlang文档计划翻译了两篇论文。直到最近,偶然在一个讨论中听说Twitter为提高性能而用到了Erlang实现的消息中间件,于是就萌发出动手做一个消息中间件的念头。把几种主要的协议看过一遍之后,我把目标锁定在Stomp上,原因就是它足够简单。正如Stomp网站上介绍的:
Stomp是一个非常简单而容易实现的协议……服务器端要做得好可能还不太容易,但要做一个客户端是非常轻松的,你甚至可以用Telnet登录到Stomp broker并与其交互!
目标确定了,然后就要一步步向前走。我搜索到了erlstomp这个项目,但其中的参与者们讨论了好几个月却还没开始写一行代码。不过Kurt的一个计划给我指出了方向。和别的通信协议一样,Stomp无非是规定了客户端和服务器之间对话的语义。(下图描绘了一个经过简化的、典型的Stomp通信序列。)先让双方能对话,然后逐渐完善语义,这就是我要做的两件事。
又经过半天的搜索,我发现了一篇题为“采用OTP原则构造非阻塞的TCP服务器”的文章和另一篇日语文章,它们都描述了如何搭建一个基本的TCP服务器架构,并实现了一个最简单的通信协议:echo(客户端发送什么,服务器端就原样发回什么)。抄袭了这个基础,剩下的事情就相对清晰了,无非是一点点把我想要的协议实现出来。同时我还加上了EUnit单元测试,用Gozirra(一个Java实现的Stomp客户端)创建了验收测试。一个星期业余时间的忙碌之后,这个broker已经可以跟验收测试的客户端通畅对话了。于是我给它起了个名字叫“Stomperl”(看得出来这是个丝毫不费脑子的名字),发布了0.0.1版本。可以看到,此时Stomperl的架构基本上还是第一天抄袭来的那个样子:
+----------------+
| tcp_server_sup |
+--------+-------+
| (one_for_one)
+----------------+---------+
| |
+-------+------+ +-------+--------+
| tcp_acceptor | + tcp_client_sup |
+--------------+ +-------+--------+
| (simple_one_for_one)
+-----|---------+
+-------|--------+|
+--------+-------+|+
| tcp_stomp |+
+--------+-------+
| (one for each)
+-------+--------+
| mailer |
+-------+--------+
实现Stomp协议的过程轻松得出人意料。一旦习惯了用Erlang的方式来看待手中的多个进程,一些在Java或者Ruby里会相当棘手的并发编程问题就成了小菜一碟,我能够像操作对象一样轻松地操作多个进程,而且还能够清晰地描述它们(前面的架构图中所有的矩形框都是进程)。而且OTP的supervisor机制实在是一大惊喜:负责处理的tcp_stomp
进程如果发生任何异常而崩溃,只会导致与其对应的一个客户端通信失败,作为supervisor的tcp_client_sup
进程会帮它处理好一切后事,不会让影响波及整个服务器。让我最费脑筋的反而是另一个看似简单的问题:信息放在什么地方最合理。在一篇blog里我把保存信息的方式分为参数传递、进程字典、ETS、DETS和Mnesia五种,并在最后总结道:
应该首先考虑靠前的手段,如果有明确的理由表明一种手段不能满足需要时才可以考虑比较靠后的手段。这很费脑子,有时让人沮丧。但经过深思熟虑的程序好过不假思索的程序,发现自己犯错好过犯错而不自知。
在Stomperl里,每个tcp_stomp进程(以及与之对应的mailer进程)负责处理一个客户端的socket连接。在这些进程之间有两项信息需要共享:
一开始我曾经考虑用DETS来保存订阅信息,但经过认真考虑之后发现,在这种情况下使用DETS会造成较大的开销(每次消息传递都需要读写文件),而目前Stomperl并不支持多节点分布式的部署,因此DETS不带来额外的好处。于是我转而使用了ETS:在整个系统的“顶端”(即tcp_server_sup进程启动时)创建一个ETS表,逐次传递给所有子进程,在这张表里存放所有需要在进程间共享的信息。做这个重构花了一些时间,因为几乎所有的函数都需要加上一个参数来传递ETS表。但重构完成以后,“消息队列如何实现”的问题也迎刃而解了:直接用这个ETS表来实现消息队列,所有进程都能到其中查找自己需要的消息。
刚开始做Stomperl的时候我对消息中间件几乎一无所知,“消息发送失败时该做什么”这个问题曾经让我困扰了两三天:如果一条消息没有人接收,它应该被保存起来吗?如果一个订阅者接收成功而另一个接收失败,还应该重发吗?直到与Stomp协议的维护者们进行一次讨论之后,才澄清了我的疑惑:
你究竟是采用“消息队列”模式还是“发布/订阅”模式?或者说,消息的目标是队列(queue)还是主题(topic)?如果是队列,那么broker只把消息发给第一个合适的订阅者,一旦成功就不会尝试发给第二个订阅者。队列里的消息只发送一次、给一个订阅者。如果是主题,那么broker会把消息发布给两个订阅者,如果其中之一失败了,它也不会尝试重发,因为按照定义主题就只是把消息发布给所有订阅者,不管这些订阅者是否真的使用(甚至收到)这些消息。
我在0.0.1版本中的实现方式实际上是一个“发布/订阅”模式。经过一番研究以后我发现大多数消息中间件至少支持消息队列,其中一些同时也支持“发布/订阅”模式,于是我又花了一些时间来实现消息队列。还好,得益于Erlang强大的进程建模能力,这一步的难度并不大。
除了可以用来练习Erlang编程之外,Stomperl还有什么真实的用途呢?实际上这个问题主要归结为“Stomp有什么真实的用途”。Stomp的主要优势还是简单。由于简单,互联网应用、甚至是某些特殊的桌面应用都可以考虑在其中引入这样一个消息总线,从而给应用的各个部分解耦。譬如说Selenium或者Eft之类的功能测试工具都可能把TestRunner和TestEngine两部分拆开,后者把用户的输入(可能是某种DSL)发送给前者去执行,这时就可以考虑引入一个Stomp broker来给两部分牵线搭桥。而且借助StompConnect可以把任何JMS broker包装成Stomp broker,这也就使得编写Stomp客户端程序的工作不大可能被浪费——因为几乎所有重要的消息中间件都支持JMS。
那么Stomperl呢?正如我在项目主页上写的,既然用了Erlang,那么高效率、可伸缩性、可靠性和并行程序设计的优雅都是题中应有之义。截至今天,我已经准备好发布0.0.2版本,这个版本同时支持消息队列和“发布/订阅”两种消息模式,并且完整支持了Stomp协议中描述的所有消息格式,而代码规模还不到1000行。随后我还会为它做更多的兼容性测试和性能测试,并努力为它找到一个实际应用的场景。如果你恰好有一个项目需要一个简单的消息中间件,不妨考虑一下Stomperl,或许它也会给你一个惊喜。