文章目录
  1. 1. 准备工作
    1. 1.1. 配置Tomcat
    2. 1.2. 添加catalina.jar
  2. 2. Java后台
    1. 2.1. CometServlet
    2. 2.2. MessageSender
    3. 2.3. AjaxMessageServlet
  3. 3. Web前端
  4. 4. 源码

首先,无图无真相,先上图:
Web聊天室
这是一个基于Comet实现的聊天室Demo,功能类似于QQ群聊。聊天过程中如果有新想消息,那么就需要服务器推送消息到浏览器,所以这里可以使用Comet技术。

Comet一般有两种实现方式:长轮询(long-polling)、流(streaming)。而本文中的这个Demo的实现方式是基于流(streaming),前端使用了一个隐藏的iframe,这也是比较常用的一种方式。不过由于使用iframe流,导致浏览器上面的进度一直在转,这是因为iframe一直在加载的原因,先不要在意这些细节。

Tomcat提供了Comet相关的API,用Servlet实现CometProcessor接口就可以很简单的实现Comet了。

准备工作

配置Tomcat

首先,需要配置Tomcat连接为NIO,否则无法使用Tomcat Comet。

Tomcat目录下conf/server.xml,protocol更改为org.apache.coyote.http11.Http11NioProtocol:

1
<Connector connectionTimeout="20000" port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol" redirectPort="8443"/>

添加catalina.jar

在开发过程中,需要用到Tomcat的catalina.jar包,在Tomcat的lib目录下。程序在Tomcat中运行时再去掉。

Java后台

CometServlet

这个Servlet是处理Comet Http长连接的Servlet,这个Servlet实现Tomcat提供的CometProcessor接口,通过event方法来处理Http长连接周期内的多种事件:

BEGIN事件:有新的HTTP连接;
END事件:连接关闭,例如浏览器关闭;
ERROR事件:连接错误,例如timeout。

有关事件更详细介绍在Tomcat官方文档中有:http://tomcat.apache.org/tomcat-7.0-doc/aio.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@WebServlet("/comet")
public class CometServlet extends HttpServlet implements CometProcessor {

// 所有正在等待响应的HTTP长连接
private ArrayList<HttpServletResponse> connections = null;

// 用于发送消息的线程
private MessageSender messageSender = null;

// 启动消息处理线程
public void init() {
connections = new ArrayList<HttpServletResponse>();
messageSender = new MessageSender(connections);
Thread messageSenderThread = new Thread(messageSender);
messageSenderThread.start();
}

public void event(CometEvent event) throws IOException, ServletException {

HttpServletResponse response = event.getHttpServletResponse();
response.setCharacterEncoding("UTF-8");

if (event.getEventType() == CometEvent.EventType.BEGIN) {
System.out.println("BEGIN");

// 一段大于1024的字符串,针对某些浏览器缓存
PrintWriter out = response.getWriter();
StringBuilder sb = new StringBuilder();
for(int i = 0; i < 1024; i++) {
sb.append('a');
}
out.println("<!-- " + sb.toString() + " -->"); // 注意加上HTML注释
out.flush();

synchronized(connections) {
connections.add(response);
System.out.println("当前在线用户:" + connections.size());
}

} else if (event.getEventType() == CometEvent.EventType.ERROR) {
System.out.println("ERROR");

synchronized(connections) {
connections.remove(response);
System.out.println("当前在线用户:" + connections.size());
}
event.close();

} else if (event.getEventType() == CometEvent.EventType.END) {
System.out.println("END");

synchronized(connections) {
connections.remove(response);
System.out.println("当前在线用户:" + connections.size());
}
event.close();

}
}

}

在这个Servlet中,ArrayList<HttpServletResponse> connections用于保存正在等待响应的HTTP长连接,是HttpServletResponse对象,可以理解为所有在线用户。在BEGIN事件中会向connections中添加一个连接,在END和ERROR事件中会将对应的连接删除。

在Servlet初始化init的时候,启动一个线程用于处理聊天消息,并把connections传过去。

在BEGIN事件中,先通过response的输出流输出了一段大于1024的字符串,这是由于浏览器的缓存原因,如果没有的话在某些浏览器下会有要等到流写到一定字节数后再显示的情况。这段字符串没有实际意义,所以可以随便写什么,但不要忘了加上HTML注释。

MessageSender

MessageSender是处理聊天消息的一个线程,实现Runnable接口。当有新的聊天信息时,它通过HttpServletResponse的输出流立即将信息发送到所有连接的客户端,没有新的信息则处于阻塞状态。

处理聊天消息的时候使用了java.util.concurrent中的阻塞队列ArrayBlockingQueue。ArrayBlockingQueue.take()方法用于获取并移除队列中的一个元素,当队列为空时该方法阻塞当前线程,直到有其他线程向这个队列中添加新元素。当然这里也可以用wait/notify来替代。

