这几天在给公司的一个点对点聊天系统升级,之前只是使用简单的ajax轮询方式实现,每5秒钟取一次数据,延时太长,用户体验不是很好,因此打算采用服务器推送技术,故此整理了以下文档,将自己找到的一些资料及心得与大家在此分享。本文主要综述了Comet相关的概念、应用场景、常用的两种实现模型、及PHP实现代码。
概 念:Comet,基于 HTTP 长连接的“服务器推”技术,是一种 Web 应用程序的架构。基于这种架构开发的应用中,服务器端会主动以异步的方式向客户端程序推送数据,而不需要客户端显式的发出请求。
其他别名:服务器推送技术(Server Push),反向Ajax
应用场景:Comet 架构非常适合事件驱动的 Web 应用以及对交互性和实时性要求很强的应用,如实时监控、股票交易行情分析、聊天室和 Web 版在线游戏等。
实现模型:基于 Comet 架构的 Web 应用使用客户端和服务器端之间的 HTTP 长连接来作为数据传输的通道。每当服务器端的数据因为外部的事件而发生改变时,服务器端就能够及时把相关的数据推送给客户端。通常来说,有两种实现长连接的模型:
1. 基于 Iframe 的流方式(streaming)
基本原理: 在页面中加入一个Iframe标签,Iframe的src发起到服务器的连接,服务器端将其搁置,这样就建立了一个服务器端到客户端的通道,每当服务器端有数据时直接将数据经由此通道发送。
数据处理:这种方式服务器端返回的数据一般为类似“<script type="text/javascript">js_func(“json_data”)</script>”的JS脚本,其中js_func为写在母页面中的一个用来处理返回结果的回调函数,服务器端将返回的数据作为回调函数的参数,浏览器在收到数据后就会执行这段JS脚本。
优点: 具有高兼容特性
缺点: 使用 iframe 请求一个长连接有一个很明显的不足之处是有些浏览器会显示加载没有完成,一直处加页面加载中。Google 的天才们使用一个称为“htmlfile”的 ActiveX 解决了在 IE 中的加载显示问题。Zeitoun 网站提供的 comet-iframe.tar.gz,封装了一个基于 iframe 和 htmlfile 的 JavaScript comet 对象,支持 IE、Mozilla Firefox 浏览器,可以作为参考。
2. 基于 AJAX 长轮询方式(long-polling)
基本原理: 客户端发起一个ajax请求,服务器端将该请求搁置(pending)或者说挂起,直到服务器端有数据需要推送时返回数据并断开连接,客户端在接收ajax返回后处理数据,同时再次发起下一个ajax请求。
数据处理:通过 ajax 的回调函数来进行数据处理。
优点: 兼容性较高,实现简单
缺点: 对于php这种语言来说,如果要做到实时,那么服务端就要承受大得多的压力,因为搁置到什么时候往往是不确定的,这就要php脚本每次搁置都进行一个while循环。
注意: 浏览器有连接数限制。我得出的结论是如果当前页面上有一个ajax请求处于等待返回状态,那么其他ajax请求都会被搁置(Chrome, Firefox已测)。如果页面有一般ajax需求怎么办?解决方法是开个框架,框架中使在另一个域名下进行Comet长轮询,需要注意跨域问题。
未来方向:在HTML5标准中,定义了客户端和服务器通讯的WebSocket方式,在得到浏览器支持以后,WebSocket将会取代Comet成为服务器推送的方法,目前chrome、Firefox、Opera、Safari等主流版本均支持,Internet Explorer从10开始支持。
注意事项:对于一个实际的应用而言,系统的稳定性和性能是非常重要的。将 HTTP 长连接用于实际应用,很多细节需要考虑。
1. 不要在同一客户端同时使用超过两个的 HTTP 长连接
HTTP 1.1 规范中规定,客户端不应该与服务器端建立超过两个的 HTTP 连接,超过之后,新的连接会被阻塞。HTTP 1.1 对两个长连接的限制,会对使用了长连接的 Web 应用带来如下现象:在客户端如果打开超过两个的 IE 窗口去访问同一个使用了长连接的 Web 服务器,第三个 IE 窗口的 HTTP 请求被前两个窗口的长连接阻塞。所以在开发长连接的应用时, 必须注意在使用了多个 frame 的页面中,不要为每个 frame 的页面都建立一个 HTTP 长连接,这样会阻塞其它的 HTTP 请求,在设计上考虑让多个 frame 的更新共用一个长连接。
2. 控制信息与数据信息使用不同的 HTTP 连接
使用长连接时,存在一个很常见的场景:客户端需要关闭页面,而服务器端还处在读取数据的阻塞状态,客户端需要及时通知服务器端关闭数据连接。服务器在收到关闭请求后首先要从读取数据的阻塞状态唤醒,然后释放为这个客户端分配的资源,再关闭连接。所以在设计上,我们需要使客户端的控制请求和数据请求使用不同的 HTTP 连接,才能使控制请求不会被阻塞。在实现上,如果是基于 iframe 流方式的长连接,客户端页面需要使用两个 iframe,一个是控制帧,用于往服务器端发送控制请求,控制请求能很快收到响应,不会被阻塞;一个是显示帧,用于往服务器端发送长连接请求。如果是基于 AJAX 的长轮询方式,客户端可以异步地发出一个 XMLHttpRequest 请求,通知服务器端关闭数据连接。
补充:关于如何通知服务器关闭数据连接请看,络络的另一篇文章:http://www.cnblogs.com/hackboy/p/3735070.html
3. SESSION锁定问题
对于上述第二点并不是十分理解,如何通过向服务器端发送控制请求?如何通知服务器端关闭数据连接?服务器端又怎么样关闭数据连接?经过我的实践发现,在服务器端如果使用了session,刷新页面时,页面会阻塞直到长连接超时返回方能重新加载页面,不知道这是不是上一点中所描述的问题?在PHP手册中查到一段英文,翻译过来大致是这样子的:默认情况下,session会在脚本执行完毕之后自动进行存储,脚本在操作session时session会被锁定,这意味着在同一时刻只有一个脚本可以操作session。由于该锁定的存在,当长连接被服务器端搁置期间,session会被一直锁定占用,从而导至其它请求无法打开session,从而造成页面阻塞。你当然可以关闭网站的session功能,但这不现实,那么有没有一种方法可以在session操作完之后就及时将其存储并关闭呢?通过session_write_close函数,你可以在对session的操作完成之后但脚本尚未执行完毕之前及时将session存储并释放。于是在服务器端程序进行搁置前加入session_write_close函数之后成功解决了长连接导致的页面阻塞问题。
4. 在客户和服务器之间保持心跳信息
在浏览器与服务器之间维持一个长连接会为通信带来一些不确定性,因为数据传输是随机的,客户端不知道何时服务器才有数据传送。服务器端需要确保当客户端不再工作时,释放为这个客户端分配的资源。因此需要一种机制使双方知道大家都在正常运行。在实现上:服务器端在阻塞读时会设置一个时限,超时后阻塞读调用会返回,同时发给客户端没有新数据到达的心跳信息。此时如果客户端已经关闭,服务器往通道写数据会出现异常,服务器端就会及时释放为这个客户端分配的资源。如果客户端使用的是基于 AJAX 的长轮询方式;服务器端返回数据、关闭连接后,经过某个时限没有收到客户端的再次请求,会认为客户端不能正常工作,会释放为这个客户端分配、维护的资源。当服务器处理信息出现异常情况,需要发送错误信息通知客户端,同时释放资源、关闭连接。
代码示例1.1 Comet-ajax-backend.php(ajax长轮询服务器端代码)
<?php //不限制超时时间 set_time_limit(0); //在开启session的应用中这个函数非常重要,防止页面因session占用阻塞 session_write_close(); //用来存放数据的文件 $filename = './data.txt'; // 如果有传递将消息存入文件 $msg = isset($_GET['msg']) ? $_GET['msg'] : ''; if ($msg != '') { file_put_contents($filename, $msg); die(); } $content = file_get_contents($filename); // $msg是否为空 while ($content=='') { sleep(1); $content = file_get_contents($filename); } //清空data.txt file_put_contents($filename, ''); // 取到消息返回 $response['msg'] = $content; echo json_encode($response); ?>
代码示例1.2 Comet-ajax-index.html(ajax长轮询客户端代码)
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>Comet demo</title> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> <script type="text/javascript" src="jquery.js"></script> <script> var comet = { url:'backend.php', error:false, connect : function(){ $.ajax({ url: comet.url, type: 'post', dataType: 'json', timeout: 0, success: function (response) { comet.error = false; $("#content").append('<div>' + response.msg + '</div>'); }, error: function () { comet.error = true; }, complete: function () { if (comet.error) { setTimeout(function () { comet.connect(); }, 5000); } else { //alert(comet.timestamp); comet.connect(); } } }) } } // 发送消息函数 function send(msg) { $.ajax({ data: {'msg': msg}, type: 'get', url: comet.url }) } $(document).ready(function () { //页面加载完毕创建长连接 comet.connect(); }); </script> </head> <body> <div id="content"> </div> <p> <form action="" method="get" οnsubmit="send($('#word').val());$('#word').val('');return false;"> <input type="text" name="word" id="word" value=""/> <input type="submit" name="submit" value="Send"/> </form> </p> </body> </html>
代码示例2.1 Comet-iframe-backend.php(iframe服务器端代码)
<?php header("Cache-Control: no-cache, must-revalidate"); header("Expires: Mon, 26 Jul 1997 05:00:00 GMT"); //关闭PHP缓存 ob_end_flush(); //将内容强制冲刷到浏览器 flush(); //不限制超时时间 set_time_limit(0); //在开启session的应用中这个函数非常重要,防止页面因session占用阻塞 session_write_close(); ?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>Comet php backend</title> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> </head> <body> <script type="text/javascript"> // KHTML browser don't share javascripts between iframes var is_khtml = navigator.appName.match("Konqueror") || navigator.appVersion.match("KHTML"); if (is_khtml) { var prototypejs = document.createElement('script'); prototypejs.setAttribute('type','text/javascript'); prototypejs.setAttribute('src','prototype.js'); var head = document.getElementsByTagName('head'); head[0].appendChild(prototypejs); } // load the comet object var comet = window.parent.comet; </script> <?php while(1) { echo '<script type="text/javascript">'; echo 'comet.printServerTime('.time().');'; echo '</script>'; // 强制将数据发送到浏览器 flush(); // 休息一秒钟 sleep(1); } ?> </body> </html>
代码示例2.2 Comet-iframe-index.html(iframe客户端代码)
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>Comet-iframe-demo</title> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <script type="text/javascript" src="jquery.js"></script> </head> <body> <div id="content">The server time will be shown here</div> <script type="text/javascript"> var comet = { connection : false, iframediv : false, iframesrc : './backend.php', initialize: function() { if (navigator.appVersion.indexOf("MSIE") != -1) { // 创建争取IE浏览器的iframe comet.connection = new ActiveXObject("htmlfile"); comet.connection.open(); comet.connection.write("<html>"); comet.connection.write("<script>document.domain = '"+document.domain+"'"); comet.connection.write("</html>"); comet.connection.close(); comet.iframediv = comet.connection.createElement("div"); comet.connection.appendChild(comet.iframediv); comet.connection.parentWindow.comet = comet; comet.iframediv.innerHTML = "<iframe id='comet_iframe' src='"+comet.iframesrc+"'></iframe>"; } else if (navigator.appVersion.indexOf("KHTML") != -1) { // 创建争对KHTML浏览器的iframe comet.connection = document.createElement('iframe'); comet.connection.setAttribute('id', 'comet_iframe'); comet.connection.setAttribute('src', comet.iframesrc); with (comet.connection.style) { position = "absolute"; left = top = "-100px"; height = width = "1px"; visibility = "hidden"; } document.body.appendChild(comet.connection); } else { // 创建争对其它浏览器的iframe comet.connection = document.createElement('iframe'); comet.connection.setAttribute('id', 'comet_iframe'); with (comet.connection.style) { left = top = "-100px"; height = width = "1px"; visibility = "hidden"; display = 'none'; } comet.iframediv = document.createElement('iframe'); comet.iframediv.setAttribute('src', comet.iframesrc); comet.connection.appendChild(comet.iframediv); document.body.appendChild(comet.connection); } }, // 重新加载页面时释放iframe,防止IE浏览器出错 onUnload: function() { if (comet.connection) { comet.connection = false; } }, // 数据处理回调函数 printServerTime: function (time) { $('#content').html(time); } } $(function(){ //页面加载完毕后创建iframe到服务器的长连接 comet.initialize(); $(window).unload(function(){comet.onUnload();}); }) </script> </body> </html>
本文文档及代码示例下载:http://yun.baidu.com/s/1c0ovV6w