聂永的博客

记录工作/学习的点点滴滴。

Servlet 3.0笔记之异步请求Comet推送iFrame示范

Servlet3规范提出异步请求,绝对是一巨大历史进步。之前各自应用服务器厂商纷纷推出自己的异步请求实现(或者称comet,或者服务器推送支持,或者长连接),诸如Tomcat6中的NIO连接协议支持,Jetty的continuations编程架构,SUN、IBM、BEA等自不用说,商业版的服务器对Comet的支持,自然走在开源应用服务器前面,各自为王,没有一个统一的编程模型,怎一个乱字了得。相关的comet框架也不少,诸如pushlet、DWR、cometd;最近很热HTML5也不甘寂寞,推出WebSocket,只是离现实较远。
总体来说,在JAVA世界,很乱!缺乏规范,没有统一的编程模型,会严重依赖特定服务器,或特定容器。
好在Servlet3具有了异步请求规范,各个应用服务器厂商只需要自行实现即可,这样编写符合规范的异步Servlet代码,不用担心移植了。
现在编写支持comet风格servlet,很简单:
  1. 在注解处标记上 asyncSupported = true;
  2. final AsyncContext ac = request.startAsync();
这里设定简单应用环境:一个非常受欢迎博客系统,多人订阅,终端用户仅仅需要访问订阅页面,当后台有新的博客文章提交时,服务器会马上主动推送到客户端,新的内容自动显示在用户的屏幕上。整个过程中,用户仅仅需要打开一次页面(即订阅一次),后台当有新的内容时会主动展示用户浏览器上,不需要刷新什么。下面的示范使用到了iFrame,有关Comet Stream,会在以后展开。有关理论不会在本篇深入讨论,也会在以后讨论。
这个系统需要一个博文内容功能:
新的博文后台处理部分代码:
 protected void doPost(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
MicBlog blog = new MicBlog();

blog.setAuthor("发布者");
blog.setId(System.currentTimeMillis());
blog.setContent(iso2UTF8(request.getParameter("content")));
blog.setPubDate(new Date());

// 放到博文队列里面去
NewBlogListener.BLOG_QUEUE.add(blog);

request.setAttribute("message", "博文发布成功!");

request.getRequestDispatcher("/WEB-INF/pages/write.jsp").forward(
request, response);
}

