美文网首页java基础软件测试goSpringBoot实战系列
[WebSocket入门]手把手搭建WebSocket多人在线聊

[WebSocket入门]手把手搭建WebSocket多人在线聊

作者: 蛮三刀酱 | 来源:发表于2019-08-08 19:14 被阅读0次

    前言

    本文中搭建了一个简易的多人聊天室,使用了WebSocket的基础特性。

    源代码来自老外的一篇好文:

    https://www.callicoder.com/spring-boot-websocket-chat-example/

    本文内容摘要:

    • 初步理解WebSocket的前后端交互逻辑
    • 手把手使用 SpringBoot + WebSocket 搭建一个多人聊天室Demo
    • 代码源码及其解释
    • 前端展示页面

    此外,在下一篇文章中,我们将做到:

    • 对该WebSocket聊天室进行分布式改造,同时部署多台机器来作为集群,支撑高并发。
    • 保存用户session,并且在集群上实现session同步,比如实时展示当前在线的用户!

    正文

    WebSocket多人在线聊天室

    本文工程源代码:

    https://github.com/qqxx6661/springboot-websocket-demo

    新建工程

    我们新建一个SpringBoot2的项目工程,在默认依赖中,添加websocket依赖:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
    

    WebSocket 配置

    我们先来设置websocket的配置,新建config文件夹,在里面新建类WebSocketConfig

    import org.springframework.context.annotation.Configuration;
    import org.springframework.messaging.simp.config.MessageBrokerRegistry;
    import org.springframework.web.socket.config.annotation.*;
    
    @Configuration
    @EnableWebSocketMessageBroker
    public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/ws").withSockJS();
        }
    
        @Override
        public void configureMessageBroker(MessageBrokerRegistry registry) {
            registry.setApplicationDestinationPrefixes("/app");
            registry.enableSimpleBroker("/topic");
        }
    }
    

    代码解释:

    @EnableWebSocketMessageBroker用于启用我们的WebSocket服务器。

    我们实现了WebSocketMessageBrokerConfigurer接口,并实现了其中的方法。

    在第一种方法中,我们注册一个websocket端点,客户端将使用它连接到我们的websocket服务器。

    withSockJS()是用来为不支持websocket的浏览器启用后备选项,使用了SockJS。

    方法名中的STOMP是来自Spring框架STOMP实现。 STOMP代表简单文本导向的消息传递协议。它是一种消息传递协议,用于定义数据交换的格式和规则。为啥我们需要这个东西?因为WebSocket只是一种通信协议。它没有定义诸如以下内容:如何仅向订阅特定主题的用户发送消息,或者如何向特定用户发送消息。我们需要STOMP来实现这些功能。

    在configureMessageBroker方法中,我们配置一个消息代理,用于将消息从一个客户端路由到另一个客户端。

    第一行定义了以“/app”开头的消息应该路由到消息处理方法(之后会定义这个方法)。

    第二行定义了以“/topic”开头的消息应该路由到消息代理。消息代理向订阅特定主题的所有连接客户端广播消息。

    在上面的示例中,我们使用的是内存中的消息代理。

    之后也可以使用RabbitMQ或ActiveMQ等其他消息代理。

    创建 ChatMessage 实体

    ChatMessage用来在客户端和服务端中交互

    我们新建model文件夹,创建实体类ChatMessage。

    public class ChatMessage {
        private MessageType type;
        private String content;
        private String sender;
    
        public enum MessageType {
            CHAT,
            JOIN,
            LEAVE
        }
    
        public MessageType getType() {
            return type;
        }
    
        public void setType(MessageType type) {
            this.type = type;
        }
    
        public String getContent() {
            return content;
        }
    
        public void setContent(String content) {
            this.content = content;
        }
    
        public String getSender() {
            return sender;
        }
    
        public void setSender(String sender) {
            this.sender = sender;
        }
    }
    

    实体中,有三个字段:

    • type:消息类型
    • content:消息内容
    • sender:发送者

    类型有三种:

    • CHAT: 消息
    • JOIN:加入
    • LEAVE:离开

    创建Controller来接收和发送消息

    创建controller文件夹,在controller文件夹添加类ChatController

    import com.example.websocketdemo.model.ChatMessage;
    import org.springframework.messaging.handler.annotation.MessageMapping;
    import org.springframework.messaging.handler.annotation.Payload;
    import org.springframework.messaging.handler.annotation.SendTo;
    import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
    import org.springframework.stereotype.Controller;
    
    @Controller
    public class ChatController {
    
        @MessageMapping("/chat.sendMessage")
        @SendTo("/topic/public")
        public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
            return chatMessage;
        }
    
        @MessageMapping("/chat.addUser")
        @SendTo("/topic/public")
        public ChatMessage addUser(@Payload ChatMessage chatMessage, 
                                   SimpMessageHeaderAccessor headerAccessor) {
            // Add username in web socket session
            headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
            return chatMessage;
        }
    }
    

    代码解释:

    我们在websocket配置中,从目的地以/app开头的客户端发送的所有消息都将路由到这些使用@MessageMapping注释的消息处理方法。

    例如,具有目标/app/chat.sendMessage的消息将路由到sendMessage()方法,并且具有目标/app/chat.addUser的消息将路由到addUser()方法

    添加WebSocket事件监听

    完成了上述代码后,我们还需要对socket的连接和断连事件进行监听,这样我们才能广播用户进来和出去等操作。

    创建listener文件夹,新建WebSocketEventListener类

    import com.example.websocketdemo.model.ChatMessage;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.event.EventListener;
    import org.springframework.messaging.simp.SimpMessageSendingOperations;
    import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
    import org.springframework.stereotype.Component;
    import org.springframework.web.socket.messaging.SessionConnectedEvent;
    import org.springframework.web.socket.messaging.SessionDisconnectEvent;
    
    @Component
    public class WebSocketEventListener {
    
        private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class);
    
        @Autowired
        private SimpMessageSendingOperations messagingTemplate;
    
        @EventListener
        public void handleWebSocketConnectListener(SessionConnectedEvent event) {
            logger.info("Received a new web socket connection");
        }
    
        @EventListener
        public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
            StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
    
            String username = (String) headerAccessor.getSessionAttributes().get("username");
            if(username != null) {
                logger.info("User Disconnected : " + username);
    
                ChatMessage chatMessage = new ChatMessage();
                chatMessage.setType(ChatMessage.MessageType.LEAVE);
                chatMessage.setSender(username);
    
                messagingTemplate.convertAndSend("/topic/public", chatMessage);
            }
        }
    }
    

    代码解释:

    我们已经在ChatController中定义的addUser()方法中广播了用户加入事件。因此,我们不需要在SessionConnected事件中执行任何操作。

    在SessionDisconnect事件中,编写代码用来从websocket会话中提取用户名,并向所有连接的客户端广播用户离开事件。

    创建前端聊天室页面

    我们在src/main/resources文件下创建前端文件,结构类似这样:

    static
      └── css
           └── main.css
      └── js
           └── main.js
      └── index.html   
    

    1. HTML文件 index.html

    HTML文件包含用于显示聊天消息的用户界面。 它包括sockjs和stomp 两个js库。

    SockJS是一个WebSocket客户端,它尝试使用本机WebSockets,并为不支持WebSocket的旧浏览器提供支持。 STOMP JS是javascript的stomp客户端。

    笔者在文件里使用了国内的CDN源

    <!DOCTYPE html>
    <html>
      <head>
          <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
          <title>Spring Boot WebSocket Chat Application</title>
          <link rel="stylesheet" href="/css/main.css" />
      </head>
      <body>
        <noscript>
          <h2>Sorry! Your browser doesn't support Javascript</h2>
        </noscript>
    
        <div id="username-page">
            <div class="username-page-container">
                <h1 class="title">Type your username</h1>
                <form id="usernameForm" name="usernameForm">
                    <div class="form-group">
                        <input type="text" id="name" placeholder="Username" autocomplete="off" class="form-control" />
                    </div>
                    <div class="form-group">
                        <button type="submit" class="accent username-submit">Start Chatting</button>
                    </div>
                </form>
            </div>
        </div>
    
        <div id="chat-page" class="hidden">
            <div class="chat-container">
                <div class="chat-header">
                    <h2>Spring WebSocket Chat Demo</h2>
                </div>
                <div class="connecting">
                    Connecting...
                </div>
                <ul id="messageArea">
    
                </ul>
                <form id="messageForm" name="messageForm">
                    <div class="form-group">
                        <div class="input-group clearfix">
                            <input type="text" id="message" placeholder="Type a message..." autocomplete="off" class="form-control"/>
                            <button type="submit" class="primary">Send</button>
                        </div>
                    </div>
                </form>
            </div>
        </div>
    
        <script src="https://cdn.bootcss.com/sockjs-client/1.1.4/sockjs.min.js"></script>
        <script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script>
        <script src="/js/main.js"></script>
      </body>
    </html>
    

    2. JavaScript main.js

    添加连接到websocket端点以及发送和接收消息所需的javascript。

    'use strict';
    
    var usernamePage = document.querySelector('#username-page');
    var chatPage = document.querySelector('#chat-page');
    var usernameForm = document.querySelector('#usernameForm');
    var messageForm = document.querySelector('#messageForm');
    var messageInput = document.querySelector('#message');
    var messageArea = document.querySelector('#messageArea');
    var connectingElement = document.querySelector('.connecting');
    
    var stompClient = null;
    var username = null;
    
    var colors = [
        '#2196F3', '#32c787', '#00BCD4', '#ff5652',
        '#ffc107', '#ff85af', '#FF9800', '#39bbb0'
    ];
    
    function connect(event) {
        username = document.querySelector('#name').value.trim();
    
        if(username) {
            usernamePage.classList.add('hidden');
            chatPage.classList.remove('hidden');
    
            var socket = new SockJS('/ws');
            stompClient = Stomp.over(socket);
    
            stompClient.connect({}, onConnected, onError);
        }
        event.preventDefault();
    }
    
    
    function onConnected() {
        // Subscribe to the Public Topic
        stompClient.subscribe('/topic/public', onMessageReceived);
    
        // Tell your username to the server
        stompClient.send("/app/chat.addUser",
            {},
            JSON.stringify({sender: username, type: 'JOIN'})
        )
    
        connectingElement.classList.add('hidden');
    }
    
    
    function onError(error) {
        connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
        connectingElement.style.color = 'red';
    }
    
    
    function sendMessage(event) {
        var messageContent = messageInput.value.trim();
        if(messageContent && stompClient) {
            var chatMessage = {
                sender: username,
                content: messageInput.value,
                type: 'CHAT'
            };
            stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
            messageInput.value = '';
        }
        event.preventDefault();
    }
    
    
    function onMessageReceived(payload) {
        var message = JSON.parse(payload.body);
    
        var messageElement = document.createElement('li');
    
        if(message.type === 'JOIN') {
            messageElement.classList.add('event-message');
            message.content = message.sender + ' joined!';
        } else if (message.type === 'LEAVE') {
            messageElement.classList.add('event-message');
            message.content = message.sender + ' left!';
        } else {
            messageElement.classList.add('chat-message');
    
            var avatarElement = document.createElement('i');
            var avatarText = document.createTextNode(message.sender[0]);
            avatarElement.appendChild(avatarText);
            avatarElement.style['background-color'] = getAvatarColor(message.sender);
    
            messageElement.appendChild(avatarElement);
    
            var usernameElement = document.createElement('span');
            var usernameText = document.createTextNode(message.sender);
            usernameElement.appendChild(usernameText);
            messageElement.appendChild(usernameElement);
        }
    
        var textElement = document.createElement('p');
        var messageText = document.createTextNode(message.content);
        textElement.appendChild(messageText);
    
        messageElement.appendChild(textElement);
    
        messageArea.appendChild(messageElement);
        messageArea.scrollTop = messageArea.scrollHeight;
    }
    
    
    function getAvatarColor(messageSender) {
        var hash = 0;
        for (var i = 0; i < messageSender.length; i++) {
            hash = 31 * hash + messageSender.charCodeAt(i);
        }
        var index = Math.abs(hash % colors.length);
        return colors[index];
    }
    
    usernameForm.addEventListener('submit', connect, true)
    messageForm.addEventListener('submit', sendMessage, true)
    

    代码解释:

    connect()函数使用SockJS和stomp客户端连接到我们在Spring Boot中配置的/ws端点。

    成功连接后,客户端订阅/topic/public,并通过向/app/chat.addUser目的地发送消息将该用户的名称告知服务器。

    stompClient.subscribe()函数采用一种回调方法,只要消息到达订阅主题,就会调用该方法。

    其它的代码用于在屏幕上显示和格式化消息。

    3. CSS main.css

    * {
        -webkit-box-sizing: border-box;
        -moz-box-sizing: border-box;
        box-sizing: border-box;
    }
    
    html,body {
        height: 100%;
        overflow: hidden;
    }
    
    body {
        margin: 0;
        padding: 0;
        font-weight: 400;
        font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
        font-size: 1rem;
        line-height: 1.58;
        color: #333;
        background-color: #f4f4f4;
        height: 100%;
    }
    
    body:before {
        height: 50%;
        width: 100%;
        position: absolute;
        top: 0;
        left: 0;
        background: #128ff2;
        content: "";
        z-index: 0;
    }
    
    .clearfix:after {
        display: block;
        content: "";
        clear: both;
    }
    
    .hidden {
        display: none;
    }
    
    .form-control {
        width: 100%;
        min-height: 38px;
        font-size: 15px;
        border: 1px solid #c8c8c8;
    }
    
    .form-group {
        margin-bottom: 15px;
    }
    
    input {
        padding-left: 10px;
        outline: none;
    }
    
    h1, h2, h3, h4, h5, h6 {
        margin-top: 20px;
        margin-bottom: 20px;
    }
    
    h1 {
        font-size: 1.7em;
    }
    
    a {
        color: #128ff2;
    }
    
    button {
        box-shadow: none;
        border: 1px solid transparent;
        font-size: 14px;
        outline: none;
        line-height: 100%;
        white-space: nowrap;
        vertical-align: middle;
        padding: 0.6rem 1rem;
        border-radius: 2px;
        transition: all 0.2s ease-in-out;
        cursor: pointer;
        min-height: 38px;
    }
    
    button.default {
        background-color: #e8e8e8;
        color: #333;
        box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
    }
    
    button.primary {
        background-color: #128ff2;
        box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
        color: #fff;
    }
    
    button.accent {
        background-color: #ff4743;
        box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
        color: #fff;
    }
    
    #username-page {
        text-align: center;
    }
    
    .username-page-container {
        background: #fff;
        box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
        border-radius: 2px;
        width: 100%;
        max-width: 500px;
        display: inline-block;
        margin-top: 42px;
        vertical-align: middle;
        position: relative;
        padding: 35px 55px 35px;
        min-height: 250px;
        position: absolute;
        top: 50%;
        left: 0;
        right: 0;
        margin: 0 auto;
        margin-top: -160px;
    }
    
    .username-page-container .username-submit {
        margin-top: 10px;
    }
    
    
    #chat-page {
        position: relative;
        height: 100%;
    }
    
    .chat-container {
        max-width: 700px;
        margin-left: auto;
        margin-right: auto;
        background-color: #fff;
        box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
        margin-top: 30px;
        height: calc(100% - 60px);
        max-height: 600px;
        position: relative;
    }
    
    #chat-page ul {
        list-style-type: none;
        background-color: #FFF;
        margin: 0;
        overflow: auto;
        overflow-y: scroll;
        padding: 0 20px 0px 20px;
        height: calc(100% - 150px);
    }
    
    #chat-page #messageForm {
        padding: 20px;
    }
    
    #chat-page ul li {
        line-height: 1.5rem;
        padding: 10px 20px;
        margin: 0;
        border-bottom: 1px solid #f4f4f4;
    }
    
    #chat-page ul li p {
        margin: 0;
    }
    
    #chat-page .event-message {
        width: 100%;
        text-align: center;
        clear: both;
    }
    
    #chat-page .event-message p {
        color: #777;
        font-size: 14px;
        word-wrap: break-word;
    }
    
    #chat-page .chat-message {
        padding-left: 68px;
        position: relative;
    }
    
    #chat-page .chat-message i {
        position: absolute;
        width: 42px;
        height: 42px;
        overflow: hidden;
        left: 10px;
        display: inline-block;
        vertical-align: middle;
        font-size: 18px;
        line-height: 42px;
        color: #fff;
        text-align: center;
        border-radius: 50%;
        font-style: normal;
        text-transform: uppercase;
    }
    
    #chat-page .chat-message span {
        color: #333;
        font-weight: 600;
    }
    
    #chat-page .chat-message p {
        color: #43464b;
    }
    
    #messageForm .input-group input {
        float: left;
        width: calc(100% - 85px);
    }
    
    #messageForm .input-group button {
        float: left;
        width: 80px;
        height: 38px;
        margin-left: 5px;
    }
    
    .chat-header {
        text-align: center;
        padding: 15px;
        border-bottom: 1px solid #ececec;
    }
    
    .chat-header h2 {
        margin: 0;
        font-weight: 500;
    }
    
    .connecting {
        padding-top: 5px;
        text-align: center;
        color: #777;
        position: absolute;
        top: 65px;
        width: 100%;
    }
    
    
    @media screen and (max-width: 730px) {
    
        .chat-container {
            margin-left: 10px;
            margin-right: 10px;
            margin-top: 10px;
        }
    }
    
    @media screen and (max-width: 480px) {
        .chat-container {
            height: calc(100% - 30px);
        }
    
        .username-page-container {
            width: auto;
            margin-left: 15px;
            margin-right: 15px;
            padding: 25px;
        }
    
        #chat-page ul {
            height: calc(100% - 120px);
        }
    
        #messageForm .input-group button {
            width: 65px;
        }
    
        #messageForm .input-group input {
            width: calc(100% - 70px);
        }
    
        .chat-header {
            padding: 10px;
        }
    
        .connecting {
            top: 60px;
        }
    
        .chat-header h2 {
            font-size: 1.1em;
        }
    }
    

    整个项目结构如下:

    image.png

    启动

    启动SpringBoot项目

    效果入下:

    image.png image.png

    补充:使用RabbitMQ代替内存作为消息代理

    添加依赖:

    <!-- RabbitMQ Starter Dependency -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    
    <!-- Following additional dependency is required for Full Featured STOMP Broker Relay -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-reactor-netty</artifactId>
    </dependency>
    

    然后将WebSocketConfig类中configureMessageBroker方法改为使用RabbitMq,完成!

    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
    
        // Use this for enabling a Full featured broker like RabbitMQ
        registry.enableStompBrokerRelay("/topic")
                .setRelayHost("localhost")
                .setRelayPort(61613)
                .setClientLogin("guest")
                .setClientPasscode("guest");
    }
    

    如此一来,便可以通过RabbitMq进行消息的订阅。

    相关文章

      网友评论

        本文标题:[WebSocket入门]手把手搭建WebSocket多人在线聊

        本文链接:https://www.haomeiwen.com/subject/gdsfjctx.html