实际上可以将其理解成一个生产者消费者问题,有用户发送消息到服务器相当于生产一条消息,而这个线程将消息发送给所以用户相当于消费一条消息,而这个阻塞队列即是缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class MessageSender implements Runnable {

// 所有正在等待响应的HTTP长连接
private ArrayList<HttpServletResponse> connections;

// 未发送给客户端的消息集合
public static ArrayBlockingQueue<String> messages = new ArrayBlockingQueue<String>(10);

public MessageSender(ArrayList<HttpServletResponse> connections) {
this.connections = connections;
}

public void run() {

while(true) {

// 消息阻塞队列中获取一条消息,如果队列为空则阻塞
String message = null;
try {
message = messages.take();
} catch (InterruptedException e) {
e.printStackTrace();
}

// 给每个客户端发送消息
synchronized (connections) {

for(HttpServletResponse response : connections) {
try {
PrintWriter out = response.getWriter();

// 输出一段脚本,调用JS将消息显示在页面上
out.println("<script>parent.addMsg('" + message + "<br>')</script>");
out.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
}

AjaxMessageServlet

这个Servlet用于处理用户发送信息的请求,这是一个普通的Http请求而不是长连接。点击页面中的“发送”按钮时,就会通过Ajax向这个Servlet提交聊天信息。

当接受到新的消息时,向MessageSender中的阻塞队列ArrayBlockingQueue中put添加一条数据。当有新的数据,队列不为空时,MessageSender线程不再阻塞,会立即将消息发送到客户端浏览器。这就相当于通知MessageSender线程发送消息给客户端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@WebServlet("/sendMsg")
public class AjaxMessageServlet extends HttpServlet {

public void doPost(HttpServletRequest request, HttpServletResponse response) throws UnsupportedEncodingException {

request.setCharacterEncoding("UTF-8");
try {
// 这就相当于通知MessageSender线程发送消息给客户端
MessageSender.messages.put("[" + request.getParameter("name") + "]: " + request.getParameter("msg"));
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public void doGet(HttpServletRequest request, HttpServletResponse response) throws UnsupportedEncodingException {
doPost(request, response);
}
}

Web前端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<!DOCTYPE HTML>
<html>
<head>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<script type="text/javascript">

// 向HTML追加message,这个函数是给服务器向iframe中添加的javascript脚本调用
function addMsg(msg) {
var msgElement = document.getElementById("msg");
msgElement.innerHTML += msg;
}

// 点击“发送”按钮后Ajax发送消息
function sendMsg() {
var xmlhttp = new XMLHttpRequest();
xmlhttp.open("POST", "sendMsg"); // sendMsg是AjaxMessageServlet对应的URL
xmlhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

var name = document.getElementById("input-name").value;
var msg = document.getElementById("input-msg").value;
xmlhttp.send("name=" + encodeURIComponent(name) + "&msg=" + encodeURIComponent(msg));
document.getElementById("input-msg").value = "";
}

// 服务器timeout后再重新加载iframe
function iframeRefresh() {
var iframeElement = document.getElementById("iframe");
iframeElement.src = iframeElement.src;
}

</script>
</head>
<body>

<!-- 隐藏的iframe,src="comet"是CometServlet对应的URL,用于加载服务器推送的JS脚本 -->
<iframe id="iframe" style="display: none;" src="comet" onload="iframeRefresh();"></iframe>

<div id="msg" style="height: 400px; width: 400px; overflow: scroll;"></div>

姓名:<input type="text" id="input-name"><br>
消息:<br>
<textarea id="input-msg" cols="30" rows="4"></textarea><br>
<input type="button" value="发送" onclick="sendMsg();">

</body>
</html>

JS中,addMsg函数是提供给MessageSender中输出的js脚本来调用的,用于将消息显示在页面上。

sendMsg函数是“发送”按钮点击事件,将聊天信息发送到AjaxMessageServlet。

iframeRefresh函数是在服务器超时的时候reload重新加载iframe,timeout对服务器来说是超时,对客户端来说是加载完成,所以在iframe的onload中调用。设置timeout超时时间可以在BEGIN事件中用event.setTimeout(30 * 1000)或event.getHttpServletRequest().setAttribute(“org.apache.tomcat.comet.timeout”, new Integer(30 * 1000))来设置。

页面上的iframe设置成display: none也就是不显示,src是CometServle对应的URL,当有新的信息时,MessageSender会向iframe中输出一段JS:

1
out.println("<script>parent.addMsg('" + message + "<br>')</script>");

浏览器加载到这段JS后会立即运行,调用addMsg函数将信息显示在页面上。

源码

Demo on GitHub: https://github.com/wucao/tomcat-comet-chat-room-demo

文章目录
  1. 1. 准备工作
    1. 1.1. 配置Tomcat
    2. 1.2. 添加catalina.jar
  2. 2. Java后台
    1. 2.1. CometServlet
    2. 2.2. MessageSender
    3. 2.3. AjaxMessageServlet
  3. 3. Web前端
  4. 4. 源码