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 一下 :
<!--
????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 條聊天信息,舊的將被丟棄,另外新的客戶端進來后能讀取到最近的信息:
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 章節:
????????????
<
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 的用處:
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 :
接著定義一個 destination ,以便 Flex 客戶端能訂閱聊天室,組裝好之前定義的長輪詢頻道和 adapter :
????????
<
channels
>
????????????
<
channel?
ref
="long-polling-amf"
/>
????????
</
channels
>
????????
<
adapter?
ref
="myad"
/>
????
</
destination
>
服務器端就算搞定了,接著搞定 Flex 那邊的代碼吧,灰常灰常的簡單。先到 Building your client-side application 學習如何創建和 BlazeDS 通訊的 Flex 項目。然后在 chat.mxml 中寫下:
<
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.aygfsteel.com/rosen