private static String iso2UTF8(String str){
try {
return new String(str.getBytes("ISO-8859-1"), "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return null;
}
当用户需要订阅博客更新时的界面:
当前页面HTML代码可以说明客户端的一些情况:
<html>
<head>
<title>comet推送测试</title>
<meta http-equiv="X-UA-Compatible" content="IE=8" />
<meta http-equiv="content-type" content="text/html;charset=UTF-8"/>
<meta name="author" content="yongboy@gmail.com"/>
<meta name="keywords" content="servlet3, comet, ajax"/>
<meta name="description" content=""/>
<link type="text/css" rel="stylesheet" href="css/main.css"/>
<script type="text/javascript" src="js/jquery-1.4.min.js"></script>
<script type="text/javascript" src="js/comet.js"></script>
</head>
<body style="margin: 0; overflow: hidden">
<div id="showDiv" class="inputStyle"></div>
</body>
</html>
id为“showDiv”的div这里作为一个容器,用于组织显示最新的信息。
而客户端逻辑,则在comet.js文件中具体展示了如何和服务器交互的一些细节:
/**
* 客户端Comet JS 渲染部分
* @author yongboy@gmail.com
* @date 2010-10-18
* @version 1.0
*/
String.prototype.template=function(){
var args=arguments;
return this.replace(/\{(\d+)\}/g, function(m, i){
return args[i];
});
}
var html = '<div class="logDiv">'
+ '<div class="contentDiv">{0}</div>'
+ '<div class="tipDiv">last date : {1}</div>'
+ '<div class="clear">&nbsp;</div>'
+ '</div>';

function showContent(json) {
$("#showDiv").prepend(html.template(json.content, json.date));
}
var server = 'blogpush';
var comet = {
connection : false,
iframediv : false,

initialize: function() {
if (navigator.appVersion.indexOf("MSIE") != -1) {
comet.connection = new ActiveXObject("htmlfile");
comet.connection.open();
comet.connection.write("<html>");
comet.connection.write("<script>document.domain = '"+document.domain+"'");
comet.connection.write("</html>");
comet.connection.close();
comet.iframediv = comet.connection.createElement("div");
comet.connection.appendChild(comet.iframediv);
comet.connection.parentWindow.comet = comet;
comet.iframediv.innerHTML = "<iframe id='comet_iframe' src='"+server+"'></iframe>";
}else if (navigator.appVersion.indexOf("KHTML") != -1 || navigator.userAgent.indexOf('Opera') >= 0) {
comet.connection = document.createElement('iframe');
comet.connection.setAttribute('id', 'comet_iframe');
comet.connection.setAttribute('src', server);
with (comet.connection.style) {
position = "absolute";
left = top = "-100px";
height = width = "1px";
visibility = "hidden";
}
document.body.appendChild(comet.connection);
}else {
comet.connection = document.createElement('iframe');
comet.connection.setAttribute('id', 'comet_iframe');
with (comet.connection.style) {
left = top = "-100px";
height = width = "1px";
visibility = "hidden";
display = 'none';
}
comet.iframediv = document.createElement('iframe');
comet.iframediv.setAttribute('onLoad', 'comet.frameDisconnected()');
comet.iframediv.setAttribute('src', server);
comet.connection.appendChild(comet.iframediv);
document.body.appendChild(comet.connection);
}
},
frameDisconnected: function() {
comet.connection = false;
$('#comet_iframe').remove();
//setTimeout("chat.showConnect();",100);
},
showMsg:function(data){
showContent(data);
},
timeout:function(){
var url = server + "?time=" + new Date().getTime();
if (navigator.appVersion.indexOf("MSIE") != -1) {
comet.iframediv.childNodes[0].src = url;
} else if (navigator.appVersion.indexOf("KHTML") != -1 || navigator.userAgent.indexOf('Opera') >= 0) {
document.getElementById("comet_iframe").src = url;
} else {
comet.connection.removeChild(comet.iframediv);
document.body.removeChild(comet.connection);
comet.iframediv.setAttribute('src', url);
comet.connection.appendChild(comet.iframediv);
document.body.appendChild(comet.connection);
}
},
onUnload: function() {
if (comet.connection) {
comet.connection = false;
}
}
}

if (window.addEventListener) {
window.addEventListener("load", comet.initialize, false);
window.addEventListener("unload", comet.onUnload, false);
} else if (window.attachEvent) {
window.attachEvent("onload", comet.initialize);
window.attachEvent("onunload", comet.onUnload);
}
需要注意的是comet这个对象在初始化(initialize)和超时(timeout)时的处理方法,能够在IE以及火狐下面表现的完美,不会出现正在加载中标志。当然超时方法(timeout),是在服务器端通知客户端调用。在Chrome和Opera下面一直有进度条显示,暂时没有找到好的解决办法。
后台处理客户端请求请求代码:
/**
* 负责客户端的推送
* @author yongboy
* @date 2011-1-13
* @version 1.0
*/
@WebServlet(urlPatterns = { "/blogpush" }, asyncSupported = true)
public class BlogPushAction extends HttpServlet {
private static final long serialVersionUID = 8546832356595L;
private static final Log log = LogFactory.getLog(BlogPushAction.class);

protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {

response.setHeader("Cache-Control", "private");
response.setHeader("Pragma", "no-cache");
response.setContentType("text/html;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
final PrintWriter writer = response.getWriter();

// 创建Comet Iframe
writer.println("<!doctype html public \"-//w3c//dtd html 4.0 transitional//en\">");
writer.println("<script type=\"text/javascript\">var comet = window.parent.comet;</script>");
writer.flush();

final AsyncContext ac = request.startAsync();
ac.setTimeout(10 * 60 * 1000);// 10分钟时间;tomcat7下默认为10000毫秒

ac.addListener(new AsyncListener() {
public void onComplete(AsyncEvent event) throws IOException {
log.info("the event : " + event.toString()
+ " is complete now !");
NewBlogListener.ASYNC_AJAX_QUEUE.remove(ac);
}

public void onTimeout(AsyncEvent event) throws IOException {
log.info("the event : " + event.toString()
+ " is timeout now !");

// 尝试向客户端发送超时方法调用,客户端会再次请求/blogpush,周而复始
log.info("try to notify the client the connection is timeout now ...");
String alertStr = "<script type=\"text/javascript\">comet.timeout();</script>";
writer.println(alertStr);
writer.flush();
writer.close();

NewBlogListener.ASYNC_AJAX_QUEUE.remove(ac);
}

public void onError(AsyncEvent event) throws IOException {
log.info("the event : " + event.toString() + " is error now !");
NewBlogListener.ASYNC_AJAX_QUEUE.remove(ac);
}

public void onStartAsync(AsyncEvent event) throws IOException {
log.info("the event : " + event.toString()
+ " is Start Async now !");
}
});

NewBlogListener.ASYNC_AJAX_QUEUE.add(ac);
}
}
每一个请求都需要request.startAsync(request,response)启动异步处理,得到AsyncContext对象,设置超时处理时间(这里设置10分钟时间),注册一个异步监听器。
异步监听器可以在异步请求于启动、完成、超时、错误发生时得到通知,属于事件传递机制,从而更好对资源处理等。
在长连接超时(onTimeout)事件中,服务器会主动通知客户端再次进行请求注册。
若中间客户端非正常关闭,在超时后,服务器端推送数量就减少了无效的连接。在真正应用中,需要寻觅一个较为理想的值,以保证服务器的有效连接数,又不至于浪费多余的连接。
每一个异步请求会被存放在一个高效并发队列中,在一个线程中统一处理,具体逻辑代码:
/**
* 监听器单独线程推送到客户端
* @author yongboy
* @date 2011-1-13
* @version 1.0
*/
@WebListener
public class NewBlogListener implements ServletContextListener {
private static final Log log = LogFactory.getLog(NewBlogListener.class);
public static final BlockingQueue<MicBlog> BLOG_QUEUE = new LinkedBlockingDeque<MicBlog>();
public static final Queue<AsyncContext> ASYNC_AJAX_QUEUE = new ConcurrentLinkedQueue<AsyncContext>();
private static final String TARGET_STRING = "<script type=\"text/javascript\">comet.showMsg(%s);</script>";

private String getFormatContent(MicBlog blog) {
return String.format(TARGET_STRING, buildJsonString(blog));
}

public void contextDestroyed(ServletContextEvent arg0) {
log.info("context is destroyed!");
}

public void contextInitialized(ServletContextEvent servletContextEvent) {
log.info("context is initialized!");
// 启动一个线程处理线程队列
new Thread(runnable).start();
}

private Runnable runnable = new Runnable() {
public void run() {
boolean isDone = true;

while (isDone) {
if (!BLOG_QUEUE.isEmpty()) {
try {
log.info("ASYNC_AJAX_QUEUE size : "
+ ASYNC_AJAX_QUEUE.size());
MicBlog blog = BLOG_QUEUE.take();

if (ASYNC_AJAX_QUEUE.isEmpty()) {
continue;
}

String targetJSON = getFormatContent(blog);

for (AsyncContext context : ASYNC_AJAX_QUEUE) {
if (context == null) {
log.info("the current ASYNC_AJAX_QUEUE is null now !");
continue;
}
log.info(context.toString());
PrintWriter out = context.getResponse().getWriter();

if (out == null) {
log.info("the current ASYNC_AJAX_QUEUE's PrintWriter is null !");
continue;
}

out.println(targetJSON);
out.flush();
}
} catch (Exception e) {
e.printStackTrace();
isDone = false;
}
}
}
}
};

private static String buildJsonString(MicBlog blog) {
Map<String, Object> info = new HashMap<String, Object>();
info.put("content", blog.getContent());
info.put("date",
DateFormatUtils.format(blog.getPubDate(), "HH:mm:ss SSS"));

JSONObject jsonObject = JSONObject.fromObject(info);

return jsonObject.toString();
}
}
异步请求上下文AsyncContext获取输出对象(response),向客户端传递JSON格式化序列对象,具体怎么解析、显示,由客户端(见comet.js)决定。
鉴于Servlet为单实例多线程,最佳实践建议是不要在servlet中启动单独的线程,本文放在ServletContextListener监听器中,以便在WEB站点启动时中,创建一个独立线程,在有新的博文内容时,遍历推送所有已注册客户端
整个流程梳理一下:
  1. 客户端请求 blog.html
  2. blog.html的comet.js开始注册启动事件
  3. JS产生一个iframe,在iframe中请求/blogpush,注册异步连接,设定超时为10分钟,注册异步监听器
  4. 服务器接收到请求,添加异步连接到队列中
  5. 客户端处于等待状态(长连接一直建立),等待被调用
  6. 后台发布新的博客文章
  7. 博客文章被放入到队列中
  8. 一直在守候的独立线程处理博客文章队列;把博客文章格式化成JSON对象,一一轮询推送到客户端
  9. 客户端JS方法被调用,进行JSON对象解析,组装HTML代码,显示在当前页面上
  10. 超时发生时,/blogpush通知客户端已经超时,调用超时(timeout)方法;同时从异步连接队列中删除
  11. 客户端接到通知,对iframe进行操作,再次进行连接请求,重复步骤2
大致流程图,如下:
diagram2

其连接模型,偷懒,借用IBM上一张图片说明:

posted on 2011-01-10 10:57 nieyong 阅读(7563) 评论(5)  编辑  收藏 所属分类: Servlet3

评论

# re: Servlet 3.0笔记之异步请求Comet推送iFrame示范 2011-04-29 10:53 RonQi

很好的文章啊,博主原创的吧。我目前在做一个服务器端推消息的程序,类似客服系统那样的,服务端推给页面客服人员问题,客服回答完后提交给服务端。我觉得可以参考楼主的程序,不知楼主是否在正式环境下使用过Servlet3的推技术呢,现在这方面的资料还不是很多  回复  更多评论   

# re: Servlet 3.0笔记之异步请求Comet推送iFrame示范 2011-05-04 14:02 nieyong

@RonQi
暂无在正式环境下使用Servlet3的推送。
不过在现实环境下,有人已经使用golang做服务器,采用长轮询做推送,在实际环境中长轮询使用较多一些。
有关轮询还是推送,可以参考
《Push Or Pull?》
http://rdc.taobao.com/team/jm/archives/918

里面对推送和轮询分别存在的问题,分析的很透彻。  回复  更多评论   

# re: Servlet 3.0笔记之异步请求Comet推送iFrame示范 2011-09-24 04:08 wxh0800

很有启发的代码, 推荐!!!
但是楼主还没有完善线程同步机制,CPU占用太高,实际环境不能用。
不过还是非常感谢,相应的文章并不多  回复  更多评论   

# re: Servlet 3.0笔记之异步请求Comet推送iFrame示范 2012-09-09 08:50 lee_jie1001@foxmail.com

请问下,异步连接只能响应一次啊!我做了两个页面,一个get请求与服务器建立异步连接,一个post请求更新消息队列,客户先使用get与服务器建立连接,让后我用另外的页面通过post发消息,只有第一个消息能收到!  回复  更多评论   

# re: Servlet 3.0笔记之异步请求Comet推送iFrame示范[未登录] 2012-10-09 10:06 iloveyou

ie、ff下异步推动时,浏览器请求图标总在不停转动,用户体验不太好啊,不知楼主是否也发现这个问题??  回复  更多评论   


只有注册用户登录后才能发表评论。


网站导航:
 

公告

所有文章皆为原创,若转载请标明出处,谢谢~

新浪微博,欢迎关注:

导航

<2011年1月>
2627282930311
2345678
9101112131415
16171819202122
23242526272829
303112345

统计

常用链接

留言簿(58)

随笔分类(130)

随笔档案(151)

个人收藏

最新随笔

搜索

最新评论

阅读排行榜

评论排行榜