使用WebSocket实现的一个简单的多人聊天室
环境:JDK1.8, tomcat-8.5.51
源码可以在GitHub
上查阅:ChatRoom
WebSocket
最常用的地方就是聊天室,所以我用Servlet
+WebSocket
实现了一个简单的多人聊天室。这个多人聊天室改一改就能作为一对一的聊天室使用,毕竟实现方法都是差不多的。
首先是maven
依赖,在此只展示几个要注意的依赖项:
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
<scope>provided</scope>
</dependency>
......
......
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.10.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.10.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
Lombok
简化实体的编写(虽然只有一个实体),然后jackson
相关的依赖用于Java
实例和JSON
对象之间的相互转化。为了避免兼容问题,我这里选择依赖基本上都是最新版本。
然后是实体,$ChatMessage$ 用于存储消息相关的信息,使用lombok
简化了编写:
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.OffsetDateTime;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ChatMessage {
private String username;
private String message;
private OffsetDateTime timestamp;
}
有了实体之后再建立仓库,用于存储聊天信息以及用户信息:
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArraySet;
public class ChatRepository {
private static List<ChatMessage> repository = new LinkedList<>();
private static CopyOnWriteArraySet<String> users = new CopyOnWriteArraySet<>();
public static List<ChatMessage> getRepository() { /*...*/ }
public static synchronized void addChat(ChatMessage chatMessage) {
repository.add(chatMessage);
if (repository.size() > 10) repository.remove(0);
}
public static CopyOnWriteArraySet<String> getUsers() { /*...*/ }
}
没有连接数据库,因此这里使用了 $LinkedList$ 在内存存储聊天记录,$addChat$ 方法用于添加聊天记录,设置为只存储 $10$ 条记录。要注意的是 $addChat$ 方法上使用了 $synchronized$ 关键字用于保证同步。不过这里有个小问题就是通过 $Getter$ 获取的 $List$ 的 $add$ 方法并没有同步,因此可以的话还是使用阻塞容器比较好。接着我使用了同步容器 $CopyOnWriteArraySet$ 用于存储用户列表。
然后就是Servlet
,我把Servlet
用于处理用户名的填写及转发,在转发完成后,就全部交予WebSocket
服务终端进行管理了。
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet(name = "chatRoomServlet", urlPatterns = "/room")
public class ChatRoomServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
resp.sendRedirect("/ChatRoom");
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String username = req.getParameter("username");
if (username == null || username.trim().length() == 0) {
req.setAttribute("error", "empty");
req.getRequestDispatcher("/index.jsp").forward(req, resp);
} else if (!username.matches("^[0-9a-zA-z_]+$")) {
req.setAttribute("error", "illegal");
req.getRequestDispatcher("/index.jsp").forward(req, resp);
} else if (ChatRepository.getUsers().contains(username)) {
req.setAttribute("error", "duplicated");
req.getRequestDispatcher("/index.jsp").forward(req, resp);
} else {
req.setAttribute("username", username);
req.setAttribute("chatRepository", ChatRepository.getRepository());
req.getRequestDispatcher("/WEB-INF/jsp/view/chatRoom/room.jsp").forward(req, resp);
}
}
}
我这里使用了 $index.jsp$ 文件处理表单的填写和提交,在以GET
方式访问时 $doGet$ 方法将网址重定向至用户名填写的页面,也就是 $index.jsp$,由于实现逻辑比较简单,因此在这里不做展示。$doPost$ 方法用于判断用户名的合法性,如果合法,就转到聊天室界面,也就是 $room.jsp$ 。
在实现服务终端之前,我们要先实现编码器和译码器,用于处理JSON
对象。由于实现途径是实现接口,因此我们可以在一个类内实现编码和译码:
import com.fasterxml.jackson.core.JsonGenerationException;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.websocket.*;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
public class ChatMessageCodec
implements Encoder.BinaryStream<ChatMessage>, Decoder.BinaryStream<ChatMessage> {
private static final ObjectMapper MAPPER = new ObjectMapper();
static {
MAPPER.findAndRegisterModules();
MAPPER.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);
}
@Override
public void encode(ChatMessage chatMessage, OutputStream outputStream)
throws EncodeException, IOException {
try {
ChatMessageCodec.MAPPER.writeValue(outputStream, chatMessage);
} catch (JsonGenerationException | JsonMappingException e) {
throw new EncodeException(chatMessage, e.getMessage(), e);
}
}
@Override
public ChatMessage decode(InputStream inputStream) throws DecodeException, IOException {
try {
return ChatMessageCodec.MAPPER.readValue(inputStream, ChatMessage.class);
} catch (JsonParseException | JsonMappingException e) {
throw new DecodeException((ByteBuffer) null, e.getMessage(), e);
}
}
@Override
public void init(EndpointConfig endpointConfig) {}
@Override
public void destroy() {}
}
在这里使用了之前导入的依赖jackson
包中的 $ObjectMapper$ 用于处理JSON
对象,$init$ 和 $destroy$ 方法都是空实现,如果需要的话也可以修改。
接下来是服务终端:
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.util.concurrent.CopyOnWriteArraySet;
@ServerEndpoint(
value = "/room/{username}",
encoders = ChatMessageCodec.class,
decoders = ChatMessageCodec.class)
public class ChatRoomServer {
private static CopyOnWriteArraySet<Session> sessions = new CopyOnWriteArraySet<>();
@OnOpen
public void onOpen(Session session, @PathParam("username") String username) {
ChatRepository.getUsers().add(username);
ChatMessage chatMessage = new ChatMessage(username, "加入了聊天室", OffsetDateTime.now());
try {
for (Session s : sessions) {
if (s.isOpen()) s.getBasicRemote().sendObject(chatMessage);
}
} catch (IOException | EncodeException e) {
onError(session, e);
}
sessions.add(session);
session.getUserProperties().put("username", username);
}
@OnMessage
public void onMessage(Session session, ChatMessage chatMessage) {
/*send message to every session*/
}
@OnError
public void onError(Session session, Throwable e) {
String username = (String) session.getUserProperties().get("username");
ChatMessage chatMessage = new ChatMessage(username, "因错误而断开了连接", OffsetDateTime.now());
/* send message to every session */
}
@OnClose
public void onClose(Session session, CloseReason closeReason) {
if (closeReason.getCloseCode() == CloseReason.CloseCodes.NORMAL_CLOSURE) {
sessions.remove(session);
String username = (String) session.getUserProperties().get("username");
ChatRepository.getUsers().remove(username);
ChatMessage chatMessage =
new ChatMessage(username, username + "离开了聊天室", OffsetDateTime.now());
/* send message to every session */
}
}
}
首先使用了 $@ServerEndpoint$ 注解标出服务终端,要注意注解使用了 $encoders$ 和 $decoders$ 设置了编码器和解码器(也就是之前实现的 $ChatMessageCodec$ 类),通过 $CopyOnWriteArraySet$ 存储了所有加入聊天室的会话,由于发送消息的代码大体一致,因此我省略了之后几个方法发送消息的实现。
最后就是聊天室的实现了,也就是 $room.jsp$ 文件。
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%--@elvariable id="username" type="java.lang.String"--%>
<%--@elvariable id="chatRepository" type="java.util.List<org.example.ChatMessage>"--%>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>聊天室</title>
<script src="https://code.jquery.com/jquery-3.5.0.js"
integrity="sha256-r/AaFHrszJtwpe+tHyNi/XCfMxYpbsRg2Uqn0x3s2zc=" crossorigin="anonymous"></script>
<script src="http://cdn.staticfile.org/moment.js/2.24.0/moment-with-locales.js"></script>
</head>
<body>
<!---省略--->
<script type="text/javascript" lang="javascript">
$(document).ready(function () {
let error = $("#chat-room-error");
let errorBody = $("#chat-room-error-body");
let waiting = $("#chat-room-waiting");
let waitingBody = $("#chat-room-waiting-body");
let messageShow = $("#chat-room-message-show");
let messageText = $("#chat-room-message-text");
let messageSendLog = $("#chat-room-message-send-log");
let encoder = new TextEncoder("utf-8");
let decoder = new TextDecoder("utf-8");
let server = null;
let log = function (username, message, timestamp) {
messageShow
.append($('<div>')
.addClass('chat-room-username')
.text((username === '${username}' ? '你' : username) + ' 发布于:' + timestamp.toLocaleString()))
.append($('<div>')
.addClass('chat-room-message')
.text(message))
.append($('<br/>'));
}
let sendToHome = function () {
let button = $('<input>').attr('type', 'submit');
$('body').append($('<div>').append($('<form>').attr('method', 'GET').attr('action', '/ChatRoom').append(button)));
button.click();
}
if (!("WebSocket" in window)) {
errorBody.text('WebSocket are not supported in this browser. Try to update your browser to the latest version.');
error.show();
return;
}
messageSendLog.hide();
error.hide();
waiting.show();
try {
server = new WebSocket("ws://" + window.location.host + '<c:url value="/room/${username}"/>');
server.binaryType = "arraybuffer";
waiting.hide();
} catch (e) {
waiting.hide();
errorBody.text(e);
error.show();
return;
}
server.onopen = function (event) {
waiting.hide();
log('${username}', '加入了聊天室', new Date());
}
server.onclose = function (event) {
if (server != null) {
log('${username}', '离开了聊天室', new Date());
}
server = null;
if (!event.wasClean || event.code !== 1000) {
errorBody.text('Code ' + event.code + ': ' + event.reason);
error.show();
}
}
server.onerror = function (event) {
errorBody.text(event.data);
error.show();
}
server.onmessage = function (event) {
if (event.data instanceof ArrayBuffer) {
let message = JSON.parse(decoder.decode(new Uint8Array(event.data)));
log(message.username, message.message, moment.unix(message.timestamp).format('YYYY/MM/DD kk:mm:ss'));
} else {
errorBody.text('Unexpected data type [' + typeof event.data + '].');
error.show();
}
}
sendMessage = function () {
if (server === null) {
errorBody.text("未连接");
error.show();
} else {
let text = messageText.val();
if (text === "" || text.trim().length === 0) {
messageSendLog.show();
return;
}
messageSendLog.hide();
let message = {
username: '${username}',
message: text,
timestamp: new Date()
}
try {
let json = JSON.stringify(message);
let array = encoder.encode(json);
server.send(array.buffer);
messageText.val('');
} catch (e) {
errorBody.text(e);
error.show();
}
}
}
disconnect = function () {
if (server !== null) {
log('${username}', '离开了聊天室', new Date());
server.close();
server = null;
sendToHome();
}
}
window.onbeforeunload = disconnect;
});
</script>
</body>
</html>
$body$ 标签内网页编写并不是重点,因此我在此省略。我使用了jQuery
和moment
框架,后者用于处理时间(也就是 $server.onmessage$ 方法中出现的 $moment.unix$ 方法);$log$ 方法用于向网页中添加聊天记录。在判断完浏览器支持WebSocket
之后,我们创建 $WebSocket$ 实例,并将类型设为 $arraybuffer$ ,这样我们就能以 $ArrayBuffer$ 的形式传输消息对象了。接下来就是定义WebSocket
和网页的事件方法了。为了传输对象,我先将对象转换为JSON
字符串,再将该字符串转换为 $Uint8Array$ ,而处理接收到的对象的过程就是反过来。可以看到我们使用了 $TextEncoder$ 和 $TextDecoder$ 用于编码和译码,主要目的是防止传输中文的过程中出现的乱码,具体可以在我的上一篇博文中查看。
至此聊天室实现完毕,没有写CSS
(因为懒)。可以在本地部署和测试,通过打开多个浏览器的形式实现创建多个用户。