Ajax轮询以及Comet模式—写在Servlet 3.0发布之前(转)

2008 年的夏天,偶然在网上闲逛的时候发现了 Comet 技术,人云亦云间,姑且认为它是由 Dojo 的 Alex Russell 在 2006 年提出。在阅读了大量的资料后,萌发出写篇 blog 来说明什么是 Comet 的想法。哪知道这个想法到了半年后的今天才提笔,除了繁忙的工作拖延外,还有 Comet 本身带来的困惑。

Comet 能带来生产力的提升是有目共睹的。现在假设有 1000 个用户在使用某软件,轮询 (polling) 和 Comet 的设定都是 1s 、 10s 、 100s 的潜伏期,那么在相同的潜伏期内, Comet 所需要的带宽更小,如下图:

 

不仅仅是在带宽上的优势,每个用户所真正感受到的响应时间(潜伏期)更短,给人的感觉也就更加的实时,如下图:

 

再引用一篇 IBMDW 上的译文《使用 Jetty 和 Direct Web Remoting 编写可扩展的 Comet 应用程序》,其中说到:吸引人们使用 Comet 策略的其中一个优点是其显而易见的高效性。客户机不会像使用轮询方法那样生成烦人的通信量,并且事件发生后可立即发布给客户机。

上面一遍一遍的说到 Comet 技术的优势,那么我们可以替换现有的技术结构了?不幸的是,近半年的擦边球式的关注使我对 Comet 的理解越发的糊涂,甚至有人说 Comet 这个名词已被滥用。去年的一篇博文,《 The definition of Comet? 》使 Comet 更加扑朔迷离,甚至在维基百科上大家也对准确的 Comet 定义产生争论。还是等牛人们争论清楚再修改维基百科吧,在这里我想还是引用维基百科对 Comet 的定义:服务器推模式 (HTTP server push 、 streaming) 以及长轮询 (long polling) ,这两种模式都是 Comet 的实现。

除了对 Comet 的准确定义尚缺乏有效的定论外, Comet 还存在不少技术难题,随着 Tomcat 6 、 Jetty 6 的发布,他们基于 NIO 各自实现了异步 Servlet 机制。有兴趣的看官可以分别实现这两个容器的 Comet ,至少我还没玩转。

在编写服务器端的代码上面,我很困惑, http://tomcat.apache.org/tomcat-6.0-doc/aio.html 这里演示了如何在 Tomcat 6 中实现异步 Servlet ;我们再把目光换到 Jetty 6 上,还是前面提到的那篇 IBMDW 译文,如果你和我一样无聊,可以下载那边文章的 sample 代码。我惊奇的发现每个厂商对异步 Servlet 的封装是不同的,一个傻傻的问题:我的 Comet 服务器端的代码可移植么?至今我还在问这个问题!好吧,业界有规范么?有当然有,不过看起来有些争论会发生——那就是 Servlet 3.0 规范 (JSR-315) , Servlet 3.0 正在公开预览,它明确的支持了异步 Servlet ,《 Servlet 3.0 公开预览版引发争论》,又让我高兴不起来了:“来自 RedHat 的 Bill Burke 写的一篇博文,其中他批评了 Jetty 6 中的异步 servlet 实现 ......Greg Wilkins 宣布他致力于 Servlet 3.0 异步 servlet 的一个实现 ...... 虽然还需要更多测试,但是这个代码已经实现了基本的异步行为,不需要很复杂的重新分发请求或者前递方法。我相信这代表了 3.0 的合理折中方案。在我们从 3.0 的简单子集里获得经验之后,如果需要更多的特性,可以添加到 3.1 中 ........” 。牛人们还在做最佳范例,口水仗也还要继续打,看来要尝到 Comet 的甜头是很困难的。 STOP !我已经不想再分析如何写客户端的代码了,什么 dojo 、 extJs 、 DWR 、 ZK....... 都有自己的实现。我认为这一切都要等 Servelt 3.0 正式发布以后,如何编写客户端代码才能明朗点。

