Servlet 3.0 实战:异步 Servlet 与 Comet 风格应用程序

概述javascript

做为 Java EE 6 体系中重要成员的 JSR 315 规范,将 Servlet API 最新的版本从 2.5 提高到了 3.0,这是近 10 年来 Servlet 版本号最大的一次升级,这次升级中引入了若干项令开发人员兴奋的特性,如:html

  • 可插拔的 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 程序。java

JSR 315 中的 Servlet 异步处理系列特性在不少场合都有用武之地,但人们最早看到的,是它们在“服务端推”(Server-Side Push)方式 —— 也称为 Comet 方式的交互模型中的价值。在 JCP(Java Community Process)网站上提出的 JSR 315 规范目标列表,关于异步处理这个章节的标题就直接定为了“Async and Comet support”(异步与 Comet 支持)。jquery

本文将详细介绍 Comet 风格应用的实现方式,以及 Servlet 3.0 中的异步处理特性在 Comet 风格程序中的实际应用。web

经典 Request-Response 交互模型的突破数据库

“Comet 技术”、“服务端推技术(Server-Side Push)”、“反向 Ajax 技术”这几个名称说的是同一件事情,可能您已经据说过其中的一项或者几项。但没据说过也没有关系,一句话就足以表达它们所有的意思:“在没有客户端请求的 状况下,服务端向客户端发送数据”。apache

这句话听起来很简单很好理解,可是任何一个长期从事 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 v三、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 同样普及。

相关文章
相关标签/搜索