異步 Servlet 與 Comet 風格應用

簡介: 自 JSR 315 規範(即 Servlet 3.0)的草案公開發布以來,最新一代 Servlet 規範的各種新特性被越來越多的開發人員所關注。規範中提到的一系列高級目標:如可插拔的 Web 框架、便捷開發特性、增強安全性支持等都令人期待。但其中關注程度最高的,毫無疑問是異步 Servlet。本文將詳細介紹 Comet 風格應用的實現方式,以及 Servlet 3.0 中的異步處理特性在 Comet 風格程序中的實際應用。

概述

作爲 Java EE 6 體系中重要成員的 JSR 315 規範,將 Servlet API 最新的版本從 2.5 提升到了 3.0,這是近 10 年來 Servlet 版本號最大的一次升級,此次升級中引入了若干項令開發人員興奮的特性,如:

  • 可插拔的 Web 架構(Web framework pluggability)。

  • 通過 Annotations 代替傳統 web.xml 配置文件的 EOD 易於開發特性(ease of development)。

  • Serlvet 異步處理支持。

  • 安全性提升,如 Http Only Cookies、login/logout 機制。

  • 其它改進,如文件上傳的直接支持等。

其中,在開源社區中討論得最多的就是 Servlet 異步處理的支持,所謂 Servlet 異步處理,包括了非阻塞的輸入/輸出、異步事件通知、延遲 request 處理以及延遲 response 輸出等幾種特性。這些特性大多並非 JSR 315 規範首次提出,譬如非阻塞輸入/輸出,在 Tomcat 6.0 中就提供了 Advanced NIO 技術以便一個 Servlet 線程能處理多個 Http Request,Jetty、GlassFish 也曾經有過類似的支持。但是使用這些 Web 容器提供的高級特性時,因爲現有的 Servlet API 沒有對這類應用的支持,所以都必須引入一些 Web 容器專有的類、接口或者 Annotations,導致使用了這部分高級特性,就必須與特定的容器耦合在一起,這對很多項目來說都是無法接受的。因此 JSR 315 將這些特性寫入規範,提供統一的 API 支持後,這類異步處理特性才真正具備廣泛意義上的實用性,只要支持 Servlet 3.0 的 Web 容器,就可以不加修改的運行這些 Web 程序。

JSR 315 中的 Servlet 異步處理系列特性在很多場合都有用武之地,但人們最先看到的,是它們在“服務端推”(Server-Side Push)方式 —— 也稱爲 Comet 方式的交互模型中的價值。在 JCP(Java Community Process)網站上提出的 JSR 315 規範目標列表,關於異步處理這個章節的標題就直接定爲了“Async and Comet support”(異步與 Comet 支持)。

本文將詳細介紹 Comet 風格應用的實現方式,以及 Servlet 3.0 中的異步處理特性在 Comet 風格程序中的實際應用。

經典 Request-Response 交互模型的突破

“Comet 技術”、“服務端推技術(Server-Side Push)”、“反向 Ajax 技術”這幾個名稱說的是同一件事情,可能您已經聽說過其中的一項或者幾項。但沒聽說過也沒有關係,一句話就足以表達它們全部的意思:“在沒有客戶端請求的 情況下,服務端向客戶端發送數據”。

這句話聽起來很簡單很好理解,但是任何一個長期從事 B/S 應用程序開發的程序都清楚,這實現起來並不簡單,甚至很長一段時間內,人們認爲這是並不可能的。因爲這種做法完全不符合傳統基於 HTTP 協議的交互思想:只有基於 Socket 層次的應用才能做到 Server 和 Client 端雙方對等通訊,而基於 HTTP 的應用中,Server 只是對來自 Client 的請求進行迴應,不關心客戶端的狀態,不主動向客戶端請求信息,因此 Http 協議被稱爲無狀態、單向性協議,這種交互方式稱爲 Request-Response 交互模型。