现在抛开绕来绕去的争执吧,既然 Ajax+Servlet 实现 Comet 很困难,何不换个思维呢。我这里倒是有个小小的 sample ,说明如何在 Adobe BlazeDS 中实现长轮询模式。关于 BlazeDS ,可以在这里找到些信息。为了说明什么是长轮询,首先来看看什么是轮询,既在一定间隔期内由 web 客户端发起请求到服务器端取回数据,如下图所示:

                         

 

至于轮询的缺点,在前面的论述中已有覆盖,至于优点大家可以 google 一把,我觉得最大的优点就是技术上很好实现,下面是个 Ajax 轮询的例子,这是一个简单的聊天室,首先是 chat.html 代码,想必这些代码网上一抓就一大把,支持至少 IE6 、 IE7 、 FF3 浏览器,让人烦心的是乱码问题,在传递到 Servlet 之前要 encodeURI 一下 :


<! DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd" >
<!--
    chat page
    author rosen jiang
    since 2008/07/29
-->
< html >
  
< head >
   
< meta  http-equiv ="content-type"  content ="text/html; charset=utf-8" >
    
< script  type ="text/javascript" >
    
// servlets url
     var  url  =   " http://127.0.0.1:8080/ajaxTest/Ajax " ;
    
// bs version
     var  version  =  navigator.appName + "   " + navigator.appVersion;
    
// if is IE
     var  isIE  =   false ;

    
if (version.indexOf( " MSIE 6 " ) > 0   ||  version.indexOf( " MSIE 7 " ) > 0 ){
        isIE 
=   true ;
    }

    
// Httprequest object
     var  Httprequest  =   function () {}
    
// creatHttprequest function of Httprequest
    Httprequest.prototype.creatHttprequest = function (){
        
var  request  =   false ;
        
// init XMLHTTP or XMLHttpRequest
         if  (isIE) {
            
try  {
                request 
=   new  ActiveXObject( " Msxml2.XMLHTTP " );
            } 
catch  (e) {
                
try  {
                    request 
=   new  ActiveXObject( " Microsoft.XMLHTTP " );
                } 
catch  (e) {}
            }
        }
else  {  // Mozilla bs etc.
            request  =   new  XMLHttpRequest();
        }
        
if  ( ! request) {
            
return   false ;
        }
        
return  request;
    }
    
// sendMsg function of Httprequest
    Httprequest.prototype.sendMsg = function (msg){
        
var  http_request  =      this .creatHttprequest();
        
var  reslult  =   "" ;
        
var  methed  =   false ;
        
if  (http_request) {    
            
if  (isIE) {                
                http_request.onreadystatechange 
=
                        
function  (){ // callBack function
                             if  (http_request.readyState  ==   4 ) {
                                
if  (http_request.status  ==   200 ) {
                                    reslult 
=  http_request.responseText;
                                } 
else  {
                                    alert(
" 您所请求的页面有异常。 " );
                                }
                            }
                        };
            } 
else  {
                http_request.onload 
=  
                        
function  (){ //  callBack function of Mozilla bs etc.
                             if  (http_request.readyState  ==   4 ) {
                                
if  (http_request.status  ==   200 ) {
                                    reslult 
=  http_request.responseText;
                                } 
else  {
                                    alert(
" 您所请求的页面有异常。 " );
                                }
                            }
                        };
            }
            
// send msg
             if (msg != null   &&  msg != "" ){
                request_url 
=  url + " ? " + Math.random() + " &msg= " + msg;
                
// encodeing utf-8 Character
                request_url  =  encodeURI(request_url);
                http_request.open(
" GET " , request_url,  false );
            }
else {
                http_request.open(
" GET " , url + " ? " + Math.random(),  false );
            }
            http_request.setRequestHeader(
" Content-type " , " charset=utf-8; " );
            http_request.send(
null );
        }
        
return  reslult;    
    }
</ script >
</ head >
< body >
  
< div >
      
< input  type ="text"  id ="sendMsg" ></ input >
      
< input  type ="button"  value ="发送消息"  onclick ="send()" />
      
< br />< br />
      
< div  style ="width:470px;overflow:auto;height:413px;border-style:solid;border-width:1px;font-size:12pt;" >
             
< div  id ="msg_content" ></ div >
          
< div  id ="msg_end"  style ="height:0px; overflow:hidden" >   </ div >
      
