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

来源:http://www.blogjava.net/rosen/archive/2009/02/11/254309.html

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

阅读更多

没有更多推荐了,返回首页