一、客户端技术的考虑
导言
说到实时互动,我们直接能够想到的是聊天室或者Web IM,虽然因为xxx的原因,国内大多数文字聊天室都已经关闭,而视频聊天室,大多都是一帮xxx的美女在那边跳舞,越来越少的人在关注这个领域。
可是企业应用的角度恰恰相反,Web客服、内部IM、实时教学等业务的兴起,也会为这个领域的繁荣注入了强心剂,对于交互日益复杂的今日,我们又将以怎样的视角来设计我们的架构,又应该从哪些技术来考虑,更加应该考虑如何平衡技术与业务的关系,这是我撰写本文的初衷。
言归正传,还是从最简单的地方入手,我们来写一个聊天室,简单无比的聊天室。如果您从事过多年的Web开发,相信对此不会陌生。从最早的CGI到asp,再到现在的百花齐放,跟随着互联网这十年的发展,对于实时互动也经历了多个阶段。
抛开早期的CGI编写聊天室(大多是使用Perl,在98、99年左右),因为对于我们大多人来说已经有些陌生。我们就说用asp吧,编写简单的聊天室几乎是web编程的一门必修课,实现原理简单的出奇:
1. 建立一个frameset的html页面,分为三栏,主要区域显示聊天内容,右边为用户列表,下方为发言区,如下图所示:
2. 在线列表和聊天内容的页面在Header部分分别加入下面这样的Meta
<meta http-equiv="refresh" content="2;url=http://www.yoursite.com/newpage.htm" />
3. 将所有用户发言和用户列表存储在Application变量中,通过定期刷新获取用户聊天内容和在线用户列表
4. 在此基础上扩展私聊、表情和管理的功能
5. ……
用这样的篇幅来形容一个聊天室的基本原理,我想已经足够了,当中几乎不用JavaScript,更加别说Ajax了,虽然xmlhttp被微软早在IE5 Beta的时候就引入,但是并没有得到太多人的关注,要实现所谓的“无刷新”,大抵还是通过隐藏Iframe的方式来实现的,在那个年代,在聊天室听到页面刷新发出的声音也不足为怪。
为了更好的用户体验,采用CGI编写聊天室的高手们则是使用了Server Push技术,也就是显示聊天内容的页面和服务器始终保持连接,一旦有用户新的发言,立即被推送到浏览器,也就做到了基本上的实时。
随着Ajax的兴起,对于这样的应用,我们拥有了更多的方案选择空间,通过XMLHTTP队服务器发起一个请求,然后将获取的数据通过JavaScript的方式渲染到浏览器,再也没有烦人的刷新声音,又不需要用到CGI这样的高深技术,相关的应用的繁荣也就情理之中了。
通过Ajax后台请求服务器,然后通过设置一定的Interval,这是当前大多实时互动业务的基本实现,对于大多业务而言,也能够满足要求了。但是如果面对更加严苛的场景呢,我们的技术是通过刷新来实现模拟“实时”,但是真正面临要求实时的场景,如工业控制的监控、实时演示、实时报价、体育直播等,这是我们将会面临这样的问题:
l 对于状态的刷新频率,我们是通过setInterval或者setTimeout来实现的,从某种程度来说,我们的时间设置频率就是实时的延迟时间
l 如果设置为2秒,那么意味着我们存在两秒的延迟,我们可以把这个时间调整到500毫秒,理论上能够更加接近实际业务的要求,但与此同时也带来另外一个问题
l 频繁的刷新会导致服务器资源的浪费和无谓的网络带宽
l 在小规模用户的场景下,这些资源的空耗可以忽略不计,但是在大规模用户(如超过2万在线用户),服务器资源和网络带宽就变成不可忽视的课题
谈到这里,我们也清楚了设计一个大规模实时互动系统需要的一些关键目标:
1. 从客户端来说保证“实时”,也就是需要服务器和浏览器建立起实时的通信,在服务端数据变化的时候,能够第一时间推送到客户端,也就是说我们需要一个“服务器推”的技术。
2. 提高单一服务器的处理能力,通过一定的技术保证不会因为“实时”的要求而导致服务器效率的低下。
3. 设计一个可伸缩的服务器网络架构,以保证在单一机器性能到达峰值的情况下,可以通过增加服务器来实现服务能力的提升,而非只能够通过提高硬件配置
4. 让其“实时”,还要让其“互动”,也就是不同角色的业务人员能够通过这个系统有效地互动
首先我们来考虑第一个问题应该如何解决,之前提到了IFrame刷新,Ajax后台请求和Http长连接等技术,所有的目的只有一个:让整个应用看起来更加像“服务器推”送数据给浏览器。IBM的工程师周婷撰写了《Comet:基于 HTTP 长连接的“服务器推”技术》,相对详细地介绍了不同“服务器推”技术,我不是去重复介绍她提到的内容,只是在她的思路基础上更加深入地讨论一下不同技术方案的实现细节:
基于客户端的“服务器推”技术
这样的技术实现的原理很简单,由浏览器所在的客户端对服务器发起一个长连接,然后由这个双工通信的连接来实现服务器和客户端浏览器的通讯。我们知道浏览器只能够发起HTTP请求,同时还有相对严格的安全沙箱机制,并不是我们能够通过JavaScript脚本能够做到随意地与远程端口建立连接。这是我们需要通过浏览器插件来越过这些安全限制,同时又保证插件能够与浏览器进行无缝继承,也才能够达到我们将数据呈现在浏览器的目的。
ActiveX
这是过去最流行的技术,在浏览器嵌入一个我们自己用VC++或者Delphi编写的ActiveX控件,然后由这个控件负责和远程服务的通讯。你可以和桌面服务程序一样去编写您的ActiveX控件,从这个角度来说是无所不能的,但是由于对Windows底层需要比较深入的了解,让许多开发人员望而怯步,与此同时,我们需要将这个ActiveX空间显式地安装到客户端桌面,而浏览器对于ActiveX安装的严格限制也导致部署的困难。
在过去没有更好替代技术的时候,这样的技术还是得到了广泛的应用,如视频聊天和语音聊天,早期的几乎都是以ActiveX为主的。
Java Applet
在J2EE没有兴起之前,用Java来编写Applet几乎是许多人认为Java的唯一用途,不过相对于ActiveX的门槛来说,还是相对比较低,也就是在2002年以前有些公司选择在客户端用Java Appet来构建他们的互动应用,Sohu,Chinren早期的聊天室大多是基于这个技术的。到如今,似乎被渐渐遗忘了。
Flash XMLSocket
几乎所有的浏览器都安装了Flash播放器,同时Flash和JavaScript与生俱来的高度交互性,你甚至都可以把Flash当着浏览器的标准配置,如果您只是想做一些Web应用,并不熟悉Flash编程,可以选择JavaScript<->XmlSocket的Bridge类库,我推荐使用SocketJS、jssockets和SocketBridge。如果您觉得不能满足您的需求,自行编写一个Bridge也不是不可以,从技术上来说也很简单:
l 如果你你希望Flash的函数能够通过JavaScript调用它,可以通过ExternalInterface.addCallback()来实现
l 如果你希望调用外部的JavaScript函数,可以通过ExternalInterface.call()来实现
下面的代码展示了一个基本的XmlSocket Bridge实现,是使用ActionScript编写的,您如果有兴趣,可以使用MATAS(http://www.matas.org)来编译它,如果安装了Flex SDK,也可以通过它来编译,置于开发ActionScript,如果你不想用Flash IDE,也没有安装Flex Builder,那么可以选择Flash Develop。
import flash.external.ExternalInterface;
class Comet
{
static var socket : XMLSocket;
static function connect(host:String, port:Number)
{
System.security.loadPolicyFile('xmlsocket://' + host + ':' + port);
socket = new XMLSocket();
socket.onData = onData;
socket.onConnect = onConnect;
socket.onClose = onDisconnect;
socket.connect(host, port);
}
static function disconnect()
{
socket.close();
}
static function onConnect(success:Boolean)
{
if(success)
ExternalInterface.call("comet.connected");
else
ExternalInterface.call("comet.errorConnecting");
}
static function send(data:String)
{
socket.send(unescape(data));
}
static function onDisconnect()
{
ExternalInterface.call("comet.disconnected");
}
static function onData(data:String)
{
ExternalInterface.call("comet.receive", escape(data));
}
static function main()
{
ExternalInterface.addCallback("connect", null, connect);
ExternalInterface.addCallback("send", null, send);
ExternalInterface.addCallback("disconnect", null, disconnect);
ExternalInterface.call("comet.initialized");
}
}
从实际开发来看,使用Flash的XmlSocket来连接远程是比较简单的,唯一需要注意的问题是安全策略使用是否得当,在9.0.115.0之前,我们可以通过HTTP检查crossdomain.xml来设置安全策略,但是从这个版本之后,Adobe对其安全策略进行了修改,整体安全策略微有些复杂,详细情况如下:
1) 首先向目标主机 843 端口发起连接,并发送一个字符串,内容为 "<policy-file-request/>",并等待返回安全策略文件并分析。
2) 若 1) 失败,则检查 AS 代码中是否使用了 Security.loadPolicyFile( "xmlsocket://host:port" ) 方法加载安全策略文件,若有,则获取并分析。
3) 若 2) 失败,则向 AS 代码中即将连接的 "目标主机:端口" 发起请求,过程同 1)。
4) 若成功获得安全策略文件并经分析认为允许建立连接,则继续执行 Connect() 方法,此时方真正尝试创建与目标主机的连接。
如果一个主机上有多个XmlSocket服务,可以考虑统一编写一个PolicyServer监听843端口,如果只是一个独立的应用,也可以考虑在逻辑里直接提供策略文件。
SilverLight
这个号称Flash Killer的浏览器插件是微软公司推出的,号称全球有1/4的浏览器已经安装了SilverLight插件,你可以用熟悉的C#或者其他语言来编写你的处理逻辑,同时和JavaSript也实现了无缝的集成,唯一要考虑的还是安全策略。
如果是WebClient或HTTP请求,可以使用和Flash同样的安全策略,也就是检查目标服务器上的crossdomain.xml文件,不过首先还是按照silverlight自己的安全策略来检查目标服务器上的clientaccesspolicy.xml文件,出现错误之后方才检查flash策略文件。
如果是Socket连接(跨越域或源连接),则首先检查目标服务器的943端口,通过它获得安全策略信息,与此同时,Socket服务器的目标端口只能够在4502到4534之间。
如果是在其他端口上,只能够通过端口转发来做。
不明白微软为什么要设计的这么复杂,如果有一个Socket服务要同时接受flash,silverlight和客户端的连接,就要很奔溃地在843端口和943端口搭建策略服务器,有些吃饱了撑着,要不不支持flash,要么就彻底一样,现在有些不伦不类的。
基于HTTP长连接的“服务器推”技术
上面我们谈到的都是基于浏览器的插件技术而实现和服务器的实时通信,在单纯浏览器的环境下呢,我们要用Comet,也就是纯粹的HTTP长连接“服务器推”技术。
HTTP长轮询(Long-Polling)
这是Comet最基本的实现方式,也称之为反向(Reserve) Ajax,相对传统的Ajax请求,不同之处在于:
1. 服务器端会阻塞请求直到有数据传递或超时才返回。
2. 客户端 JavaScript 响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接。
3. 当客户端处理接收的数据、重新建立连接时,服务器端可能有新的数据到达;这些信息会被服务器端保存直到客户端重新建立连接,客户端会一次把当前服务器端所有的信息取回。
这样的实现最大的优势是无需任何浏览器插件,而且几乎能够保证是实时的,因为会在一直阻塞等待直到服务器数据产生,与此同时相对于定期轮询,尤其是在空数据的情况下,产生的请求次数和网络带宽的耗费都会小上许多。
基于IFrame或htmlfile的流(streaming)方式
在我们最开始提到过使用隐藏IFrame保持和服务器的长连接,然后通过脚本的方式更新客户端信息,简单地说,Server Push一堆的script tag的数据给客户端,通知客户端更新数据,如下所示:
<script type=”javascript”>top.updateMessage(“new user”);</script>
从服务器推送给浏览器,然后调用其updateMessage方法,把相关的数据传递给浏览器以做更多的业务处理。在周婷的文章中提到了Google的天才们使用了一个名为htmlfile的activex控件来解决IE浏览器下状态栏不停止的问题,并没有给出相关的代码,CometDialy的Michael Carter给出了一个示范性的代码,通过下面的脚本,我们可以看到htmlfile这个神秘的组件的工作逻辑:
function connect_htmlfile(url, callback) {
// no more 'var transferDoc...'
transferDoc = new ActiveXObject("htmlfile");
transferDoc.open();
transferDoc.write(
"<html><script>" +
"document.domain='" + document.domain + "';" +
"</script></html>");
transferDoc.close();
var ifrDiv = transferDoc.createElement("div");
transferDoc.body.appendChild(ifrDiv);
ifrDiv.innerHTML = "<iframe src='" + url + "'></iframe>";
transferDoc.callback = callback;
}
而在iframe指向的地址,我们可以通过这样的方式和页面通信:
<script>
parent.callback(["arbitrary", "data", ["goes", "here"]);
</script>
最后一个问题是浏览器关闭的时候,我们希望有效地关闭连接,为了做到这点,我们必须注册一个unload事件,然后做两件事情:
1. 移除transferDoc的引用
2. 显示调用垃圾回收
具体代码如下:
function htmlfile_close() {
transferDoc = null;
CollectGarbage();
}
利用JSONP实现跨域长轮询
HTTP的一个问题就是跨域安全,在如果您希望应用驻留在第三方网站,这是必须现实考虑的问题,如果没有采用客户端技术的话,长轮询的POST只能够针对自身服务器的,要想实现跨域调用,只能够用script这个tag来加载远程的脚本,通过通过回调执行数据。这时我们只能够依赖于JSONP。关于JSONP的详细内容,请参考相关的文章
小结
到目前为止,我们讨论了在构建实时互动应用系统中浏览器客户端可能的技术选择,从实际的角度来看,如果采用基于客户端方案,flash的xmlsocket无疑是最成熟的,另外就是Comet,大致可以等同地理解为Http长轮询的技术,也有了很多的集成解决方案。
不管采用怎样的技术,还是需要和具体的服务器技术结合的,下面的篇幅,我们将讨论针对这些不同客户端技术而需要考虑的服务器实现。