下面介绍在ARP之上的一个非常热门的技术实现:服务器推送技术。
服务器推送技术(Server Push)是最近Web技术中最热门的一个流行术语,它的别名叫Comet(彗星)。它是继AJAX之后又一个倍受追捧的Web技术。服务器推送技术最近的流行与AJAX有着密切的关系。
随着Web技术的流行,越来越多的应用从原有的C/S模式转变为B/S模式,享受着Web技术所带来的各种优势(例如跨平台、免客户端维护、跨越防火墙、扩展性好等)。但是基于浏览器的应用,也有它不足的地方。主要在于界面的友好性和交互性。由于浏览器中的页面每次需要全部刷新才能从服务器端获得最新的数据或向服务器传送数据,这样产生的延迟所带来的视觉感受非常糟糕。因此很多的桌面应用为了获得更友好的界面放弃了Web技术,或者采用浏览器的插件技术(ActiveX、Applet、Flash等)。但是浏览器插件技术本身又有许多问题,例如跨平台问题和插件版本兼容性问题。
随着AJAX技术的兴起,让广大开发人员又一次看到了使用浏览器来替代桌面应用的机会,并且这次机会非常大。AJAX将整个页面的刷新变成页面局部的刷新,并且数据的传送是以异步方式进行,这使得网络延迟带来的视觉差异将会消失。AJAX还利用DHTML和丰富的JavasSript语言来模拟桌面系统的各种事件和响应过程,以及平滑滚动和拖拽的效果。还不止这些,更有一些IT巨头(Google、Sun、Oracle等)提供了非常丰富的AJAX开发工具,使得开发和调试AJAX应用变得简单高效,并且开发的AJAX应用还可以跨越各种浏览器和操作系统。在这种情况下基于AJAX的Web应用迅速涌起,吞噬着原有桌面系统的份额。聊天工具、邮件阅读器、博客编辑器,甚至是Office办公软件和文字处理软件在浏览器中都有着美丽的外观和几乎可以与桌面系统媲美的交互界面。Google更是提出“有了浏览器和Google,就不需要微软”的口号和策略。在AJAX的世界中,除了传统的CAD设计软件和大型游戏软件等因为对系统硬件的苛刻需求,还离不开桌面系统以外,似乎其他所有的应用都可以变成Web应用了。
但是,在浏览器中的AJAX应用中存在一个致命的缺陷无法满足传统桌面系统的需求。那就是“服务器发起的消息传递(Server-Initiated Message Delivery)”。在很多的应用当中,服务器软件需要向客户端主动发送消息或信息。因为服务器掌握着系统的主要资源,能够最先获得系统的状态变化和事件的发生。当这些变化发生的时候,服务器需要主动地向客户端实时地发送消息。例如股票的变化。在传统的桌面系统中,这种需求没有任何问题,因为客户端和服务器之间通常存在着持久的连接,这个连接可以双向传递各种数据。而基于HTTP协议的Web应用却不行。上节中也提到过,在Web世界中,服务器永远是被动地发送数据,前提是客户端必须先发送请求。浏览器其实并不知道服务器的信息什么时候会有改变,为了模拟实时的交流,或者不想错过某些信息,只能通过轮询(Polling)技术不断刷新页面来获得最新的数据(见图18-5)。这种方式不但浪费服务器的资源,最重要的是每次建立(或关闭)新的HTTP连接都有一定的延迟,这种延迟使得频繁信息传递的应用无法忍受。于是就产生了“服务器推送技术”。
图18-5 Web请求的轮询技术
“服务器推送技术”在很久以前就出现过。例如Netscape曾经推出适用于Push技术的专用浏览器和经过修改的HTML语言。但是这仅仅在特定的浏览器中才能使用,其他流行的浏览器(IE等)就不兼容这种技术。
现在的“服务器推送技术”是保持原有的HTTP协议不变,在服务器端改变处理方式,使得服务器能够使用浏览器已经打开的HTTP连接,主动向浏览器发送消息(见图18-6)。这里关键的技术是要保持原有的HTTP连接不断。一旦拥有持久的连接,服务器就可以根据自己的数据更新,随时地向客户端发送最新的信息。
图18-6 服务器数据推送技术
在GlassFish中,Grizzly通过NIO的技术实现了异步请求服务(ARP),并在ARP之上扩展了服务器推送技术的实现,将其也命名为“Comet”。因为使用了NIO,Grizzly才可以在保持HTTP连接的同时,并不会绑定固定的线程,使得GlassFish具有很好的扩展性,可以很好地同时支持大量的Comet请求。下面我们来分析Grizzly中对Comet的实现。
18.2.1 Comet实现的分析
如图18-7所示,Comet的实现是基于ARP之上的,因此整个框架结构仍然符合ARP的模式。读者可以与“新邮件提醒功能”做一个比较,大部分的代码都相类似。最大的不同就是“新邮件提醒功能”是Grizzly的一个扩展,而Comet却已经是Grizzly的一部分,它与其他Grizzly的核心Java包位于同样重要的位置。所有的Comet的实现都在com.sun. enterprise.web.connector.grizzly.comet包中。
因为是ARP的扩展,所以它的入口仍然是AsyncFilter接口的实现。Comet对AsyncFilter接口的实现是CometAsyncFilter类。这个类的注册比“新邮件提醒功能”要简单,只需要在GlassFish的启动配置文件(domain.xml)中加上<property name="cometSupport" value="true"/>就行了,在SelectorThreadConfig类中就会读取到(见例18.12),并且调用SelectorThread中的enableCometSupport方法(见例18.13)将CometAsyncFilter类注册到系统。
图18-7 Comet实现类结构图
【例18.12】在SelectorThreadConfig类中打开Comet功能:
if (System.getProperty(ENABLE_COMET_SUPPORT) != null){
selectorThread.enableCometSupport(
Boolean.valueOf(System.getProperty(ENABLE_COMET_SUPPORT)).booleanValue());
}
【例18.13】SelectorThread中的enableCometSupport方法:
protected void enableCometSupport(boolean enableComet){
if ( enableComet ){
asyncExecution = true;
setBufferResponse(false);
isFileCacheEnabled = false;
isLargeFileCacheEnabled = false;
asyncHandler = new DefaultAsyncHandler();
asyncHandler.addAsyncFilter(new CometAsyncFilter());
SelectorThread.logger()
.log(Level.INFO,"Enabling Grizzly ARP Comet support.");
} else {
asyncExecution = false;
}
}
在CometAsyncFilter类中,最重要的方法就是doFilter,它是Comet与异步请求处理(ARP)框架的接口。
【例18.14】CometAsyncFilter中的doFilter方法:
public boolean doFilter(AsyncExecutor asyncExecutor) {
AsyncProcessorTask apt =
(AsyncProcessorTask) asyncExecutor.getAsyncTask();
CometEngine cometEngine = CometEngine.getEngine();
try{
if (!cometEngine.handle(apt)) {
return true;
}
} catch (IOException ex){
logger.log(Level.SEVERE,"CometAsyncFilter",ex);
}
return false;
}
从例18.14可以看出,在doFilter方法之中,所有的操作都交给CometEngine的handle方法。
CometEngine是Comet应用中最先接触的类。如果一个Servlet或JSP页面要想成为Comet请求,那么在编程的时候需要经过以下几步。
(1) 获得CometEngine的实例对象,并将需要成为Comet请求的路径注册:
CometEngine cometEngine = CometEngine.getEngine();
CometContext cometContext = cometEngine.register(contextPath);
(2) 注册一个CometHandler:
cometContext.addCometHandler(handler);
(3) 最后,如果有消息发送,可以通过下面的方法通知所有注册的通道:
cometContext.notify(handler);
有关CometContext和CometHandler类,在下面的内容会进行稍微详细的描述。
当请求处理交给CometEngine对象以后,CometEngine以及其他几个类(CometContext和CometHandler等)就会对这个请求的生命周期负起全部的责任。Comet请求和其他的请求不一样,它需要长时间地保持HTTP连接,来保证服务器端能够利用这些连接主动发送消息给浏览器客户端。因此CometEngine并没有使用主线程的Selector(在SelectorThread中运行的Selector,而是使用了自己的Selector对象:CometSelector,而让主线程的Selector负责其他类型的请求读取和处理。CometSelector的主要职责是负责已经注册的Comet请求的生命周期:哪些Comet请求的连接被用户关闭或异常关闭,哪些Comet请求根据配置已经超时。在这些情况下,需要系统释放相应的资源,使得系统更加稳定和健壮。
而CometContext的作用则是应用程序和Comet实现之间的桥梁。CometHandler可以利用它来注册,因此CometContext掌握了当前Comet应用中所有注册了的频道。这样当其中有一个频道利用CometContext来发送消息时,CometContext能够将消息主动发送给所有注册的Handler。这些对象的关系,可以通过一个典型的例子的讲解更加清楚的展现出来。
18.2.2 Comet实例讲解——“聊天室”应用
“聊天室”是一个非常典型的Comet应用。通常的“聊天室”至少需要包含两个基本的功能:发送本人的消息和接受显示别人的消息。这里的Comet应用主要是指接受别人的消息。因为别人什么时候发送了消息浏览器是不会知道的,只有聊天服务器本身知道,如果想要将各种消息实时地通知各个客户端,就需要服务器推送技术。
现有的很多“聊天室”大多使用轮询(Polling)技术,来使得浏览器不断自动刷新以获得最新的消息。这种实现方法在并发用户不太多的情况下还能接受。如果并发用户非常多,服务器的负担就会大大地增加。另外每次重新建立连接所带来的延迟也使得用户不能非常及时地获得最新的消息。综合这些因此,对“聊天室”的最佳实现应该使用Comet技术,也就是“服务器推送技术”。
下面来讲解一个使用GlassFish的Comet来实现的“聊天室”。在本书所附的CD中有详细的代码和步骤来部署和运行“聊天室”应用。
在“聊天室”中,只有一个Servlet和几个JSP页面文件。JSP页面非常简单,只是简单的HTML。所有的请求处理都在Servlet中。
【例18.15】Servlet中的init方法:
...
public void init(ServletConfig config) throws ServletException {
super.init(config);
contextPath = config.getServletContext().getContextPath() + "/chat";
CometEngine cometEngine = CometEngine.getEngine(); // [1]
CometContext context = cometEngine.register(contextPath); // [2]
context.setExpirationDelay(20*1000); // [3]
}
...
从例18.15的代码可以看出,Servlet在初始化的时候做了以下三件事情。
(1) 获得了一个CometEngine的实例对象。上文已经解释过,CometEngine对象是Comet应用的入口。任何Comet应用都需要CometEngine对象来注册Comet请求的路径。
(2) 将当前的路径向CometEngine进行注册。显然,当Comet功能打开的时候,GlassFish不会将所有的请求都认为是Comet请求,而是仅仅当请求的路径和将注册的路径相匹配的时候才会进行Comet处理。注册成功的结果是返回一个CometContext对象。上文已经解释过,CometContext是每个用户之间交流的桥梁。
(3) 设置当前Comet应用的超时的阀值。
【例18.16】Servlet的doPost方法中的部分代码(一):
...
public void doPost(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException
{
String action = request.getParameter("action");
CometEngine cometEngine = CometEngine.getEngine();
CometContext cometContext = cometEngine.getCometContext(contextPath);
...
}
从例18.16的代码中可以看出,在处理Comet请求,与其他用户交互的时候,是需要先获得CometContext的。需要指出的是,CometEngine对象是一个单例对象(Singleton),只会存在一个实例,因此任何时候调用getEngine的方法都会获得同一个实例。
【例18.17】Servlet的doPost方法中的部分代码(二):
...
if (action != null) {
if ("login".equals(action)) {
String username = request.getParameter("username");
request.getSession(true).setAttribute("username", username);
if (firstServlet != -1){
cometContext.notify("User " + username
+ " from " + request.getRemoteAddr()
+ " is joining the chat.<br/>",CometEvent.NOTIFY,firstServlet);
}
...
从例18.17的代码可以看出,当用户登录成功后,除了将用户信息保存到session中之外,还会通过cometContext向所有其他用户发出“新用户登录”的信息。
【例18.18】Servlet的doPost方法中的部分代码(三):
...
else if ("post".equals(action)){
String username = (String) request.getSession(true)
.getAttribute("username");
String message = request.getParameter("message");
cometContext.notify("[ " + username + " ] " + message + "<br/>");
response.sendRedirect("post.jsp");
return;
...
例18.18的代码是在处理用户“说话”的情况。如果用户在自己的发送消息框中向其他在线的用户发送了一些消息,Servlet在处理的时候就是通过cometContext来通知所有的在线用户。
【例18.19】Servlet的doPost方法中的部分代码(四):
...
else if ("openchat".equals(action)) {
response.setContentType("text/html");
String username = (String) request.getSession(true)
.getAttribute("username");
response.getWriter().println("<h2>Welcome "+ username + " </h2>");
CometRequestHandler handler = new CometRequestHandler();
handler.clientIP = request.getRemoteAddr();
handler.attach(response.getWriter());
cometContext.addCometHandler(handler);
return;
...
例18.19的代码演示的是“聊天消息显示”的功能,这才是真正Comet的请求,这个请求的连接是一直保持打开着的,等待着服务器主动将最新的信息发送到浏览器。这段代码中最主要的内容就是向CometContext注册了一个CometHandler。注册之后,这个Handler就会等待服务器端的回调,来完成向浏览器输出的功能。
【例18.20】CometRequestHandler类的onEvent方法:
public class CometRequestHandler implements CometHandler<PrintWriter>{
public void onEvent(CometEvent event) throws IOException{
try{
if (firstServlet != -1 && this.hashCode() != firstServlet){
event.getCometContext().notify("User " + clientIP
+ " is getting a new message.<br/>",CometEvent.NOTIFY,
firstServlet);
}
if (event.getType() != CometEvent.READ){
printWriter.println(event.attachment());
printWriter.flush();
}
} catch (Throwable t){
t.printStackTrace();
}
}
...
}
例18.19的代码解释了CometRequestHandler类在收到了系统的函数回调之后,进入到onEvent方法。在onEvent方法的处理中,仅仅是简单地将系统传递过来的消息通过一直保持的HTTP连接向客户传过去。
当“聊天室”应用运行的时候,用户界面如图18-8所示。其中下半部分是发送消息的部分,它的处理代码对应于例18-18。上半部分是对话消息显示的部分,它的处理代码对应于例18.19。