</ div >
  
</ div >
</ body >
< script  type ="text/javascript" >
    
var  data_comp  =   "" ;
    
// send button click
     function  send(){
        
var  sendMsg  =  document.getElementById( " sendMsg " );
        
var  hq  =   new  Httprequest();
        hq.sendMsg(sendMsg.value);
        sendMsg.value
= "" ;
    }
    
// processing wnen message recevied
     function  writeData(){
        
var  msg_content  =  document.getElementById( " msg_content " );
        
var  msg_end  =  document.getElementById( " msg_end " );
        
var  hq  =   new  Httprequest();
        
var  value  =  hq.sendMsg();
        
if (data_comp  !=  value){
            data_comp 
=  value;
            msg_content.innerHTML 
=  value;
            msg_end.scrollIntoView();
        }
        setTimeout(
" writeData() " ,  1000 );
    }
    
// init load writeData 
    onload  =  writeData;
</ script >
</ html >

 

接下来是 Servlet ,如果你是用的 Tomcat ,在这里注意下编码问题,否则又是乱码,另外我使用 LinkedList 实现了一个队列,该队列的最大长度是 30 ,也就是最多能保存 30 条聊天信息,旧的将被丢弃,另外新的客户端进来后能读取到最近的信息:

 

package  org.rosenjiang.ajax;

import  java.io.IOException;
import  java.io.PrintWriter;
import  java.text.SimpleDateFormat;
import  java.util.Date;
import  java.util.LinkedList;

import  javax.servlet.ServletException;
import  javax.servlet.http.HttpServlet;
import  javax.servlet.http.HttpServletRequest;
import  javax.servlet.http.HttpServletResponse;

/**
 * 
 * 
@author  rosen jiang
 * 
@since  2009/02/06
 * 
 
*/
public   class  Ajax  extends  HttpServlet {
    
private   static   final   long  serialVersionUID  =   1L ;
    
//  the length of queue
     private   static   final   int  QUEUE_LENGTH  =   30 ;
    
//  queue body
     private   static  LinkedList < String >  queue  =   new  LinkedList < String > ();
    
    
/**
     * response chat content
     * 
     * 
@param  request
     * 
@param  response
     * 
@throws  ServletException
     * 
@throws  IOException
     
*/
    
public   void  doGet(HttpServletRequest request, HttpServletResponse response)
            
throws  ServletException, IOException {
        
// parse msg content
        String msg  =  request.getParameter( " msg " );
        SimpleDateFormat sdf 
=   new  SimpleDateFormat( " yyyy-MM-dd HH:mm:ss " );
        
// push to the queue
         if  (msg  !=   null   &&   ! msg.equals( "" )) {
            
byte [] b  =  msg.getBytes( " ISO_8859_1 " );
            msg 
=  sdf.format( new  Date())  + "    " + new  String(b,  " utf-8 " ) + " <br> " ;
            
if (queue.size()  ==  QUEUE_LENGTH){
                queue.removeFirst();
            }
            queue.addLast(msg);
        }
        
// response client
        response.setContentType( " text/html " );
        response.setCharacterEncoding(
" utf-8 " );
        PrintWriter out 
=  response.getWriter();
        msg 
=   "" ;
        
// loop queue
         for ( int  i = 0 ; i < queue.size(); i ++ ){
            msg 
=  queue.get(i);
            out.println(msg
== null   ?   ""  : msg);
        }
        out.flush();
        out.close();
    }

    
/**
     * The doPost method of the servlet.
     *
     * 
@param  request
     * 
@param  response
     * 
@throws  ServletException
     * 
@throws  IOException
     
*/
    
public   void  doPost(HttpServletRequest request, HttpServletResponse response)
            
throws  ServletException, IOException {
        
this .doGet(request, response);
    }
}


 

打开浏览器,实验下效果,将就用吧,稍微有些延迟。还是看看长轮询吧,长轮询有三个显著的特征:

1. 服务器端会阻塞请求直到有数据传递或超时才返回。

2. 客户端响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接。

3. 当客户端处理接收的数据、重新建立连接时,服务器端可能有新的数据到达;这些信息会被服务器端保存直到客户端重新建立连接,客户端会一次把当前服务器端所有的信息取回。

 

