带Websocket的单房间聊天程序
这是一个使用 Websocket 创建简单聊天程序的示例。
引言
这是一个使用 Websocket 创建简单聊天程序的示例。
背景
这是一个使用 Websocket 创建简单聊天程序的示例。为了充分关注 Websocket,该示例被保持得尽可能简单,只支持一个聊天室。由于这是一个简单的聊天室,用户无需密码即可登录房间。尽管后端是用 Java 在 Tomcat 服务器上创建的,但它应该对使用 .Net 或 Node.js 等其他平台的人们具有一定的参考价值。
附带的是一个 Maven Web 项目。我已使用 Java 1.8.0_65 和 Tomcat 7.0.54 对其进行了测试。我使用 Eclipse Java EE IDE for Web Developers Mars.1 Release (4.5.1) 作为我的开发 IDE。我还将其部署在 Amazon EC2 Ubuntu 实例上,并使用 Chrome、Firefox 和一些移动设备进行了测试。
服务器环境设置
在 Tomcat 7 及以上版本上使用 Websocket 不需要特殊的环境设置。至少在 Tomcat 7.0.54 上是这样的。在 "web.xml" 文件中也没有对 Websocket 的特殊配置。以下是附带 Maven 项目中的 "pom.xml" 文件。
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.song.example</groupId>
<artifactId>single-room-chat</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>war</packaging>
<properties>
<tomcat.version>7.0.55</tomcat.version>
<websocket.version>1.1</websocket.version>
<jackson.version>2.6.4</jackson.version>
</properties>
<dependencies>
<!-- Dependency needed by the Web-socket -->
<!-- Tomcat has it, so no need to package into the war file -->
<dependency>
<groupId>javax.websocket</groupId>
<artifactId>javax.websocket-api</artifactId>
<version>${websocket.version}</version>
<scope>provided</scope>
</dependency>
<!-- Used to serialize the message from the browser -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- Sevlet jars for compilation, provided by Tomcat -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-servlet-api</artifactId>
<version>${tomcat.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
<plugin>
<artifactId>maven-war-plugin</artifactId>
<version>2.4</version>
<configuration>
<warSourceDirectory>WebContent</warSourceDirectory>
<failOnMissingWebXml>true</failOnMissingWebXml>
</configuration>
</plugin>
</plugins>
</build>
</project>
- "javax.websocket-api" 依赖项仅用于编译代码。由于 Tomcat 7 及以上版本默认支持 Websocket,因此我们无需将此依赖项打包到 war 文件中;
- "jackson-databind" 依赖项用于序列化/反序列化来自 Web 浏览器的消息。如果您不需要序列化或想使用其他序列化方法,则无需将其添加为依赖项。
服务器与客户端之间的消息
对于简单的聊天室,消息也很简单。它只有一个消息类型和一个消息内容,这在 "ChatMessage.java" 文件中实现。
package com.song.chat.message;
public class ChatMessage {
private MessageType messageType;
private String message;
public void setMessageType(MessageType v) { this.messageType = v; }
public MessageType getMessageType() { return messageType; }
public void setMessage(String v) { this.message = v; }
public String getMessage() { return this.message; }
}
"MessageType" 在 "MessageType.java" 文件中被实现为一个 "enum"。
package com.song.chat.message;
public enum MessageType { LOGIN, MESSAGE }
此示例中只有两种消息类型。一种用于登录聊天室的请求,另一种用于发送消息以在房间中广播。
服务器端点
为了让浏览器通过 Websocket 与服务器通信,我们需要创建一个用 "@ServerEndpoint" 注释的类。
package com.song.web.socket;
import java.io.IOException;
import java.util.Map;
import java.util.logging.Logger;
import javax.websocket.CloseReason;
import javax.websocket.EndpointConfig;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.song.chat.message.ChatMessage;
import com.song.chat.message.MessageType;
import com.song.chat.room.Room;
@ServerEndpoint(value = "/chat")
public class ChatEndpoint {
private Logger log = Logger.getLogger(ChatEndpoint.class.getSimpleName());
private Room room = Room.getRoom();
@OnOpen
public void open(final Session session, EndpointConfig config) {}
@OnMessage
public void onMessage(final Session session, final String messageJson) {
ObjectMapper mapper = new ObjectMapper();
ChatMessage chatMessage = null;
try {
chatMessage = mapper.readValue(messageJson, ChatMessage.class);
} catch (IOException e) {
String message = "Badly formatted message";
try {
session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, message));
} catch (IOException ex) { log.severe(ex.getMessage()); }
} ;
Map<String, Object> properties = session.getUserProperties();
if (chatMessage.getMessageType() == MessageType.LOGIN) {
String name = chatMessage.getMessage();
properties.put("name", name);
room.join(session);
room.sendMessage(name + " - Joined the chat room");
}
else {
String name = (String)properties.get("name");
room.sendMessage(name + " - " + chatMessage.getMessage());
}
}
@OnClose
public void onClose(Session session, CloseReason reason) {
room.leave(session);
room.sendMessage((String)session.getUserProperties().get("name") + " - Left the room");
}
@OnError
public void onError(Session session, Throwable ex) { log.info("Error: " + ex.getMessage()); }
}
- "@ServerEndpoint" 注释表示 "ChatEndpoint" 是一个 Websocket 端点。访问此端点的 URL 应为 "ws://server-address:port-number/single-room-chat/chat";
- "@OnOpen" 注释表示当请求新的 Websocket 连接时,将调用 "onOpen" 方法;
- "@OnMessage" 注释表示当新消息到达时,将调用 "onMessage" 方法。消息可以是登录请求,也可以是将消息广播到房间的消息;
- "@OnClose" 注释表示当客户端关闭 Websocket 连接时,将调用 "onClose" 方法。即使连接关闭不是自愿的(可能是由于互联网连接丢失或错误情况),也会调用此方法;
- "@OnError" 注释表示发生错误时,将调用 "onError" 方法
在 Tomcat 环境中,"ChatEndpoint" 类不是单例。它具有会话范围,为来自客户端的每个 Websocket 连接会话实例化。一旦建立连接,会话实例将用于所有消息,直到连接会话关闭为止。"Room.java" 实现聊天室,负责将消息广播到所有活动的 Websocket 连接。
package com.song.chat.room;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import javax.websocket.Session;
public class Room {
private static Room instance = null;
private List<Session> sessions = new ArrayList<Session>();
public synchronized void join(Session session) { sessions.add(session); }
public synchronized void leave(Session session) { sessions.remove(session); }
public synchronized void sendMessage(String message) {
for (Session session: sessions) {
if (session.isOpen()) {
try { session.getBasicRemote().sendText(message); }
catch (IOException e) { e.printStackTrace(); }
}
}
}
public synchronized static Room getRoom() {
if (instance == null) { instance = new Room(); }
return instance;
}
}
- 由于该应用程序是单个聊天室,因此 "Room" 类是单例;
- 由于房间里会有很多用户,所有方法都是同步的,以避免冲突和竞态条件。
客户端
客户端 HTML 布局在 "index.jsp" 文件中实现。
<body>
<div id="container">
<div id="loginPanel">
<div id="infoLabel">Type a name to join the room</div>
<div style="padding: 10px;">
<input id="txtLogin" type="text" class="loginInput"
onkeyup="proxy.login_keyup(event)" />
<button type="button" class="loginInput" onclick="proxy.login()">Login</button>
</div>
</div>
<div id="msgPanel" style="display: none">
<div id="msgContainer" style="overflow: auto;"></div>
<div id="msgController">
<textarea id="txtMsg"
title="Enter to send message"
onkeyup="proxy.sendMessage_keyup(event)"
style="height: 20px; width: 100%"></textarea>
<button style="height: 30px; width: 100px" type="button"
onclick="proxy.logout()">Logout</button>
</div>
</div>
</div>
</body>
用于创建 Websocket 并与服务器通信的 JavaScript 在 "chatroom.js" 文件中实现。
var CreateProxy = function(wsUri) {
var websocket = null;
var audio = null;
var elements = null;
var playSound = function() {
if (audio == null) {
audio = new Audio('content/sounds/beep.wav');
}
audio.play();
};
var showMsgPanel = function() {
elements.loginPanel.style.display = "none";
elements.msgPanel.style.display = "block";
elements.txtMsg.focus();
};
var hideMsgPanel = function() {
elements.loginPanel.style.display = "block";
elements.msgPanel.style.display = "none";
elements.txtLogin.focus();
};
var displayMessage = function(msg) {
if (elements.msgContainer.childNodes.length == 100) {
elements.msgContainer.removeChild(elements.msgContainer.childNodes[0]);
}
var div = document.createElement('div');
div.className = 'msgrow';
var textnode = document.createTextNode(msg);
div.appendChild(textnode);
elements.msgContainer.appendChild(div);
elements.msgContainer.scrollTop = elements.msgContainer.scrollHeight;
};
var clearMessage = function() {
elements.msgContainer.innerHTML = '';
};
return {
login: function() {
elements.txtLogin.focus();
var name = elements.txtLogin.value.trim();
if (name == '') { return; }
elements.txtLogin.value = '';
// Initiate the socket and set up the events
if (websocket == null) {
websocket = new WebSocket(wsUri);
websocket.onopen = function() {
var message = { messageType: 'LOGIN', message: name };
websocket.send(JSON.stringify(message));
};
websocket.onmessage = function(e) {
displayMessage(e.data);
showMsgPanel();
playSound();
};
websocket.onerror = function(e) {};
websocket.onclose = function(e) {
websocket = null;
clearMessage();
hideMsgPanel();
};
}
},
sendMessage: function() {
elements.txtMsg.focus();
if (websocket != null && websocket.readyState == 1) {
var input = elements.txtMsg.value.trim();
if (input == '') { return; }
elements.txtMsg.value = '';
var message = { messageType: 'MESSAGE', message: input };
// Send a message through the web-socket
websocket.send(JSON.stringify(message));
}
},
login_keyup: function(e) { if (e.keyCode == 13) { this.login(); } },
sendMessage_keyup: function(e) { if (e.keyCode == 13) { this.sendMessage(); } },
logout: function() {
if (websocket != null && websocket.readyState == 1) { websocket.close();}
},
initiate: function(e) {
elements = e;
elements.txtLogin.focus();
}
}
};
"CreateProxy" 函数在 "index.jsp" 文件中的 "DOMContentLoaded" 事件中使用。
var proxy = CreateProxy(wsUri);
document.addEventListener("DOMContentLoaded", function(event) {
console.log(document.getElementById('loginPanel'));
proxy.initiate({
loginPanel: document.getElementById('loginPanel'),
msgPanel: document.getElementById('msgPanel'),
txtMsg: document.getElementById('txtMsg'),
txtLogin: document.getElementById('txtLogin'),
msgContainer: document.getElementById('msgContainer')
});
});
您可能想稍微注意一下 "chatroom.js" 文件中的 "login" 函数。这是在客户端创建 Websocket 的地方。您还应该注意到它还设置了与服务器端点类似的 "onopen"、"onmessage"、"onclose" 和 "onerror" 事件。根据我的经验,"onerror" 方法可能并不总是可靠触发,但 "onclose" 事件总是会触发,即使关闭是由于互联网连接丢失或 Web 服务器关闭。
运行应用程序
附带的是一个 Maven 项目。您可以通过 "mvn clean install" 来构建它,并将 war 文件部署到 Tomcat 服务器以运行它。您也可以将其导入 Eclipse 中运行。如果您不确定如何将 Maven 项目导入 Eclipse,可以参考此链接。应用程序启动后,您将看到登录页面,要求您输入任何用户名即可登录聊天室。
登录房间后,您可以发送和接收消息。您可以尝试在多个浏览器窗口中打开网页,看看消息是否可靠地在房间中广播。
关注点
- 这是一个使用 Websocket 创建简单聊天程序的示例;
- 它有点粗糙,但足够简单,因此重点在于 Websoket;
- 每当我们谈论套接字时,我们都需要仔细考虑其可靠性,因为互联网本身并不可靠。我已经将此示例部署在 Amazon EC2 Ubuntu 实例上,并在使用移动互联网连接购物时用一些移动设备进行了测试;
- 虽然这个例子是在 Tomcat 上运行的,但如果您的平台是 .Net 或 Node.js,它应该具有一定的参考价值;
- 我希望您喜欢我的帖子,也希望这个例子能在某个方面帮助到您。
历史
第一次修订 - 2016/1/25