Philip McCarthy(ph)
異步服務器端事件驅動的Ajax程序很難實現也很難獲得伸縮性在Java+developers: target=blank>作者的系列文章裡Plilip McCarthy展示了一個有效的方式:
Comet模式允許您push數據到客戶端而且Jetty 的Continuations API讓您的Comet程序對大量客戶端獲得高可伸縮性您可以方便的同DWR 使用Comet和Continuations
隨著Ajax在Web程序開發技術裡建立了牢固的位置出現了幾種常見的Ajax使用模式例如Ajax通常用於響應用戶輸入來使用新數據修改局部頁面但有時候Web程序的用戶界面需要根據偶爾的異步服務器端事件來更新而不需要用戶動作 例如在Ajax聊天程序裡顯示其他用戶輸入的一條新消息由於Web浏覽器和服務器間的HTTP連接只能由浏覽器建立服務器不能推更改數據到浏覽器
Ajax程序有兩個解決該問題的基本方式:浏覽器每隔幾秒請求服務器來獲得更改或者服務器維持與浏覽器的連接並且傳遞數據長連接技術稱為Comet本文展示了怎樣使用Jetty服務器引擎和DWR來實現簡單而高效的Comet Web程序
為什麼要Comet?
輪詢方式的主要缺點是在大量客戶端時產生了大量的傳輸浪費每個客戶端都必須有規律的請求服務器來獲得更改這是服務器資源的一個重擔最壞的情況是程序很少更新例如Ajax郵件收件箱在這種情況下大量的客戶端輪詢是多余的服務器僅僅簡單的響應沒有數據 可以通過增加輪詢間隔時間來減輕服務器負荷但是這引入了服務器事件和客戶端知曉之間的延遲當然一個合理的折衷方案可以多數程序適用並且輪詢的工作方式也可以接受
然而對Comet策略的呼喚來自它可感知的高效客戶端不會產生輪詢方式特有的傳輸浪費一旦事件發生就會被發布到客戶端但是維持長連接也消耗了服務器資源當servlet位置持久的請求在等候狀態時servlet獨占一個線程這樣傳統的servlet引擎就限制了Comet的伸縮性因為客戶端的數量會迅速超過服務器棧可以有效處理的線程的數量
Jetty 有什麼不同
Jetty 設計來處理大量並發連接它使用Java語言的不堵塞I/O(javanio)庫並且使用優化的輸出緩沖架構Jetty也有一個處理長連接的殺手锏:一個稱為Continuations的特性我將用一個接收請求然後等待兩秒發送響應的簡單servlet來示范Continuations然後我將展示當服務器擁有更多的客戶端時將發生什麼最後我將使用Continuations重新實現servlet並且您將看到它們的不同
為了讓它更簡單我將限制Jetty servlet引擎為一個單一的請求處理線程列表顯示了相關的jettyxml配置事實上我需要允許在ThreadPool裡總共有個線程:Jetty服務器本身使用一個HTTP連接器使用一個來監聽進來的請求最後剩一個線程來執行servlet代碼
列表 單一servlet線程的Jetty配置
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
<?xml version=?>
<!DOCTYPE Configure PUBLIC //Mort Bay Consulting//DTD Configure//EN
>
<Configure id=Server class=orgmortbayjettyserver>
<Set name=ThreadPool>
<New class=orgmortbaythreadBoundedThreadPool>
<Set name=minThreads></Set>
<Set name=lowThreads></Set>
<Set name=maxThreads></Set>
</New>
</Set>
</Configure>
下一步
為了模仿異步事件
列表
顯示了BlockingServlet的service()方法
它簡單的使用Thread
sleep()調用來在完成前暫停
毫秒
同時它也在執行開始和結束時輸出系統時間
為了幫助區分不同請求的輸出
它也把一個請求參數作為標識符記錄到日志
列表
BlockingServlet
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
public class BlockingServlet extends HttpServlet {
public void service(HttpServletRequest req HttpServletResponse res) throws javaioIOException {
String reqId = reqgetParameter(id);
ressetContentType(text/plain);
resgetWriter()println(Request: + reqId + \tstart:\t + new Date());
resgetWriter()flush();
try {
Threadsleep();
} catch (Exception e) {}
resgetWriter()println(Request: + reqId + \tend:\t + new Date());
}
}
現在您可以觀察幾個同步請求下servlet的行為列表顯示了使用lynx的個並行請求時控制台的輸出命令行簡單的啟動個lynx進程加上一個標識符序數到請求的URL
列表 到BlockingServlet的幾個並發請求的輸出
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
$ for i in seq ; do lynx dump localhost:/blocking?id=$i & done
Request: start: Sun Jul :: BST
Request: end: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: end: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: end: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: end: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: end: Sun Jul :: BST
列表
的輸出並不驚奇
由於Jetty只有一個線程來執行servlet的service()方法
Jetty將每個請求列隊並按順序服務
時間戳顯示了在一個應答分派給一個請求(以及end消息)後
servlet開始處理下一個請求(下一個start消息)
所以即使所有的
個請求是同時發出的
最後的那個請求必須等待
秒才能得到處理
現在看看Jetty 的Continuations特性在這種情形下是多麼的有用列表顯示了列表的BlockingServlet使用Continuations API重寫後的樣子我將在後面解釋代碼
列表 ContinuationServlet
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
public class ContinuationServlet extends HttpServlet {
public void service(HttpServletRequest req HttpServletResponse res) throws javaioIOException {
String reqId = reqgetParameter(id);
Continuation cc = ContinuationSupportgetContinuation(req null);
ressetContentType(text/plain);
resgetWriter()println(Request: + reqId + \tstart:\t + new Date());
resgetWriter()flush();
ccsuspend();
resgetWriter()println(Request: + reqId + \tend:\t + new Date());
}
}
列表
顯示了對ContinuationServlet作
個並發請求時的輸出
可以和列表
比較一下
列表
到ContinuationServlet的幾個並發請求的輸出
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
$ for i in seq ; do lynx dump localhost:/continuation?id=$i & done
Request: start: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: end: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: end: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: end: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: end: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: start: Sun Jul :: BST
Request: end: Sun Jul :: BST
在列表
裡有兩件重要的事情值得注意
首先
每個start消息出現了兩次
暫時不要擔心這點
其次
更重要的是
現在請求是並發處理的
沒有排隊
注意所有的start和end消息時間戳是一樣的
因此
沒有哪個請求耗時超多兩秒
即使只有單一的servlet線程在運行
深入Jetty的Continuations機制
理解Jetty的Continuations機制的將解釋您在列表裡看到的東西為了使用ContinuatinsJetty必須配置為使用它的SelectChannelConnector處理請求這個connector構建在javanio API之上允許它維持每個連接開放而不用消耗一個線程當使用SelectChannelConnector時ContinuationSupportgetContinuation()提供一個SelectChannelConnectorRetryContinuation實例(但是您必須針對Continuation接口編程)當在RetryContinuation上調用suspend()時它拋出一個特殊的運行時異常 RetryRequest該異常傳播到servlet外並且回溯到filter鏈最後被SelectChannelConnector捕獲但是不會發送一個異常響應給客戶端而是將請求維持在未決 Continuations隊列裡則HTTP連接保持開放這樣用來服務請求的線程返回給ThreadPool然後又可以用來服務其他請求
暫停的請求停留在未決 Continuations隊列裡直到指定的過期時間或者在它的Continuation上調用resume()方法當任何一個條件觸發時請求會重新提交給servlet(通過filter鏈)這樣整個請求被重播直到RetryRequest異常不再拋出然後繼續按正常情況執行
列表裡的輸出現在應該能理解了對每個請求按順序進入到servlet的service()方法start消息發送給應答然後Continuation的suspend()方法保留servlet然後釋放線程來開始下一請求所有的個請求迅速運行service()方法的第一部分並馬上進入暫停狀態所有的start消息在幾毫秒內輸出兩秒後suspend()過期第一個請求從未決隊列裡重新得到並重新提交給ContinuationServletstart消息第二次輸出對suspend()方法的第二次調用立即返回然後end消息被發送給應答然後servlet代碼執行下一個隊列請求以此類推
所以在BlockingServlet和ContinuationServlet兩種情況下請求被排入隊列來訪問單一的servlet線程盡管如此在BlockingServlet裡的兩秒鐘暫停在servlet線程裡執行時ContinuationServlet的暫停發生於servlet外面的SelectChannelConnector裡ContinuationServlet全部的吞吐量會更高因為servlet線程不會在sleep()調用時阻礙大多數時間
讓Continuations變得有用
現在您已經看到Continuations運行servlet請求暫停而不消耗線程我需要多解釋一下Continuations API來展示怎樣使用Continuations達到特殊的目的
一個resume()方法和一個suspend()方法配對您可以認為它們是標准的Object wait()/notify()機制的Continuations等價物即suspend()維持一個Continuation直到過期或者另一個線程調用resume()suspend()/resume()方法是使用Continuations實現真實的Comet風格服務的關鍵所在基本的模式是從當前請求維持Continuation調用suspend()然後等待直到您的異步時間到達然後調用resume()並生成應答
但是不像編程語言裡真實的語言級continuations如Scheme或Java語言裡的wait()/notify()在Jetty Continuation上調用resume()並不意味著代碼執行於它停止的確切位置您已經看到真正發生的是與Continuation相關的請求被重播這導致兩個問題:列表的ContinuationServlet裡代碼不合需要的重新執行以及丟失狀態 暫停時作用域裡的任何東西都丟失了
第一個問題的解決方案是isPending()方法如果isPending()方法的返回值為true這意味著suspend()在前面已經被調用過了並且二次請求的執行不會再次接觸suspend()方法換句話說給您的suspend()調用前的代碼加上isPending()條件可以確保它只被執行一次Continuation也提供了一個簡單的機制來保持狀態:putObject(Object)和getObject()方法使用它們來維持一個context對象這樣當Continuation暫停時任何您需要維持的狀態都可以得到保護您也可以使用該機制作為一種在線程之間傳遞事件數據的方法後面您將看到
寫一個基於Continuations的程序
作為一個真實世界裡的例子我將開發一個基本的GPS坐標跟蹤Web程序它將在無規律間隔內生成隨機的緯度經度對假設生成的坐標可以為附近的公眾移動位置如拿著GPS設備馬拉松運動員成隊的汽車或者運輸中的包裹位置有意思的部分在於我怎樣告訴浏覽器坐標信息圖顯示了這個簡單的GPS跟蹤程序的類圖:
圖 顯示GPS跟蹤程序主要組件的類圖
http://imgeducitycn/img_///gif border=>
首先該程序需要生成坐標的一些東西這是RandomWalkGenerator的工作從一個初始坐標開始每次對它的私有方法generateNextCoord()的調用都從該位置隨機走一步並返回一個GpsCoord對象當初始化時RandomWalkGenerator創建一個線程該線程在隨機間隔內調用generateNextCoorld()方法並發送生成的坐標給任何使用addListener()注冊自己的CoordListener實例
列表顯示了RandomWalkGenerator的循環邏輯:
列表 RandomWalkGenerator的run()方法
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
public void run() {
try {
while (true) {
int sleepMillis = + (int)(Mathrandom()*d);
Threadsleep(sleepMillis);
dispatchUpdate(generateNextCoord());
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
CoordListener是一個定義了onCoord(GpsCoord coord)方法的回調接口
在例子中
ContinuationBasedTracker類實現了CoordListener
ContinuationBasedTracker的另外一個方法為getNextPosition(Continuation
int)
列表
顯示了這些方法的具體實現:
列表
ContinuationBasedTracker的內髒
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
public GpsCoord getNextPosition(Continuation continuation int timeoutSecs) {
synchronized(this) {
if (!continuationisPending()) {
pendingContinuationsadd(continuation);
}
// wait for next update
continuationsuspend(timeoutSecs*);
}
return (GpsCoord)continuationgetObject();
}
public void onCoord(GpsCoord gpsCoord) {
synchronized(this) {
for (Continuation continuation : pendingContinuations) {
continuationsetObject(pgsCoord);
continuationresume();
}
pendingContinuationsclear();
}
}
當客戶端在Continuation裡調用getNextPosition()時isPending()方法檢查這次請求不是重試然後添加它到一個等待坐標的Continuations集合裡然後Continuation被暫停同時onCoord 當生成新坐標時調用 簡單的循環每個未決Continuations為它們設置GPS坐標然後恢復它們然後每個重試的請求完成getNextPosition()的執行從Continuation得到GpsCoord並返回它給調用者注意這裡需要同步不僅預防pendingContinuations集合裡出現不一致的狀態也確保了新添加的Continuation在它被暫停之前不會被恢復
謎題最後一部分是servlet代碼本身顯示於列表:
列表 GPSTrackerServlet實現
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
public class GpsTrackerServlet extends HttpServlet {
private static final int TIMEOUT_SECS = ;
private ContinuationBasedTracker tracker = new ContinuationBasedTracker();
public void service(HttpServletRequest req HttpServletResponse res) throws javaioIOException {
Continuation c ContinuationSupportgetContinuation(req null);
GpsCoord position = trackergetNextPosition(c TIMEOUT_SECS);
String json = new Jsonifier()toJson(position);
resgetWriter()print(json);
}
}
您可以看到
servlet所做很少
它簡單的維持請求的Continuation
調用getNextPosition()
轉換GPSCoord為JavaScript Object Notation(JSON)並輸出
這裡不需要防止任何代碼重執行
所以我不需要檢查isPending()
列表
顯示了對GpsTrackerServlet的調用的輸出
使用服務器可得到的單一線程上的
個並發請求
列表
GPSTrackerServlet輸出
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
$ for i in seq ; do lynx dump localhost:/tracker & done
{ coord : { lat : lng : } }
{ coord : { lat : lng : } }
{ coord : { lat : lng : } }
{ coord : { lat : lng : } }
{ coord : { lat : lng : } }
這個例子不是很引人注目但卻是概念的證明
在請求分派後
它們被維持開發幾秒鐘直到生成坐標
這時迅速產生應答
這是Comet模式的基本原理
使用Jetty單一線程處理
個並發請求
感謝Continuations
創建一個Comet客戶端
現在您已經看到Continuations怎樣用於創建非阻塞Web服務您可能想知道怎樣創建客戶端代碼來使用它一個Comet客戶端需要:
維持一個XMLHttpRequest連接直到接收應答
分派應答給合適的JavaScript處理者
立即建立一個新連接
更高級的Comet可以在客戶端和服務器使用合適的路由機制來使用一個連接來從多個不同的服務推數據到浏覽器一個可能性為使用JavaScript庫如Dojo等寫客戶端代碼來提供基於Comet的請求機制形如et
盡管如此如果您正在使用Java作為服務器語言在客戶端和服務器端得到高級Comet支持的更好的方式是使用DWR 如果您不熟悉DWR您可以該系列的第部分Ajax with Direct Web RemotingDWR透明的提供一個HTTPRPC傳輸層暴露您的Java對象來使用JavaScript代碼調用DWR生成客戶端代理自動marshall和unmarshall數據處理安全問題提供一個便利的客戶端輔助庫並且對所有主要的浏覽器工作
DWR :反轉Ajax
DWR 新引入的概念為反轉Ajax該機制將服務端事件推給客戶端客戶端DWR代碼透明的處理連接建立和應答解析所以從開發人員的角度來看事件可以從服務端Java代碼簡單的發布到客戶端
DWR可以配置使用個不同的機制來反轉Ajax一種是我們熟悉的輪詢方式第二種方式稱為piggyback它不創建任何到服務器的連接而是等待直到另一個DWR服務調用發生並piggyback未決事件到該請求應答這可以獲得高效率但是意味著客戶端事件通知被延遲直到客戶端作出一個不相干的調用最後一種機制使用Comet風格的長連接最好的是當DWR運行在Jetty下並且使用Continuations來獲得非阻塞Comet時可以自動檢測事件
我將修改我的GPS例子來使用DWR 反轉Ajax同時您將看到反轉Ajax怎樣工作的更多細節
我不再需要我的servletDWR提供了一個controller servlet它協調客戶端請求直接訪問Java對象我也不再需要顯示處理Continuations因為DWR在幕後處理了這些所以我只需要一個新的CoordListener實現來發布坐標更新到任何客戶端浏覽器
一個稱為ServerContext的接口提供DWR的反轉Ajax魔法ServerContext知道當前查看一個給定頁面的所有Web客戶端並且可以提供一個ScriptSession來與每個客戶端交流ScriptSession用來從Java代碼推JavaScript片段到客戶端列表顯示了ReverseAjaxTracker怎樣響應坐標通知以及使用它們來生成客戶端updateCoordinate()方法調用注意如果一個合適的轉換器是可用的則DWR的ScriptBuffer對象的appendData()調用會自動marshall一個Java對象到JSON
列表 ReverseAjaxTracker裡的通知回調方法
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
public void onCoord(GpsCoord gpsCoord) {
// Generate JavaScriptcode to call clientside
// function with coord data
ScriptBuffer script = new ScriptBuffer();
scriptappendScript(updateCoordinate()appendData(gpsCoord)appendScript(););
// Push script out to clients viewing the page
Collection<ScriptSession> sessions = sctxgetScriptSessionsByPage(pageUrl);
for (ScriptSession session : sessions) {
sessionaddScript(script);
}
}
下一步
DWR必須配置來知道ReverseAjaxTracker
在更大的程序裡
DWR的Spring集成可以使用Spring創建的beans來提供DWR
但是這裡
我將僅僅讓DWR創建一個新的ReverseAjaxTracker實例並把它放在application作用域裡
所有後續的DWR請求將訪問這個單一的實例
我也需要告訴DWR怎樣從GpsCoord beans來marshall數據到JSON由於GpsCoord是一個簡單對象DWR基於反射的BeanConverter足夠
列表顯示了ReverseAjaxTracker配置
列表 ReverseAjaxTracker的DWR配置
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
<dwr>
<allow>
<create creator=new javascrit=Tracker scope=application>
<param name=class value=developerworksjettygpstrackerReverseAjaxTracker/>
</create>
<convert converter=bean match=developerworksjettygpstrackerGpsCoord/>
</allow>
</dwr>
create元素的javascript元素指定了DWR用來暴露tracker作為一個JavaScript對象的名字但是在這裡我的客戶端代碼不會使用它而是從tracker推數據給它同時也需要在webxml裡做一些額外的配置來讓DWR使用反轉Ajax見列表
列表 DwrServlet的webxml配置
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
<servlet>
<servletname>dwrinvoker</servletname>
<servletclass>
orgdirectwebremoteingservletDwrServlet
</servletclass>
<initparam>
<paramname>activeReverseAjaxEnabled</paramname>
<paramvalue>true</paramvalue>
</initparam>
<initparam>
<paramname>initApplicationScopeCreatorsAtStartup</paramname>
<paramvalue>true</paramvalue>
</initparam>
</servlet>
第一個servlet init
param
activeReverseAjaxEnabled
激活輪詢和Comet功能
第二個
initApplicationScopeCreatorsAtStartup
告訴DWR當程序開始時初始化ReverseAjaxTracker
這會覆蓋通常在bean上作第一次請求時的延遲初始化行為
在這裡這是很有必要的
因為客戶端從不在ReverseAjaxTracker上調用方法
最後我需要實現從DWR調用的客戶端JavaScript方法回調方法updateCoordinate()被傳遞一個JSON形式的GpsCoord對象它由DWR的BeanConverter自動序列化這個方法僅僅從坐標提取longitude和latitude域並通過DOM調用添加它們到一個列表裡這在列表裡顯示了同我的頁面的onload方法一起onload包含對dwrenginesetActiveReverseAjax(true)這告訴DWR打開一個到服務器的持久的連接來等待回調
列表 反轉Ajax GPS跟蹤的客戶端實現
cellPadding= width= align=center bgColor=#fff border= heihgt=>
代碼
windowonload = function() {
dwrenginesetActiveReverseAjax(true);
}
function updateCoordinate(coord) {
if (coord) {
var li = documentcreateElement(li);
liappendChild(documentcreateTextNode(coordlongitude + + coordlatitude));
documentgetElementById(coords)appendChild(li);
}
}
現在我可以讓我的浏覽器訪問跟蹤程序頁面
當坐標數據開始生成時DWR將開始推數據到客戶端
這個實現將簡單的輸出一個生成的坐標列表
見圖
:
圖
ReverseAjaxTracker輸出
http://img
educity
cn/img_
/
/
/
jpg
border=
>
使用反轉Ajax創建一個事件驅動的Ajax程序是如此簡單
記住
感謝DWR對Jetty Continuations的使用
當等待新事件到達時線程不會阻塞在服務器
據此很容易從Yahoo!或者Google集成一個地圖窗口部件通過改變客戶端回調方法坐標可以簡單的傳遞到地圖API而不是直接添加到頁面圖顯示了在這樣的一個地圖組件上DWR反轉Ajax GPS跟蹤程序描繪的隨機路線:
圖 使用地圖UI的ReverseAjaxTracker
http://imgeducitycn/img_///jpg border=>
結論
現在您看到了Jetty Continuations聯合Comet可以提供一個高效的可伸縮的事件驅動Ajax程序的解決方案我沒有給出Continuations的伸縮性的圖因為性能在真是世界裡取決於許多變數服務器硬件操作系統的選擇JVM實現Jetty配置您的Web程序的設計和傳輸效率在負荷下都會影響Jetty Continuations的性能盡管如此Webtide的Greg Wilkins(首要的Jetty開發者) 發布了一個比較Jetty 集成Continuations與不集成Continuations的Comet程序處理並發請求時的性能的白皮書在Greg的測試裡使用Continuations並去掉了線程消費和棧內存消費使用大於的因數
您也看到了使用DWR的反轉Ajax技術實現事件驅動的Ajax程序是多麼容易DWR不僅節省您的客戶端和服務端代碼反轉Ajax也將整個服務器推機制從您的代碼中抽象出來您可以隨意轉換您的Comet方式:輪詢或者piggyback方式只需簡單的更改DWR配置您可以隨意試驗並找到適合您的程序的最佳策略而不會影響您的代碼
關於作者
Philip McCarthy是倫敦的一位軟件開發顧問專於Java和Web技術
From:http://tw.wingwit.com/Article/program/Java/Javascript/201311/25472.html