下图很好的说明了以上特征:

                             

 

既然关注的是 BlazeDS 如何实现长轮询,那么有必要稍微了解下。 BlazeDS 包含了两个重要的服务,进行远端方法调用的 RPC service 和传递异步消息的 Messaging Service ,我们即将探讨的长轮询属于 Messaging Service 。 Messaging Service 使用 producer consumer 模式来分别定义消息的发送者 (producer) 和消费者 (consumer) ,具体到 Flex 代码,有 Producer 和 Consumer 两个组件对应。在广阔的互联网上有很多 BlazeDS 入门的中文教材,我就不再废话了。假设你已经装好 BlazeDS ,打开 WEB-INF/flex/services-config.xml 文件,在 channels 节点内加一个 channel 声明长轮询频道,关于 channel 和 endpoint 请参阅 About channels and endpoints 章节:


         < channel-definition  id ="long-polling-amf"  class ="mx.messaging.channels.AMFChannel" >
            
< endpoint  url ="http://{server.name}:{server.port}/{context.root}/messagebroker/longamfpolling"  class ="flex.messaging.endpoints.AMFEndpoint" />
            
< properties >
                
< polling-enabled > true </ polling-enabled >
                
< wait-interval-millis > 60000 </ wait-interval-millis >
                
< polling-interval-millis > 0 </ polling-interval-millis >
                
< max-waiting-poll-requests > 150 </ max-waiting-poll-requests >
            
</ properties >
    
</ channel-definition >

如何实现长轮询的玄机就在上面的 properties 节点内, polling-enabled = true ,打开轮询模式; wait-interval-millis = 6000 服务器端的潜伏期,也就是服务器会保持与客户端的连接,直到超时或有新消息返回(恩,看来这就是长轮询了); polling-interval-millis = 0 表示客户端请求服务器端的间隔期, 0 表示没有任何的延迟; max-waiting-poll-requests = 150 表示服务器能承受的最大长连接用户数,超过这个限制,新的客户端就会转变为普通的轮询方式(至于这个数值最大能有多大,这和你的 web 服务器设置有关了,而 web 服务器的最大连接数就和操作系统有关了,这方面的话题不在本文内探讨)。

 

其实这样设置之后,长轮询的代码已经实现了一半了。恩,不错!看起来比异步 Servlet 实现起来简单多了。不过要实现和之前 Ajax 轮询一样的效果,还得实现自己的 ServiceAdapter ,这就是 Adapter 的用处:


package  org.rosenjiang.flex;

import  java.text.SimpleDateFormat;
import  java.util.Date;
import  java.util.LinkedList;

import  flex.messaging.io.amf.ASObject;
import  flex.messaging.messages.Message;
import  flex.messaging.services.MessageService;
import  flex.messaging.services.ServiceAdapter;

/**
 * 
 * 
@author  rosen jiang
 * 
@since  2009/02/06
 * 
 
*/
public   class  MyMessageAdapter  extends  ServiceAdapter {

    
//  the length of queue
     private   static   final   int  QUEUE_LENGTH  =   30 ;
    
//  queue body
     private   static  LinkedList < String >  queue  =   new  LinkedList < String > ();

    
/**
     * invoke method
     * 
     * 
@param  message Message
     * 
@return  Object
     
*/
    
public  Object invoke(Message message) {
        SimpleDateFormat sdf 
=   new  SimpleDateFormat( " yyyy-MM-dd HH:mm:ss " );
        MessageService msgService 
=  (MessageService) getDestination()
            .getService();
        
// message Object
        ASObject ao  =  (ASObject) message.getBody();
        
// chat message
        String msg  =  (String) ao.get( " chatMessage " );
        
if  (msg  !=   null   &&   ! msg.equals( "" )) {
            msg 
=  sdf.format( new  Date())  +   "    "   +  msg  +   " \r " ;
            
if (queue.size()  ==  QUEUE_LENGTH){
                queue.removeFirst();
            }
            queue.addLast(msg);
        }
        msg 
=   "" ;
        
// loop queue
         for ( int  i = 0 ; i < queue.size(); i ++ ){
            String chatData 
=  queue.get(i);
            
if  (chatData  !=   null ) {
                msg 
+=  chatData;
            }
        }
        ao.put(
" chatMessage " , msg);
        message.setBody(ao);
        msgService.pushMessageToClients(message, 
false );
        
return   null ;
    }
}

接下来注册该 Adapter ,打开 WEB-INF/flex/messaging-config.xml 文件,在 adapters 节点内加入一个 adapter-definition 来声明自定义 Adapter :


< adapter-definition  id ="myad"  class ="org.rosenjiang.flex.MyMessageAdapter" />

 

接着定义一个 destination ,以便 Flex 客户端能订阅聊天室,组装好之前定义的长轮询频道和 adapter :


     < destination  id ="chat" >
        
< channels >
            
< channel  ref ="long-polling-amf" />
        
</ channels >
        
< adapter  ref ="myad" />
    
</ destination >

服务器端就算搞定了,接着搞定 Flex 那边的代码吧,灰常灰常的简单。先到 Building your client-side application 学习如何创建和 BlazeDS 通讯的 Flex 项目。然后在 chat.mxml 中写下:


<? xml version="1.0" encoding="utf-8" ?>
< mx:Application  xmlns:mx ="http://www.adobe.com/2006/mxml"  creationComplete ="consumer.subscribe();send()" >
    
    
< mx:Script >
        
<![CDATA[
        
            import mx.messaging.messages.AsyncMessage;
            import mx.messaging.messages.IMessage;
            
            private function send():void
            {
                var message:IMessage = new AsyncMessage();
                message.body.chatMessage = msg.text;
                producer.send(message);
                msg.text = "";
            }
                        
            private function messageHandler(message:IMessage):void
            {
                log.text = message.body.chatMessage + "\n";
            }
            
        
]]>
    
</ mx:Script >
    
    
< mx:Producer  id ="producer"  destination ="chat" />
    
< mx:Consumer  id ="consumer"  destination ="chat"  message ="messageHandler(event.message)" />
    
    
< mx:Panel  title ="Chat"  width ="100%"  height ="100%" >
        
< mx:TextArea  id ="log"  width ="100%"  height ="100%" />
        
< mx:ControlBar >
             
< mx:TextInput  id ="msg"  width ="100%"  enter ="send()" />
             
< mx:Button  label ="Send"  click ="send()" />  
        
</ mx:ControlBar >
    
</ mx:Panel >
    
</ mx:Application >

 

之前我们说到的 Producer 和 Consumer 组件在这里出现了,由于我们要订阅的是同一个聊天室,所以 destination="chat" ,而 Consumer 组件则注册回调函数 messageHandler() ,处理异步消息的到来。当打开这个聊天客户端的时候,在 creationComplete 初始化完成后,立即进行 consumer.subscribe() ,其实接下来应该就能直接收到服务器端回馈的聊天记录了,但是我没仔细学习如何监听客户端的订阅,所以在这里我直接 send() 了一个空消息以便服务器端能回馈已有的聊天记录,接下来我就不用再讲解了,都能看懂。

现在打开浏览器,感受下长轮询的效果吧。不过遇到个问题,如果 FF 同时开两个聊天窗口,第二个打开的会有延迟感, IE 也是,按照牛人们的说法,当一个浏览器开两个以上长连接的时候才会有延迟感,不解。 BlazeDS 的长轮询也不是十全十美,有人说它不是真正的“实时” The Truth About BlazeDS and Push Messaging ,随即引发出口水仗,里面提到的 RTMP 协议在 2009 年 1 月已开源,相信以后 BlazeDS 会更“实时”;接着又有人说 BlazeDS 不是非阻塞式的,这个问题后来也没人来对应。罢了,毕竟BlazeDS才开源不久,容忍一下吧。最后,我想说的是,不论 BlazeDS 到底有什么问题,至少实现起来是轻松的,在 Servlet 3.0 没发布之前,是个不错的选择。


请注意!引用、转贴本文应注明原作者:Rosen Jiang 以及出处: http://www.blogjava.net/rosen

posted on 2013-12-11 08:57  davidkam  阅读(570)  评论(0)    收藏  举报