無狀態、單向的經典 Request-Response 交互模型有很多優點,譬如高效率、高可伸縮等。對於被動響應用戶請求爲主的應用,像 CMS、MIS、ERP 等非常適合,但是對於另外一些需要服務端主動發送的需求,像聊天室(用戶不發言的時候也需要把其它用戶的發言傳送回來)、日誌系統(客戶端沒有請求,當服 務端有日誌輸出時主動發送到客戶端)則處理起來很困難,或者說這類應用根本不適合使用經典的 Request-Response 交互模型來處理。當“不適合”與“有需求”同時存在時,人們就開始不斷尋找突破這種限制的方法。

Comet 實現的方法

  • 簡單輪詢

    最早期的 Web 應用中,主要通過 JavaScript 或者 Meta HTML 標籤等手段,定時刷新頁面來檢測服務端的變化。顯然定時刷新頁面服務端仍然在被動響應客戶端的請求,只不過客戶端的請求是連續、頻繁的,讓用戶看起來產生 有服務端自動將信息發過來的錯覺。這種方式簡單易行,但缺陷也非常明顯:可能大部分請求都是無意義的,因爲服務端期待的事件沒有發生,實際上並沒有需要發 送的信息,而不得不重複的迴應着頁面上所有內容給瀏覽器;另外就是當服務端發生變化時,並不能“實時”的返回,刷新的間隔太短,產生很大的性能浪費,間隔 太長,事件通知又可能晚於用戶期望的時間到達。

    當絕大部分瀏覽器提供了 XHR(XmlHttpRequest)對象支持後,Ajax 技術出現並迅速流行,這一階段做的輪詢就不必每次都返回都返回整個頁面中所有的內容,如果服務端沒有事件產生,只需要返回極少量內容的 http 報文體。Ajax 可以節省輪詢傳輸中大量的帶寬浪費,但它無法減少請求的次數,因此 Ajax 實現的簡單輪詢仍然有輪詢的侷限性,對其缺陷只能一定程度緩解,而無法達到質變。

  • 長輪詢(混合輪詢)

    長輪詢與簡單輪詢的最大區別就是連接時間的長短:簡單輪詢時當頁面輸出完連接就關閉了,而長輪詢一般會保持 30 秒乃至更長時間,當服務器上期待的事件發生,將會立刻輸出事件通知到客戶端,接着關閉連接,同時建立下一個連接開始一次新的長輪詢。

    長輪詢的實現方式優勢在於當服務端期待事件發生,數據便立即返回到客戶端,期間沒有數據返回,再較長的等待時間內也沒有新的請求發生,這樣可以讓發送的請求減少很多,而事件通知的靈敏度卻大幅提高到幾乎是“實時”的程度。

  • Comet 流(Forever Frame)

    Comet 流是按照長輪詢的實現思路進一步發展的產物。令長輪詢將事件通知發送回客戶端後不再關閉連接,而是一直保持直到超時事件發生才重新建立新的連接,這種變體 我們就稱爲 Comet 流。客戶端可以使用 XmlHttpRequest 對象中的 readyState 屬性來判斷是 Receiving 還是 Loaded。Comet 流理論上可以使用一個鏈接來處理若干次服務端事件通知,更進一步節省了發送到服務端的請求次數。

無論是長輪詢還是 Comet 流,在服務端和客戶端都需要維持一個比較長時間的連接狀態,這一點在客戶端不算什麼太大的負擔,但是服務端是要同時對多個客戶端服務的,按照經典 Request-Response 交互模型,每一個請求都佔用一個 Web 線程不釋放的話,Web 容器的線程則會很快消耗殆盡,而這些線程大部分時間處於空閒等待的狀態。這也就是爲什麼 Comet 風格服務非常期待異步處理的原因,希望 Web 線程不需要同步的、一對一的處理客戶端請求,能做到一個 Web 線程處理多個客戶端請求。

實戰 Servlet 異步處理

當前已經有不少支持 Servlet API 3.0 的 Web 容器,如 GlassFish v3、Tomcat 7.0、Jetty 8.0 等,在本文撰寫時,Tomcat 7 和 Jetty 8 都仍然處於測試階段,雖然支持 Servlet 3.0,但是提供的樣例代碼仍然是與容器耦合的 NIO 實現,GlassFish v3 提供的樣例(玻璃魚聊天室)則是完全標準的 Servlet 3.0 實現,如果讀者需要做找參考樣例,不妨優先查看 GlassFish 的 example 目錄。本文後一部分會提供另外一個更具備實用性的例子“Web 日誌系統”作爲 Servlet API 3.0 的實戰演示進行講解。

Web 日誌系統實戰

Apache Log4j 是當前最主流的日誌處理器,它有許多不同的 Appender 可以將日誌輸出到控制檯、文件、數據庫、Email 等等。在大部分應用中用戶都不可能查看服務器的控制檯或者日誌文件,如果能直接在瀏覽器上“實時”的查看日誌將會是給開發維護帶來方便,在本例中將實現一 個日誌輸出到瀏覽器的 Appender 實現。


清單 1. Log4j 異步 Web Appender


/** 
 * 基於 AsyncContext 支持的 Appender 
 * @author zzm 
 */ 
 public class WebLogAppender extends WriterAppender { 
     /** 
     * 異步 Servlet 上下文隊列
     */ 
     public static final Queue<AsyncContext> ASYNC_CONTEXT_QUEUE 
     = new ConcurrentLinkedQueue<AsyncContext>(); 

     /** 
     * AsyncContextQueue Writer 
     */ 
     private Writer writer = new AsyncContextQueueWriter(ASYNC_CONTEXT_QUEUE); 

     public WebLogAppender() { 
         setWriter(writer); 
     } 

     public WebLogAppender(Layout layout) { 
         this(); 
         super.layout = layout; 
     } 
 }

上面是 Appender 類的代碼模版,派生自 org.apache.log4j.WriterAppender,Log4j 默認提供的所有 Appender 都從此類繼承,子類代碼執行的邏輯僅僅是告知 WriterAppender 如何獲取 Writer。而我們最關心的如何異步將日誌信息輸出至瀏覽器,則在 AsyncContextQueueWriter 中完成。

清單 2:異步上下文隊列 Writer


