使用Java实现类似Comet风格的web app
开始
在本文中,我将展示如何使用各种不同的Java技术构建一些简单的Comet风格的Web应用程序。读者对JavaServlet、Ajax和JavaScript应该有一定的了解。我们将考察Tomcat和Jetty中一些支持Comet的特性,因此需要使用这两个产品的最新版本。本文使用Tomcat6.0.14和Jetty6.1.14.另外还需要一个支持Java5或更高版本的JDK.本文使用JDK1.5.0-16.此外还需要看看Jetty7的预发布版,因为它实现了Servlet3.0规范,我们将在本文中研究该规范。
理解Comet
您可能已经听说过Comet,因为它最近受到了一定的关注。Comet有时也称反向Ajax或服务器端推技术(server-sidepush)。其思想很简单:将数据直接从服务器推到浏览器,而不必等到浏览器请求数据。听起来简单,但是如果熟悉Web应用程序,尤其是HTTP协议,那么您就会知道,这绝不简单。实现Comet风格的Web应用程序,同时保证在浏览器和服务器上的可伸缩性,这只是在最近几年才成为可能。在本文的后面,我们将看看一些流行的JavaWeb服务器如何支持可伸缩的Comet架构,但首先我们来看看为什么要创建Comet应用程序,以及用于实现它们的常见设计模式。
使用Comet的动机
HTTP协议的成功毋庸置疑。它是Internet上大部分信息交换的基础。然而,它也有一些局限性。特别是,它是无状态、单向的协议。请求被发送到Web服务器,服务器处理请求并发回一个响应—仅此而已。请求必须由客户机发出,而服务器则只能在对请求的响应中发送数据。这至少会影响很多类型的Web应用程序的实用性。典型的例子就是聊天程序。另外还有一些例子,例如比赛的比分、股票行情或电子邮件程序。
HTTP的这些局限性也是它取得一定成功的原因。请求/响应周期使它成为了经典的模型,即每个连接使用一个线程。只要能够快速为请求提供服务,这种方法就有巨大的可伸缩性。每秒钟可以处理大量的请求,只需使用少量的服务器就可以处理很大数量的用户。对于很多经典的Web应用程序,例如内容管理系统、搜索应用程序和电子商务站点等等而言,这非常适合。在以上任何一种Web应用程序中,服务器提供用户请求的数据,然后关闭连接,并释放那个线程,使之可以为其他请求服务。如果提供初始数据之后仍可能存在交互,那么将连接保持为打开状态,因此线程就不能释放出来,服务器也就不能为很多用户服务。
但是,如果想在对请求做出响应并发送初始数据之后,仍然保持与用户的交互呢?在Web早期,这一点常使用meta刷新实现。这将自动指示浏览器在指定秒数之后重新装载页面,从而支持简陋的轮询(polling)。这不仅是一种糟糕的用户体验,而且通常效率非常低下。如果没有新的数据要显示在页面上呢?这时不得不重新呈现同样的页面。如果对页面的更改很少,并且页面的大部分没有变化呢?同样,不管是否有必要,都得重新请求和获取页面上的一切内容。
Ajax的发明和流行改变了上述状况。现在,服务器可以异步通信,因此不必重新请求整个页面。现在可以进行增量式的更新。只需使用XMLHttpRequest轮询服务器。这项技术通常被称作Comet.这项技术存在一些变体,每种变体具有不同的性能和可伸缩性。我们来看看这些不同风格的Comet.
Comet风格
Ajax的出现使Comet成为可能。HTTP的单向性质可以有效地加以规避。实际上有一些不同的方法可以绕过这一点。您可能已经猜到,支持Comet的最容易的方式是轮询(poll)。使用XMLHttpRequest向服务器发出调用,返回后,等待一段固定的时间(通常使用JavaScript的setTimeout函数),然后再次调用。这是一项非常常见的技术。例如,大多数webmail应用程序就是通过这种技术在电子邮件到达时显示电子邮件的。
这项技术有优点也有缺点。在这种情况下,您期望快速返回响应,就像任何其他Ajax请求一样。在请求之间必须有一段暂停。否则,连续不断的请求会冲垮服务器,并且这种情况下显然不具有可伸缩性。这段暂停使应用程序产生一个延时。暂停的时间越长,服务器上的新数据就需要越多的时间才能到达客户机。如果缩短暂停时间,又将重新面临冲垮服务器的风险。但是另一方面,这显然是最简单的实现Comet的方式。
现在应该指出,很多人认为轮询并不属于Comet.相反,他们认为Comet是对轮询的局限性的一个解决方案。最常见的“真正的”Comet技术是轮询的一种变体,即长轮询(longpolling)。轮询与长轮询之间的主要区别在于服务器花多长的时间作出响应。长轮询通常将连接保持一段较长的时间—通常是数秒钟,但是也可能是一分钟甚至更长。当服务器上发生某个事件时,响应被发送并随即关闭,轮询立即重新开始。
长轮询相对于一般轮询的优点在于,数据一旦可用,便立即从服务器发送到客户机。请求可能等待较长的时间,期间没有任何数据返回,但是一旦有了新的数据,它将立即被发送到客户机。因此没有延时。如果您使用过基于Web的聊天程序,或者声称“实时”的任何程序,那么它很可能就是使用了这种技术。
长轮询有一种变体,这是第三种风格的Comet.这通常被称为流(streaming)。按照这种风格,服务器将数据推回客户机,但是不关闭连接。连接将一直保持开启,直到过期,并导致重新发出请求。XMLHttpRequest规范表明,可以检查readyState的值是否为3或Receiving(而不是4或Loaded),并获取正从服务器“流出”的数据。和长轮询一样,这种方式也没有延时。当服务器上的数据就绪时,该数据被发送到客户机。这种方式的另一个优点是可以大大减少发送到服务器的请求,从而避免了与设置服务器连接相关的开销和延时。不幸的是,XMLHttpRequest在不同的浏览器中有很多不同的实现。这项技术只能在较新版本的MozillaFirefox中可靠地使用。对于InternetExplorer或Safari,仍需使用长轮询。
至此,您可能会想,长轮询和流都有一个很大的问题。请求需要在服务器上存在一段较长的时间。这打破了每个请求使用一个线程的模型,因为用于一个请求的线程一直没有被释放。更糟糕的是,除非要发回数据,否则该线程一直处于空闲状态。这显然不具有可伸缩性。幸运的是,现代JavaWeb服务器有很多方式可以解决这个问题。
Java中的Comet
现在有很多Web服务器是用Java构建的。一个原因是Java有一个丰富的本地线程模型。因此实现典型的每个连接一个线程的模型便非常简单。该模型对于Comet不大适用,但是,Java对此同样有解决的办法。为了有效地处理Comet,需要非阻塞IO,Java通过它的NIO库提供非阻塞IO.两种最流行的开源服务器ApacheTomcat和Jetty都利用NIO增加非阻塞IO,从而支持Comet.然而,这两种服务器中的实现却各不相同。我们来看看Tomcat和Jetty对Comet的支持。
Tomcat和Comet
对于ApacheTomcat,要使用Comet,主要需要做两件事。首先,需要对Tomcat的配置文件server.XML稍作修改。默认情况下启用的是更典型的同步IO连接器。现在只需将它切换成异步版本,如清单1所示。
清单1.修改Tomcat的server.xml
<!--ThisistheusualConnector,commentitoutandaddtheNIOone--> <!--ConnectorURIEncoding="utf-8"connectionTimeout="20000"port="8084" protocol="HTTP/1.1"redirectPort="8443"/--> <ConnectorconnectionTimeout="20000"port="8080"protocol="org.apache. coyote.http11.Http11NioProtocol"redirectPort="8443"/>
Servlet.这显然是Tomcat特有的一个接口。清单2显示了一个这样的例子。
清单2.TomcatCometservlet
publicclassTomcatWeatherServletextendsHttpServletimplementsCometProcessor{ privateMessageSendermessageSender=null; privatestaticfinalIntegerTIMEOUT=60*1000; @Override publicvoiddestroy(){ messageSender.stop(); messageSender=null; } @Override publicvoidinit()throwsServletException{ messageSender=newMessageSender(); ThreadmessageSenderThread= newThread(messageSender,"MessageSender["+getServletContext() .getContextPath()+"]"); messageSenderThread.setDaemon(true); messageSenderThread.start(); } publicvoidevent(finalCometEventevent)throwsIOException,ServletException{ HttpServletRequestrequest=event.getHttpServletRequest(); HttpServletResponseresponse=event.getHttpServletResponse(); if(event.getEventType()==CometEvent.EventType.BEGIN){ request.setAttribute("org.apache.tomcat.comet.timeout",TIMEOUT); log("Beginforsession:"+request.getSession(true).getId()); messageSender.setConnection(response); Weathermanweatherman=newWeatherman(95118,32408); newThread(weatherman).start(); }elseif(event.getEventType()==CometEvent.EventType.ERROR){ log("Errorforsession:"+request.getSession(true).getId()); event.close(); }elseif(event.getEventType()==CometEvent.EventType.END){ log("Endforsession:"+request.getSession(true).getId()); event.close(); }elseif(event.getEventType()==CometEvent.EventType.READ){ thrownewUnsupportedOperationException("Thisservletdoesnotaccept data"); } } }
CometProcessor接口要求实现event方法。这是用于Comet交互的一个生命周期方法。Tomcat将使用不同的CometEvent实例调用。通过检查CometEvent的eventType,可以判断正处在生命周期的哪个阶段。当请求第一次传入时,即发生BEGIN事件。READ事件表明数据正在被发送,只有当请求为POST时才需要该事件。遇到END或ERROR事件时,请求终止。
在清单2的例子中,Servlet使用一个MessageSender类发送数据。这个类的实例是在servlet的init方法中在其自身的线程中创建,并在servlet的destroy方法中销毁的。清单3显示了MessageSender.
清单3.MessageSender
privateclassMessageSenderimplementsRunnable{ protectedbooleanrunning=true; protectedfinalArrayListmessages=newArrayList(); privateServletResponseconnection; privatesynchronizedvoidsetConnection(ServletResponseconnection){ this.connection=connection; notify(); } publicvoidsend(Stringmessage){ synchronized(messages){ messages.add(message); log("Messageadded#messages="+messages.size()); messages.notify(); } } publicvoidrun(){ while(running){ if(messages.size()==0){ try{ synchronized(messages){ messages.wait(); } }catch(InterruptedExceptione){ //Ignore } } String[]pendingMessages=null; synchronized(messages){ pendingMessages=messages.toArray(newString[0]); messages.clear(); } try{ if(connection==null){ try{ synchronized(this){ wait(); } }catch(InterruptedExceptione){ //Ignore } } PrintWriterwriter=connection.getWriter(); for(intj=0;j<pendingMessages.length;j++){ finalStringforecast=pendingMessages[j]+" "; writer.println(forecast); log("Writing:"+forecast); } writer.flush(); writer.close(); connection=null; log("Closingconnection"); }catch(IOExceptione){ log("IOExeptionsendingmessage",e); } } } }
这个类基本上是样板代码,与Comet没有直接的关系。但是,有两点要注意。这个类含有一个ServletResponse对象。回头看看清单2中的event方法,当事件为BEGIN时,response对象被传入到MessageSender中。在MessageSender的run方法中,它使用ServletResponse将数据发送回客户机。注意,一旦发送完所有排队等待的消息后,它将关闭连接。这样就实现了长轮询。如果要实现流风格的Comet,那么需要使连接保持开启,但是仍然刷新数据。
回头看清单2可以发现,其中创建了一个Weatherman类。正是这个类使用MessageSender将数据发送回客户机。这个类使用YahooRSSfeed获得不同地区的天气信息,并将该信息发送到客户机。这是一个特别设计的例子,用于模拟以异步方式发送数据的数据源。清单4显示了它的代码。
清单4.Weatherman
privateclassWeathermanimplementsRunnable{ privatefinalListzipCodes; privatefinalStringYAHOO_WEATHER="http://weather.yahooapis.com/forecastrss?p="; publicWeatherman(Integer...zips){ zipCodes=newArrayList(zips.length); for(Integerzip:zips){ try{ zipCodes.add(newURL(YAHOO_WEATHER+zip)); }catch(Exceptione){ //dontadditifitsucks } } } publicvoidrun(){ inti=0; while(i>=0){ intj=i%zipCodes.size(); SyndFeedInputinput=newSyndFeedInput(); try{ SyndFeedfeed=input.build(newInputStreamReader(zipCodes.get(j) .openStream())); SyndEntryentry=(SyndEntry)feed.getEntries().get(0); messageSender.send(entryToHtml(entry)); Thread.sleep(30000L); }catch(Exceptione){ //justeatit,eatit } i++; } } privateStringentryToHtml(SyndEntryentry){ StringBuilderhtml=newStringBuilder(" "); html.append(entry.getTitle()); html.append(" "); html.append(entry.getDescription().getValue()); returnhtml.toString(); } }
这个类使用ProjectRome库解析来自YahooWeather的RSSfeed.如果需要生成或使用RSS或Atomfeed,这是一个非常有用的库。此外,这个代码中只有一个地方值得注意,那就是它产生另一个线程,用于每过30秒钟发送一次天气数据。最后,我们再看一个地方:使用该Servlet的客户机代码。在这种情况下,一个简单的JSP加上少量的JavaScript就足够了。清单5显示了该代码。
清单5.客户机Comet代码
"http://www.w3.org/TR/html4/loose.dtd"> varrequest=newXMLHttpRequest(); request.open("GET",url,true); request.setRequestHeader("Content-Type","application/x-javascript;"); request.onreadystatechange=function(){ if(request.readyState==4){ if(request.status==200){ if(request.responseText){ document.getElementById("forecasts").innerHTML= request.responseText; } } go(); } }; request.send(null); }
该代码只是在用户单击Go按钮时开始长轮询。注意,它直接使用XMLHttpRequest对象,所以这在InternetExplorer6中将不能工作。您可能需要使用一个Ajax库解决浏览器差异问题。除此之外,惟一需要注意的是回调函数,或者为请求的onreadystatechange函数创建的闭包。该函数粘贴来自服务器的新的数据,然后重新调用go函数。
现在,我们看过了一个简单的Comet应用程序在Tomcat上是什么样的。有两件与Tomcat密切相关的事情要做:一是配置它的连接器,二是在Servlet中实现一个特定于Tomcat的接口。您可能想知道,将该代码“移植”到Jetty有多大难度。接下来我们就来看看这个问题。
Jetty和Comet
Jetty服务器使用稍微不同的技术来支持Comet的可伸缩的实现。Jetty支持被称作continuations的编程结构。其思想很简单。请求先被暂停,然后在将来的某个时间点再继续。规定时间到期,或者某种有意义的事件发生,都可能导致请求继续。当请求被暂停时,它的线程被释放。
可以使用Jetty的org.mortbay.util.ajax.ContinuationSupport类为任何HttpServletRequest创建org.mortbay.util.ajax.Continuation的一个实例。这种方法与Comet有很大的不同。但是,continuations可用于实现逻辑上等效的Comet.清单6显示清单2中的weatherservlet“移植”到Jetty后的代码。
清单6.JettyCometservlet
publicclassJettyWeatherServletextendsHttpServlet{ privateMessageSendermessageSender=null; privatestaticfinalIntegerTIMEOUT=5*1000; publicvoidbegin(HttpServletRequestrequest,HttpServletResponseresponse) throwsIOException,ServletException{ request.setAttribute("org.apache.tomcat.comet",Boolean.TRUE); request.setAttribute("org.apache.tomcat.comet.timeout",TIMEOUT); messageSender.setConnection(response); Weathermanweatherman=newWeatherman(95118,32408); newThread(weatherman).start(); } publicvoidend(HttpServletRequestrequest,HttpServletResponseresponse) throwsIOException,ServletException{ synchronized(request){ request.removeAttribute("org.apache.tomcat.comet"); Continuationcontinuation=ContinuationSupport.getContinuation (request,request); if(continuation.isPending()){ continuation.resume(); } } } publicvoiderror(HttpServletRequestrequest,HttpServletResponseresponse) throwsIOException,ServletException{ end(request,response); } publicbooleanread(HttpServletRequestrequest,HttpServletResponseresponse) throwsIOException,ServletException{ thrownewUnsupportedOperationException(); } @Override protectedvoidservice(HttpServletRequestrequest,HttpServletResponseresponse) throwsIOException,ServletException{ synchronized(request){ Continuationcontinuation=ContinuationSupport.getContinuation (request,request); if(!continuation.isPending()){ begin(request,response); } Integertimeout=(Integer)request.getAttribute ("org.apache.tomcat.comet.timeout"); booleanresumed=continuation.suspend(timeout==null?10000: timeout.intValue()); if(!resumed){ error(request,response); } } } publicvoidsetTimeout(HttpServletRequestrequest,HttpServletResponseresponse, inttimeout)throwsIOException,ServletException, UnsupportedOperationException{ request.setAttribute("org.apache.tomcat.comet.timeout",newInteger(timeout)); } }
这里最需要注意的是,该结构与Tomcat版本的代码非常类似。begin、read、end和error方法都与Tomcat中相同的事件匹配。该Servlet的service方法被覆盖为在请求第一次进入时创建一个continuation并暂停该请求,直到超时时间已到,或者发生导致它重新开始的事件。上面没有显示init和destroy方法,因为它们与Tomcat版本是一样的。该servlet使用与Tomcat相同的MessageSender.因此不需要修改。注意begin方法如何创建Weatherman实例。对这个类的使用与Tomcat版本中也是完全相同的。甚至客户机代码也是一样的。只有servlet有更改。虽然servlet的变化比较大,但是与Tomcat中的事件模型仍是一一对应的。
希望这足以鼓舞人心。虽然完全相同的代码不能同时在Tomcat和Jetty中运行,但是它是非常相似的。当然,JavaEE吸引人的一点是可移植性。大多数在Tomcat中运行的代码,无需修改就可以在Jetty中运行,反之亦然。因此,毫不奇怪,下一个版本的JavaServlet规范包括异步请求处理(即Comet背后的底层技术)的标准化。我们来看看这个规范:Servlet3.0规范。
Servlet3.0规范
在此,我们不深究Servlet3.0规范的全部细节,只看看Cometservlet如果在Servlet3.0容器中运行,可能会是什么样子。注意“可能”二字。该规范已经发布公共预览版,但在撰写本文之际,还没有最终版。因此,清单7显示的是遵从公共预览规范的一个实现。
清单7.Servlet3.0Comet
@WebServlet(asyncSupported=true,asyncTimeout=5000) publicclassWeatherServletextendsHttpServlet{ privateMessageSendermessageSender; //initanddestroyarethesameasother @Override protectedvoiddoGet(HttpServletRequestrequest,HttpServletResponseresponse) throwsServletException,IOException{ AsyncContextasync=request.startAsync(request,response); messageSender.setConnection(async); Weathermanweatherman=newWeatherman(95118,32444); async.start(weatherman);; } }
值得高兴的是,这个版本要简单得多。平心而论,如果不遵从Tomcat的事件模型,在Jetty中可以有类似的实现。这种事件模型似乎比较合理,很容易在Tomcat以外的容器(例如Jetty)中实现,只是没有相关的标准。
回头看看清单7,注意它的标注声明它支持异步处理,并设置了超时时间。startAsync方法是HttpServletRequest上的一个新方法,它返回新的javax.servlet.AsyncContext类的一个实例。注意,MessageSender现在传递AsynContext的引用,而不是ServletResponse的引用。在这里,不应该关闭响应,而是调用AsyncContext实例上的complete方法。还应注意,Weatherman被直接传递到AsyncContext实例的start方法。这样将在当前ServletContext中开始一个新线程。
而且,尽管与Tomcat或Jetty相比都有较大的不同,但是修改相同风格的编程来处理Servlet3.0规范提议的API并不是太难。还应注意,Jetty7是为实现Servlet3.0而设计的,目前处于beta状态。但是,在撰写本文之际,它还没有实现该规范的最新版本。
结束语
Comet风格的Web应用程序可以为Web带来全新的交互性。它为大规模地实现这些特性带来一些复杂的挑战。但是,领先的JavaWeb服务器正在为实现Comet提供成熟、稳定的技术。在本文中,您看到了Tomcat和Jetty上当前风格的Comet的不同点和相似点,以及正在进行的Servlet3.0规范的标准化。Tomcat和Jetty使如今构建可伸缩的Comet应用程序成为可能,并且明确了未来面向Servlet3.0标准化的升级路线。