2006年Web 2.0最火的技术莫过于AJAX了,各种AJAX的框架层出不穷。正是应验一句老话:“三十年河东,三十年河西”,其实还不到三十年呢,B/S开发模式已经落伍了,现在C/S模式又吃香了,只不过client不是由OS承载,而是换成了web浏览器;client端的编程语言换成了JavaScript。AJAX似乎可以包办一切,唯一的缺陷是所有业务必须由client端来触发,Server端只能被动地响应。针对AJAX的先天缺陷,Comet技术应运而生,或者说是老树开新花,Server端的推送技术,使得Web浏览器真正成为理想的综合业务通用客户端平台。
Grizzly应用Java NIO框架,利用有限的线程为大量并发的socket连接提供服务,使得Server端有非常出色的扩展性。Grizzly Cometd为我们提供了针对Http请求的异步处理机制,加上对Bayeux协议的支持,我们可以在Cometd Servlet中创建消息队列,浏览器端可以灵活地订阅(Subscribe)消息,当Server端有新的消息需要分发时,将消息推送给订阅者;当然浏览器也可以主动地发布(Publish)消息,Server收到消息后,立即推送给此消息的订阅者(可以是Web浏览器,也可以是特殊的客户端或服务端的进程)。Glizzly Cometd的消息分发采用Bayeux协议。当前能够支持Grizzly+Bayeux的Servlet Container主要是
Jetty6和
Glassfish v2,Tomcat还不支持。在浏览器端,只有Dojo Cometd支持Bayeux协议。
在Jetty下测试Cometd Chat Demo非常顺利,我下载了最新的Jetty 6.1.7,在WinXP(sp2)的DOS窗口中启动Server:
java -jar start.jar etc/jetty.xml etc/jetty-grizzly.xml
使用IE/FF访问 http://localhost:8888/test/chat。
Grizzly是Glassfish的子项目,Glassfish直接使用grizzly的源码,重新打包。我下载的版本是最新发布的V2UR1。Chat Demo(
grizzly-cometd-chat.war)来自
Jean-Francois Arcand的blog。下载并安装Glassfish V2UR1,配置domains/domain1/config/domain.xml,将ws/tcp注释掉,添加cometSupport支持:
<http-listener id="http-listener-1" address="0.0.0.0" port="8080" acceptor-threads="1" security-enabled="false" default-virtual-server="server" server-name="" xpowered-by="true" enabled="true" family="inet" blocking-enabled="false">
<!-- property name="proxiedProtocols" value="ws/tcp"/ -->
<property name="cometSupport" value="true"/>
</http-listener>
将war文件部署到domains/domain1/autodeploy目录,能够看到Cometd Chat登录界面,输入用户名点击"Join"按钮,显示聊天页面(参见cometd1.PNG),但是未显示已登入聊天室的提示信息。检查glassfish的log文件,发现有异常抛出:
引用
StandardWrapperValve[Grizzly Cometd Servlet]: PWC1406: Servlet.service() for servlet Grizzly Cometd Servlet threw exception
java.lang.ClassCastException: java.lang.Double
at com.sun.grizzly.cometd.bayeux.VerbUtils.newHandshake(VerbUtils.java:126)
at com.sun.grizzly.cometd.bayeux.VerbUtils.parseMap(VerbUtils.java:98)
at com.sun.grizzly.cometd.bayeux.VerbUtils.parse(VerbUtils.java:71)
at com.sun.grizzly.cometd.EventRouterImpl.route(EventRouterImpl.java:90)
at com.demo.servlet.ChatDemoServlet.doPost(ChatDemoServlet.java:152)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:738)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:831)
at org.apache.catalina.core.ApplicationFilterChain.servletService(ApplicationFilterChain.java:411)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:290)
at org.apache.catalina.core.StandardContextValve.invokeInternal(StandardContextValve.java:271)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:202)
at org.apache.catalina.core.StandardPipeline.doInvoke(StandardPipeline.java:632)
at org.apache.catalina.core.StandardPipeline.doInvoke(StandardPipeline.java:577)
at com.sun.enterprise.web.WebPipeline.invoke(WebPipeline.java:94)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:206)
at org.apache.catalina.core.StandardPipeline.doInvoke(StandardPipeline.java:632)
at org.apache.catalina.core.StandardPipeline.doInvoke(StandardPipeline.java:577)
at org.apache.catalina.core.StandardPipeline.invoke(StandardPipeline.java:571)
at org.apache.catalina.core.ContainerBase.invoke(ContainerBase.java:1080)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:150)
at org.apache.catalina.core.StandardPipeline.doInvoke(StandardPipeline.java:632)
at org.apache.catalina.core.StandardPipeline.doInvoke(StandardPipeline.java:577)
at org.apache.catalina.core.StandardPipeline.invoke(StandardPipeline.java:571)
at org.apache.catalina.core.ContainerBase.invoke(ContainerBase.java:1080)
at org.apache.coyote.tomcat5.CoyoteAdapter.service(CoyoteAdapter.java:272)
at com.sun.enterprise.web.connector.grizzly.DefaultProcessorTask.invokeAdapter(DefaultProcessorTask.java:637)
at com.sun.enterprise.web.connector.grizzly.comet.CometEngine.executeServlet(CometEngine.java:547)
at com.sun.enterprise.web.connector.grizzly.comet.CometEngine.handle(CometEngine.java:299)
at com.sun.enterprise.web.connector.grizzly.comet.CometAsyncFilter.doFilter(CometAsyncFilter.java:87)
at com.sun.enterprise.web.connector.grizzly.async.DefaultAsyncExecutor.invokeFilters(DefaultAsyncExecutor.java:175)
at com.sun.enterprise.web.connector.grizzly.async.DefaultAsyncExecutor.interrupt(DefaultAsyncExecutor.java:153)
at com.sun.enterprise.web.connector.grizzly.async.AsyncProcessorTask.doTask(AsyncProcessorTask.java:92)
at com.sun.enterprise.web.connector.grizzly.TaskBase.run(TaskBase.java:265)
at com.sun.enterprise.web.connector.grizzly.WorkerThreadImpl.run(WorkerThreadImpl.java:116)
注意:如果使用Glassfish的版本不是V2UR1,可能会得到java.lang.NullPointerException异常。
初步分析错误出自grizzly cometd包对bayeux协议解析时发生数据转型错误。上网抓grizzly的sourcecode,先分析CometdServlet,在分析EventRouter和VerbUtils,发现Glassfish没有使用grizzly的最新版本,再上网抓glassfish的sourcecode。为了便于单步跟踪,在MyEclipse中创建一个Web工程,建立一个ChatDemoServlet替代CometdServlet,建立一个VerbUtils替代原来的VerbUtils,在ChatDemoServlet的doPost()方法中嵌入以下代码:
@Override
public void doPost(HttpServletRequest hreq, HttpServletResponse hres)
throws ServletException, IOException {
......
String[] messages = hreq.getParameterValues("message");
if (messages != null && messages.length > 0){
for(String message: messages){
Object verbObj = JSONParser.parse(message);
//Verb verb = VerbUtils.parse(verbObj);
VerbBase wellFormedVerb = null;
if (verbObj.getClass().isArray()){
int length = Array.getLength(verbObj);
for (int i=0; i < length; i++){
wellFormedVerb =
VerbUtils.parseMap((Map)Array.get(verbObj,i), wellFormedVerb);
}
}
// Notify our listener;
}
}
......
}
在MyEclipse中经过单步调试,发现Bug原因是:
JSONParser.parse()对请求参数中的数值进行转型,有小数点时转型为Double,否则转型为Long。而VerbUtils的newSubscribe()只能接收类型为String的参数。改进方案很简单,将VerbUtils的newSubscribe()方法中的以下代码
handshake.setVersion((String)map.get("version"));
handshake.setMinimumVersion((String)map.get("minimumVersion"));
替换为
Object ov = map.get("version");
if (ov instanceof String)
handshake.setVersion((String)ov);
else if (ov instanceof Number)
handshake.setVersion(ov.toString());
else handshake.setVersion("1.0");
ov = map.get("minimumVersion");
if (ov instanceof String)
handshake.setMinimumVersion((String)ov);
else if (ov instanceof Number)
handshake.setMinimumVersion(ov.toString());
else handshake.setMinimumVersion("0.1");
重新编译(技巧:在MyEclipse工程中建立一个com.sun.grizzly.cometd.bayeux包,将glassfish源文件的压缩包展开,将appserv-http-engine/src/main/java/com/sun/grizzly/cometd/bayeux目录下所有的java文件导入到此包中),得到VerbUtils.class文件替换glassfish安装路径的lib目录下的appserv-rt.jar文件中的同名文件。
重新测试,server端log文件中没有异常记录,但是登录和发消息都没有看到预期的消息显示在对话内。采用MyEclipse Web2.0浏览器进行跟踪,发现chat.js工作不正常,没有发出http请求到服务端。由于本人对AJAX/Dojo的功力不足,未进一步分析是何原因。与Jetty6的chat demo对比,发现两者使用dojo版本不同,所以想到使用移花接木的手段,尝试用Jetty6的客户端访问Glassfish的服务端:
1)在MyEclipse工程中WebRoot目录下创建chat目录,导入index.html、chat.js和chat.css(参见附加文件)。
2)下载最新的Dojo API包:
dojo-release-1.0.2.zip。解压缩后部署到WebRoot目录下。
3)修改index.html,内容如下:
<html>
<head>
<title>Cometd chat</title>
<script type="text/javascript" src="../dojo/dojo.js"></script>
<script type="text/javascript" src="../dojox/cometd.js"></script>
<script type="text/javascript" src="chat.js"></script>
<link rel="stylesheet" type="text/css" href="chat.css">
</head>
<body>
<h1>Cometd Chat</h1>
<div id="chatroom">
<div id="chat"></div>
<div id="input">
<div id="join" >
Username: <input id="username" type="text"/><input id="joinB" class="button" type="submit" name="join" value="Join"/>
</div>
<div id="joined" class="hidden">
Chat: <input id="phrase" type="text"></input>
<input id="sendB" class="button" type="submit" name="join" value="Send"/>
<input id="leaveB" class="button" type="submit" name="join" value="Leave"/>
</div>
</div>
</div>
</body>
4)改编chat.js,内容如下:
dojo.require("dojox.cometd");
//dojo.require("dojox.cometd.timesync");
var room = {
_last: "",
_username: null,
_connected: true,
join: function(name){
if(name == null || name.length==0 ){
alert('Please enter a username!');
}else{
dojox.cometd.init(new String(document.location).replace(/http:\/\/[^\/]*/,'').replace(/\/chat\/.*$/,'')+"/cometd");
// dojox.cometd.init("http://127.0.0.2:8080/cometd");
this._connected=true;
this._username=name;
dojo.byId('join').className='hidden';
dojo.byId('joined').className='';
dojo.byId('phrase').focus();
// subscribe and join
dojox.cometd.startBatch();
dojox.cometd.subscribe("/chat/demo", room, "_chat");
dojox.cometd.publish("/chat/demo", { user: room._username, join: true, chat : room._username+" has joined"});
dojox.cometd.endBatch();
// handle cometd failures while in the room
room._meta=dojo.subscribe("/cometd/meta",dojo.hitch(this,function(event){
console.debug(event);
if (event.action=="handshake") {
room._chat({data:{join:true,user:"SERVER",chat:"reinitialized"}});
dojox.cometd.subscribe("/chat/demo", room, "_chat");
} else if (event.action=="connect") {
if (event.successful && !this._connected)
room._chat({data:{leave:true,user:"SERVER",chat:"reconnected!"}});
if (!event.successful && this._connected)
room._chat({data:{leave:true,user:"SERVER",chat:"disconnected!"}});
this._connected=event.successful;
}
}));
}
},
leave: function(){
if (room._username==null)
return;
if (room._meta)
dojo.unsubscribe(room._meta);
room._meta=null;
dojox.cometd.startBatch();
dojox.cometd.unsubscribe("/chat/demo", room, "_chat");
dojox.cometd.publish("/chat/demo", { user: room._username, leave: true, chat : room._username+" has left"});
dojox.cometd.endBatch();
// switch the input form
dojo.byId('join').className='';
dojo.byId('joined').className='hidden';
dojo.byId('username').focus();
room._username=null;
dojox.cometd.disconnect();
},
chat: function(text){
if(!text || !text.length){ return false; }
dojox.cometd.publish("/chat/demo", { user: room._username, chat: text});
},
_chat: function(message){
var chat=dojo.byId('chat');
if(!message.data){
alert("bad message format "+message);
return;
}
var from=message.data.user;
var special=message.data.join || message.data.leave;
var text=message.data.chat;
if(!text){ return; }
if( !special && from == room._last ){
from="...";
}else{
room._last=from;
from+=":";
}
if(special){
chat.innerHTML += "<span class=\"alert\"><span class=\"from\">"+from+" </span><span class=\"text\">"+text+"</span></span><br/>";
room._last="";
}else{
chat.innerHTML += "<span class=\"from\">"+from+" </span><span class=\"text\">"+text+"</span><br/>";
}
chat.scrollTop = chat.scrollHeight - chat.clientHeight;
},
_init: function(){
dojo.byId('join').className='';
dojo.byId('joined').className='hidden';
dojo.byId('username').focus();
var element=dojo.byId('username');
element.setAttribute("autocomplete","OFF");
dojo.connect(element, "onkeyup", function(e){
if(e.keyCode == dojo.keys.ENTER){
room.join(dojo.byId('username').value);
return false;
}
return true;
});
element=dojo.byId('joinB');
element.onclick = function(){
room.join(dojo.byId('username').value);
return false;
}
element=dojo.byId('phrase');
element.setAttribute("autocomplete","OFF");
dojo.connect(element, "onkeyup", function(e){
if(e.keyCode == dojo.keys.ENTER){
room.chat(dojo.byId('phrase').value);
dojo.byId('phrase').value='';
return false;
}
return true;
});
element=dojo.byId('sendB');
element.onclick = function(){
room.chat(dojo.byId('phrase').value);
dojo.byId('phrase').value='';
}
element=dojo.byId('leaveB');
element.onclick = function(){
room.leave();
}
}
};
dojo.addOnLoad(room, "_init");
dojo.addOnUnload(room,"leave");
5)将此工程打包为cometd.war(Context Root Path必须是/cometd)部署到glassfish的domains/domain1/autodeploy/目录下。
重新测试http://localhost:8080/cometd/chat/index.html,期待已久的Cometd聊天室可以正常工作啦!