/** 
 * 向一個 Queue<AsyncContext> 中每個 Context 的 Writer 進行輸出
 * @author zzm 
 */ 
 public class AsyncContextQueueWriter extends Writer { 

     /** 
     * AsyncContext 隊列
     */ 
     private Queue<AsyncContext> queue; 

     /** 
     * 消息隊列
     */ 
     private static final BlockingQueue<String> MESSAGE_QUEUE 
     = new LinkedBlockingQueue<String>(); 

     /** 
     * 發送消息到異步線程,最終輸出到 http response 流
     * @param cbuf 
     * @param off 
     * @param len 
     * @throws IOException 
     */ 
     private void sendMessage(char[] cbuf, int off, int len) throws IOException { 
         try { 
             MESSAGE_QUEUE.put(new String(cbuf, off, len)); 
         } catch (Exception ex) { 
             IOException t = new IOException(); 
             t.initCause(ex); 
             throw t; 
         } 
     } 

     /** 
     * 異步線程,當消息隊列中被放入數據,將釋放 take 方法的阻塞,將數據發送到 http response 流上
     */ 
     private Runnable notifierRunnable = new Runnable() { 
        public void run() { 
            boolean done = false; 
            while (!done) { 
                String message = null; 
                try { 
                    message = MESSAGE_QUEUE.take(); 
                    for (AsyncContext ac : queue) { 
                        try { 
                            PrintWriter acWriter = ac.getResponse().getWriter(); 
                            acWriter.println(htmlEscape(message)); 
                            acWriter.flush(); 
                        } catch (IOException ex) { 
                            System.out.println(ex); 
                            queue.remove(ac); 
                        } 
                    } 
                } catch (InterruptedException iex) { 
                    done = true; 
                    System.out.println(iex); 
                } 
            } 
        } 
     }; 

     /** 
     * @param message 
     * @return 
     */ 
     private String htmlEscape(String message) { 
         return "<script type='text/javascript'>\nwindow.parent.update(\""
         + message.replaceAll("\n", "").replaceAll("\r", "") + "\");</script>\n"; 
     } 

     /** 
     * 保持一個默認的 writer,輸出至控制檯
     * 這個 writer 是同步輸出,其它輸出到 response 流的 writer 是異步輸出
     */ 
     private static final Writer DEFAULT_WRITER = new OutputStreamWriter(System.out); 

     /** 
     * 構造 AsyncContextQueueWriter 
     * @param queue 
     */ 
     AsyncContextQueueWriter(Queue<AsyncContext> queue) { 
         this.queue = queue; 
         Thread notifierThread = new Thread(notifierRunnable); 
         notifierThread.start(); 
     } 

     @Override 
     public void write(char[] cbuf, int off, int len) throws IOException { 
         DEFAULT_WRITER.write(cbuf, off, len); 
         sendMessage(cbuf, off, len); 
     } 

     @Override 
     public void flush() throws IOException { 
         DEFAULT_WRITER.flush(); 
     } 

     @Override 
     public void close() throws IOException { 
         DEFAULT_WRITER.close(); 
         for (AsyncContext ac : queue) { 
             ac.getResponse().getWriter().close(); 
         } 
     } 
 }


這個類是 Web 日誌實現的關鍵類之一,它繼承至 Writer,實際上是一組 Writer 的集合,其中包含至少一個默認 Writer 將數據輸出至控制檯,另包含零至若干個由 Queue<AsyncContext> 所決定的 Response Writer 將數據輸出至客戶端。輸出過程中,控制檯的 Writer 是同步的直接輸出,輸出至 http 客戶端的則由線程 notifierRunnable 進行異步輸出。具體實現方式是信息放置在阻塞隊列 MESSAGE_QUEUE 中,子線程循環時使用到這個隊列的 take() 方法,當隊列沒有數據這個方法將會阻塞線程直到等到新數據放入隊列爲止。

我們在 Log4j.xml 中修改一下配置,將 Appender 切換爲 WebLogAppender,那對 Log4j 本身的擴展就算完成了:

清單 3:Log4j.xml 配置


   <appender name="CONSOLE" class="org.fenixsoft.log.WebLogAppender"> 
      <param name="Threshold" value="DEBUG"/> 
      <layout class="org.apache.log4j.PatternLayout"> 
         <!-- The default pattern: Date Priority [Category] Message\n --> 
         <param name="ConversionPattern" value="%d %p [%c] %m%n"/> 
      </layout> 
   </appender>

接着,建立一個支持異步的 Servlet,目的是每個訪問這個 Servlet 的客戶端,都在 ASYNC_CONTEXT_QUEUE 中註冊一個異步上下文對象,這樣當有 Logger 信息發生時,就會輸出到這些客戶端。同時,將建立一個針對這個異步上下文對象的監聽器,當產生超時、錯誤等事件時,將此上下文從隊列中移除。

清單 4:Web 日誌註冊 Servlet


 /** 
 * Servlet implementation class WebLogServlet 
 */ 
 @WebServlet(urlPatterns = { "/WebLogServlet" }, asyncSupported = true) 
 public class WebLogServlet extends HttpServlet { 

    /** 
     * serialVersionUID 
     */ 
    private static final long serialVersionUID = -260157400324419618L; 

    /** 
     * 將客戶端註冊到監聽 Logger 的消息隊列中
     */ 
    @Override 
    protected void doGet(HttpServletRequest req, HttpServletResponse res) 
    throws ServletException, IOException { 
        res.setContentType("text/html;charset=UTF-8"); 
        res.setHeader("Cache-Control", "private"); 
        res.setHeader("Pragma", "no-cache"); 
        req.setCharacterEncoding("UTF-8"); 
        PrintWriter writer = res.getWriter(); 
        // for IE 
        writer.println("<!-- Comet is a programming technique that enables web 
        servers to send data to the client without having any need for the client 
        to request it. -->\n"); 
        writer.flush(); 

        final AsyncContext ac = req.startAsync(); 
        ac.setTimeout(10 * 60 * 1000); 
        ac.addListener(new AsyncListener() { 
            public void onComplete(AsyncEvent event) throws IOException { 
                WebLogAppender.ASYNC_CONTEXT_QUEUE.remove(ac); 
            } 

            public void onTimeout(AsyncEvent event) throws IOException { 
                WebLogAppender.ASYNC_CONTEXT_QUEUE.remove(ac); 
            } 

            public void onError(AsyncEvent event) throws IOException { 
                WebLogAppender.ASYNC_CONTEXT_QUEUE.remove(ac); 
            } 

            public void onStartAsync(AsyncEvent event) throws IOException { 
            } 
        }); 
        WebLogAppender.ASYNC_CONTEXT_QUEUE.add(ac); 
    } 
 }

服務端處理到此爲止差不多就結束了,我們再看看客戶端的實現。其實客戶端我們直接訪問這個 Servlet 就可以看到瀏覽器不斷的有日誌輸出,並且這個頁面的滾動條會一直持續,顯示 http 連接並沒有關閉。爲了顯示,我們還是對客戶端進行了包裝,通過一個隱藏的 frame 去讀取 WebLogServlet 發出的信息,既 Comet 流方式實現。

清單 5:客戶端頁面


<html> 
 <head></head> 
 <script type="text/javascript" src="js/jquery-1.4.min.js"></script> 
 <script type="text/javascript" src="js/application.js"></script> 
 <style> 
     .consoleFont{font-size:9; color:#DDDDDD; font-family:Fixedsys} 
     .inputStyle{font-size:9; color:#DDDDDD; font-family:Fixedsys; width:100%; 
            height:100%; border:0; background-color:#000000;} 
 </style> 
 <body style="margin:0; overflow:hidden" > 
 <table width="100%" height="100%" border="0" cellpadding="0" 
     cellspacing="0" bgcolor="#000000"> 
  <tr> 
    <td colspan="2"><textarea name="result" id="result" readonly="true" wrap="off" 
         style="padding: 10; overflow:auto" class="inputStyle" ></textarea></td> 
  </tr> 
 </table> 
 <iframe id="comet-frame" style="display: none;"></iframe> 
 </body> 
 </html>

清單 6:客戶端引用的 application.js


$(document).ready(function() { 
     var url = '/AsyncServlet/WebLogServlet'; 
     $('#comet-frame')[0].src = url; 
 }); 

 function update(data) { 
     var resultArea = $('#result')[0]; 
     resultArea.value = resultArea.value + data + '\n'; 
 }

爲了模擬日誌輸出,我們讀取了一個已有的日誌文件,將內容調用 Logger 輸出到瀏覽器,讀者在調試時直接運行源碼包中的 TestServlet 即可,運行後整體效果如下所示:

圖 1. 運行效果
運行效果

結束語

Comet 的出現爲 Web 交互帶來了全新的體驗,而 Servlet 3.0 和異步 IO 則爲 Comet 實現過程中服務端 Web 線程佔用的問題提供了規範的解決方案。隨着各種支持 Servlet 3.0 容器的出現,Comet 的應用將越來越頻繁,目前開發 Comet 應用還是具有一定的挑戰性,但隨着需求推動技術的發展,相信 Comet 的應用會變得和 AJAX 一樣普及。


原文地址:http://www.ibm.com/developerworks/cn/java/j-lo-comet/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章