VoIP通信,与传统的电话技术相比,不仅仅在于绝对的资费优势,更重要的是很容易地通过开发相应的软件,使其与企业的业务逻辑紧密集成。Asterisk作为开源VoIP软件的代表,以其强大的功能及相对低廉的建设成本,受到了全世界开发者的青睐。而FreeSWITCH作为VoIP领域的新秀,在性能、稳定性及可伸缩性等方面则更胜一筹。本文原文在http://www.freeswitch.org/node/117, 发表于2008年4月,相对日新月异的技术来讲,似乎有点过时。但本文作为FreeSWITCH背后的故事,仍很有翻译的必要。因此,本人不揣鄙陋,希望与大家共读此文,请不吝批评指正。 --译者注
FreeSWITCH 与 Asterisk 两者有何不同?为什么又重新开发一个新的应用程序呢?最近,我听到很多这样的疑问。 为此,我想对所有在该问题上有疑问的电话专家和爱好者们解释一下。我曾有大约三年的时间用在开发 Asterisk 上,并最终成为了 FreeSWITCH 的作者。因此,我对两者都有相当丰富的经验。首先,我想先讲一点历史以及我在 Asterisk 上的经验;然后,再来解释我开发FreeSWITCH的动机以及我是如何以另一种方式实现的。
我从2003年开始接触 Asterisk,当时它还不到1.0版。那时对我来讲,VoIP还是很新的东西。我下载并安装了它,几分钟后,从插在我电脑后面的电话机里传出了电话拨号音,这令我非常兴奋。接下来,我花了几天的时间研究拨号计划,绞尽脑汁的想能否能在连接到我的Linux PC上的电话上实现一些好玩的东西。由于做过许多Web开发,因此我积累了好多新鲜的点子,比如说根据来电显示号码与客户电话号码的对应关系来猜想他们为什么事情打电话等。我也想根据模式匹配来做我的拨号计划,并着手编写我的第一个模块。最初,我做的第一个模块是app_perl,现在叫做res_perl,当时曾用它在Asterisk中嵌入了一个Perl5的解释器。现在我已经把它从我的系统中去掉了。
后来我开始开发一个Asterisk驱动的系统架构,用于管理我们的呼入电话队列。我用app_queue和现在叫做AMI(大写字母总是看起来比较酷)的管理接口开发了一个原型。它确实非常强大。你可以从一个T1线路的PSTN号码呼入,并进入一个呼叫队列,坐席代表也呼入该队列,从而可以对客户进行服务。非常酷!我一边想一边看着我的可爱的Web页显示着所有的队列以及他们的登录情况。并且它还能周期性的自动刷新。令人奇怪的是,有一次我浏览器一角上的小图标在过了好长时间后仍在旋转。那是我第一次听说一个词,一个令我永远无法忘记的词 -- 死锁。
那是第一次,但决不是最后一次。那一天,我几乎学到了所有关于GNU调试器的东西,而那只是许多问题的开始。队列程序的死锁,管理器的死锁。控制台的死锁开始还比较少,后来却成了一个永无休止的过程。现在,我非常熟悉“段错误(Segmentation Fault)”这个词,它真是一个计算机开发者的玩笑。经过一年的辛勤排错,我发现我已出乎意料的非常精通C语言并且有绝地战士般的调试技巧。我有了一个分布于七台服务器、运行于DS3 TDM信道的服务平台。与此同时,我也为这一项目贡献了大量的代码,其中有好多是我具有明确版权的完整文件(http://www.cluecon.com/anthm.html)。
到了2005年,我已经俨然成了非常有名的Asterisk开发者。他们甚至在CREDITS文件以及《Asterisk,电话未来之路》这本书中感谢我。在Asterisk代码树中我不仅有大量的程序,而且还有一些他们不需要或者不想要的代码,我把它们收集到了我的网站上。(至今仍在 http://www.freeswitch.org/node/50)
Asterisk 使用模块化的设计方式。一个中央核心调入称为模块的共享目标文件以扩展功能。模块用于实现特定的协议(如SIP)、程序(如个性化的IVR)和其它外部接口(如管理接口)等。 Asterisk的核心是多线程的,但它非常保守。仅仅用于初始化的信道以及执行一个程序的信道才有线程。任何呼叫的B端都与A端都处于同一线程。当某些事件发生时(如一次转移呼叫必须首先转移到一个称作伪信道的线程模式),该操作把一个信道所有内部数据从一个动态内存对象中分离出来,放入另一个信道中。它的实现在代码注释中被注明是“肮脏的”[1]。反向操作也是如此,当销毁一个信道时,需要先克隆一个新信道,才能挂断原信道。同时也需要修改CDR的结构以避免将它视为一个新的呼叫。因此,对于一个呼叫,在呼叫转移时经常会看到3或4个信道同时存在。
这种操作成了从另一个线程中取出一个信道事实上的方法,同时它也正是开发者许许多多头痛的源头。这种不确定的线程模式是我决定着手重写这一应用程序的原因之一。
Asterisk使用线性链表管理活动的信道。链表通过一种结构体将一系列动态内存串在一起,这种结构体本身就是链表中的一个成员,并有一个指针指向它自己,以使它能链接无限的对象并能随时访问它们。这确实是一项非常有用的编程技术,但是,在多线程应用中它非常难于管理。在线程中必须使用一个信号量(互斥体,一种类似交通灯的东西)来确保在同一时刻只有一个线程可以对链表进行写操作,否则当一个线程遍历链表时,另一个线程可能会将元素移出。甚至还有比这更严重的问题 ─ 当一个线程正在销毁或监听一个信道的同时,若有另外一个线程访问该链表时,会出现“段错误”。“段错误”在程序里是一种非常严重的错误,它会造成进程立即终止,这就意味着在绝大多数情况下会中断所有通话。我们所有人都看到过“防止初始死锁”[2]这样一个不太为人所知的信息,它试图锁定一个信道,在10次不成功之后,就会继续往下执行。
管理接口(或AMI)有一个概念,它将用于连接客户端的套接字(socket)传给程序,从而使你的模块可以直接访问它。或者说,更重要的是你可以写入任何你想写入的东西,只要你所写入的东西符合Manager Events所规定的格式(协议)。但遗憾的是,这种格式没有很好的结构,因而很难解析。
Asterisk的核心与某些模块有密切的联系。由于核心使用了一些模块中的二进制代码,当它所依赖的某个模块出现问题,Asterisk就根本无法启动。如果你想打一个电话,至少在 Asterisk 1.2中,除使用app_dial和res_features外你别无选择,这是因为建立一个呼叫的代码和逻辑实际上是在app_dial中,而不是在核心里。同时,桥接语音的顶层函数实际上包含在res_features中。
Asterisk的API没有保护,大多数的函数和数据结构都是公有的,极易导致误用或被绕过。其核心非常混乱,它假设每个信道都必须有一个文件描述符,尽管实际上某些情况下并不需要。许多看起来是一模一样的操作,却使用不同的算法和杰然不同的方式来实现,这种重复在代码中随处可见。
这仅仅是我在Asterisk中遇到的最多的问题一个简要的概括。作为一个程序员,我贡献了大量的时间,并贡献了我的服务器来作为CVS代码仓库和Bug跟踪管理服务器。我曾负责组织每周电话会议来计划下一步的发展,并试图解决我在上面提到过的问题。问题是,当你对着长长的问题列表,思考着需要花多少时间和精力来删除或重写多少代码时,解决这些问题的动力就渐渐的没有了。值得一提的是,没有几个人同意我的提议并愿意同我一道做一个2.0的分支来重写这些代码。所以在2005年夏天我决定自己来。
在开始写FreeSWITCH时,我主要专注于一个核心系统,它包含所有的通用函数,即受到保护又能提供给高层的应用。像Asterisk一样,我从Apache Web服务器上得到很多启发,并选择了一种模块化的设计。第一天,我做的最基本的工作就是让每一个信道有自己的线程,而不管它要做什么。该线程会通过一个状态机与核心交互。这种设计能保证每一个信道都有同样的、可预测的路径和状态钩子,同时可以通过覆盖向系统增加重要的功能。这一点也类似其它面向对象的语言中的类继承。
做到这点其实不容易,容我慢慢讲。在开发FreeSWITCH的过程中我也遇到了段错误和死锁(在前面遇到的多,后来就少了)。但是,我从核心开始做起,并从中走了出来。由于所有信道都有它们自己的线程,有时候你需要与它们进行交互。我通过使用一个读、写锁,使得可以从一个散列表(哈希)中查找信道而不必遍历一个线性链表,并且能绝对保证当一个外部线程引用到它时,一个信道无法被访问也不能消失。这就保证了它的稳定,也不需要像Asterisk中“Channel Masquerades”之类的东西了。
FreeSWITCH核心提供的的大多数函数和对象都是有保护的,这通过强制它们按照设计的方式运行来实现。任何可扩展的或者由一个模块来提供方法或函数都有一个特定的接口,从而避免了核心对模块的依赖性。
整个系统采用清晰分层的结构,最核心的函数在最底层,其它函数分布在各层并随着层数和功能的增加而逐渐减少。
例如,我们可以写一个大的函数,打开一个任意格式的声音文件向一个信道中播放声音。而其上层的API只需用一个简单的函数向一个信道中播放文件,这样就可以将其作为一个精减的应用接口函数扩展到拨号计划模块。因此,你可以从你的拨号计划中,也可以在你个性化的C程序中执行同样的playback函数,甚至你也可以自己写一个模块,手工打开文件,并使用模块的文件格式类服务而无需关注它的代码。
FreeSWITCH由几个模块接口组成,列表如下:
拨号计划(Dialplan):实现呼叫状态,获取呼叫数据并进行路由。
终点(Endpoint):为不同协议实现的接口,如SIP,TDM等。
自动语音识别/文本语音转换(ASR/TTS):语音识别及合成。
目录服务(Directory): LDAP类型的数据库查询。
事件(Events):模块可以触发核心事件,也可以注册自己的个性事件。这些事件可以在以后由事件消费者解析。
事件句柄(Event handlers):远程访问事件和CDR。
格式(Formats):文件模式如wav。
日志(Loggers):控制台或文件日志。
语言(Languages):嵌入式语言,如Python和JavaScript。
语音(Say):从声音文件中组织话语的特定的语言模块。
计时器(Timers):可靠的计时器,用于间隔计时。
应用(Applications):可以在一次呼叫中执行的程序,如语音信箱(Voicemail)。
FSAPI(FreeSWITCH 应用程序接口)命令行程序,XML RPC函数,CGI类型的函数,带输入输出原型的拨号计划函数变量。
XML 到核心XML的钩子可用于实时地查询和创建基于XML的CDR。
所有的FreeSWITCH模块都协同工作并仅仅通过核心API或内部事件相互通信。我们非常小心地实现它以保证它能正常工作,并避免其它外部模块引起不期望的问题。
FreeSWITCH的事件系统用于记录尽可能多的信息。在设计时,我假设大多数的用户会通过一个个性化的模块远程接入FreeSWITCH来收集数据。所以,在FreeSWITCH中发生的每一个重要事情都会触发一个事件。事件的格式非常类似于一个电子邮件,它具有一个事件头和一个事件主体。事件可被序列化为一个标准的Text格式或XML格式。任何数量的模块均可以连接到事件系统上接收在线状态,呼叫状态及失败等事件。事件树内部的mod_event_socket可提供一个TCP连接,事件可以通过它被消费或记入日志。另外,还可以通过此接口发送呼叫控制命令及双向的音频流。该套接字可以通过一个正在进行的呼叫进行向外连接(Outbound)或从一个远程机器进行向内(Inbound)连接。
FreeSWITCH中另一个重要的概念是中心化的XML注册表。当FreeSWITCH装载时,它打开一个最高层的XML文件,并将其送入一个预处理器。预处理器可以解析特殊的指令来包含其它小的XML文件以及设置全局变量等。在此处设置的全局变量可以在后续的配置文件中引用。
如,你可以这样用预处理指令设置全局变量:
<X-PRE-PROCESS cmd="set" data="moh_uri=local_stream://moh"/>
现在,在文件中的下一行开始你就可以使用 $$(moh_uri},它将在后续的输出中被替换为 local_stream://moh。处理完成后XML注册表将装入内存,以供其它模块及核心访问。它有以下几个重要部分:
配置文件:配置数据用于控制程序的行为。
拨号计划:一个拨号计划的XML表示可以用于 mod_dialplan_xml,用以路由呼叫和执行程序。
分词:可标记的IVR分词是一些可以“说”多种语言的宏。
目录:域及用户的集合,用于注册及账户管理。
通过使用XML钩子模块,你可以绑定你的模块来实时地查询XML注册表,收集必要的信息,以及返回到呼叫者的静态文件中。这样你可以像一个WEB浏览器和一个CGI程序一样,通过同一个模型来控制动态的SIP注册,动态语音邮件及动态配置集群。
通过使用嵌入式语言,如Javascript, Java, Python和Perl等,可以使用一个简单的高级接口来控制底层的应用。
FreeSWITCH工程的第一步是建立一个稳定的核心,在其上可以建立可扩展性的应用。我很高兴的告诉大家在2008年5月26日将完成FreeSWITCH 1.0 PHOENIX版。有两位敢吃螃蟹的人已经把还没到1.0版的FreeSWITCH 用于他们的生产系统。根据他们的使用情况来看,我们在同样的配置下能提供Asterisk 10倍的性能。
我希望这些解释能足够概括FreeSWICH和Asterisk的不同之处以及我为何决定开始FreeSWITCH项目。我将永远是一个Asterisk开发者,因为我已深深的投入进去。并且,我也希望他们在以后的Asterisk开发方面有新的突破。我甚至还收集了很多过去曾经以为已经丢失的代码,放到我个人的网站上供大家使用, 也算是作为我对引导我进入电话领域的这一工程的感激和美好祝愿吧。
Asterisk是一个开源的PBX,而FreeSWITCH则是一个开源的软交换机。与其它伟大的软件如 Call Weaver、Bayonne、sipX、OpenSER以及更多其它开源电话程序相比,两者还有很大发展空间。我每年都期望能参加在芝加哥召开的 ClueCon大会,并向其它开发者展示和交流这些项目(http://www.cluecon.com)。
我们所有人都可以相互激发和鼓励以推进电话系统的发展。你可以问的最重要的问题是:“它是完成该功能的最合适的工具吗?”