Servlet3规范提出异步请求,绝对是一巨大历史进步。之前各自应用服务器厂商纷纷推出自己的异步请求实现(或者称comet,或者服务器推送支持,或者长连接),诸如Tomcat6中的NIO连接协议支持,Jetty的continuations编程架构,SUN、IBM、BEA等自不用说,商业版的服务器对Comet的支持,自然走在开源应用服务器前面,各自为王,没有一个统一的编程模型,怎一个乱字了得。相关的comet框架也不少,诸如pushlet、DWR、cometd;最近很热HTML5也不甘寂寞,推出WebSocket,只是离现实较远。
总体来说,在JAVA世界,很乱!缺乏规范,没有统一的编程模型,会严重依赖特定服务器,或特定容器。
好在Servlet3具有了异步请求规范,各个应用服务器厂商只需要自行实现即可,这样编写符合规范的异步Servlet代码,不用担心移植了。
现在编写支持comet风格servlet,很简单:
- 在注解处标记上 asyncSupported = true;
- 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="[email protected]"/> <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 [email protected] * @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"> </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站点启动时中,创建一个独立线程,在有新的博文内容时,遍历推送所有已注册客户端
整个流程梳理一下:
- 客户端请求 blog.html
- blog.html的comet.js开始注册启动事件
- JS产生一个iframe,在iframe中请求/blogpush,注册异步连接,设定超时为10分钟,注册异步监听器
- 服务器接收到请求,添加异步连接到队列中
- 客户端处于等待状态(长连接一直建立),等待被调用
- 后台发布新的博客文章
- 博客文章被放入到队列中
- 一直在守候的独立线程处理博客文章队列;把博客文章格式化成JSON对象,一一轮询推送到客户端
- 客户端JS方法被调用,进行JSON对象解析,组装HTML代码,显示在当前页面上
- 超时发生时,/blogpush通知客户端已经超时,调用超时(timeout)方法;同时从异步连接队列中删除
- 客户端接到通知,对iframe进行操作,再次进行连接请求,重复步骤2
大致流程图,如下:
其连接模型,偷懒,借用IBM上一张图片说明: