使用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$ 标签内网页编写并不是重点,因此我在此省略。我使用了jQuerymoment框架,后者用于处理时间(也就是 $server.onmessage$ 方法中出现的 $moment.unix$ 方法);$log$ 方法用于向网页中添加聊天记录。在判断完浏览器支持WebSocket之后,我们创建 $WebSocket$ 实例,并将类型设为 $arraybuffer$ ,这样我们就能以 $ArrayBuffer$ 的形式传输消息对象了。接下来就是定义WebSocket和网页的事件方法了。为了传输对象,我先将对象转换为JSON字符串,再将该字符串转换为 $Uint8Array$ ,而处理接收到的对象的过程就是反过来。可以看到我们使用了 $TextEncoder$ 和 $TextDecoder$ 用于编码和译码,主要目的是防止传输中文的过程中出现的乱码,具体可以在我的上一篇博文中查看。
        至此聊天室实现完毕,没有写CSS(因为懒)。可以在本地部署和测试,通过打开多个浏览器的形式实现创建多个用户。