WebSocket + Vue 簡單聊天的實現

1. 後端大體結構

一些固定的 util 類:
https://blog.csdn.net/YKenan/article/details/106319712

在這裏插入圖片描述

導包

        <dependency>
            <groupId>springCloud</groupId>
            <artifactId>springCloud_common</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <!-- 聊天 -->
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.50.Final</version>
        </dependency>
        <dependency>
            <groupId>com.corundumstudio.socketio</groupId>
            <artifactId>netty-socketio</artifactId>
            <version>1.7.17</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
        </dependency>
        <!-- mysql -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

2. 前提練習

WebSocket + Vue 的一個簡單示例: https://blog.csdn.net/YKenan/article/details/106363153

3. 監聽 Netty 啓動

package com.springCloud.netty.config.listener;

import com.springCloud.netty.config.webSocket.WebSocketServer;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;

/**
 * 在 IOC 的容器的啓動過程, 當所有的 bean 都已經處理完成之後, spring ioc 容器會有-個發佈事件的動作.
 * 讓我們的 bean 實現 ApplicationListener 接口, 這樣當發佈事件時, [spring] 的 ioc 容器就會以容器的實例對象作爲事件源類, 並從中找到事件的監聽者, 此時 ApplicationListener 接口實例中的 onApplicationEvent (Event) 方法就會被調用.
 */
@Component
public class NettyListener implements ApplicationListener<ContextRefreshedEvent> {

    @Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        System.out.println("我的父容器: " + contextRefreshedEvent.getApplicationContext().getParent());
        WebSocketServer.getInstance().start();
    }

}

4. WebSocket 服務類

4.1 WebSocketServer

WebSocketServer: 定義 NIO 主從線程, 端口.

package com.springCloud.netty.config.webSocket;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import lombok.Data;
import org.springframework.stereotype.Component;

@Data
@Component
public class WebSocketServer {

    private static class SingletonWSServer {
        static final WebSocketServer instance = new WebSocketServer();
    }

    public static WebSocketServer getInstance() {
        return SingletonWSServer.instance;
    }

    private EventLoopGroup mainGroup;
    private EventLoopGroup subGroup;
    private ServerBootstrap server;
    private ChannelFuture future;

    public WebSocketServer() {
        mainGroup = new NioEventLoopGroup();
        subGroup = new NioEventLoopGroup();
        server = new ServerBootstrap();
        server.group(mainGroup, subGroup)
                .channel(NioServerSocketChannel.class)
                .childHandler(new WebSocketServerInitializer());
    }

    public void start() {
        this.future = server.bind(8009);
        System.err.println("netty websocket server 啓動完畢...");
    }
}

4.2 初始化器

WebSocketServerInitializer: 獲取管道, 數據流和訪問路徑.

package com.springCloud.netty.config.webSocket;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;

public class WebSocketServerInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel socketChannel) {
        // 獲取管道 (pipeline)
        ChannelPipeline pipeline = socketChannel.pipeline();
        // Websocket 基於 http 協議, 所需要的 http 編碼器
        pipeline.addLast(new HttpServerCodec());
        // 在 http 上有一些數據流產生, 有大有小, 我們對其處理, 既然如此, 我們需要使用 netty 對下數據流讀寫提供支持, 這兩個類叫:
        pipeline.addLast(new ChunkedWriteHandler());
        // 對 httpMessage 進行聚合處理, 聚合成 request和 response
        pipeline.addLast(new HttpObjectAggregator(1024 * 64));
        // 本 handler 會幫你處理一些繁重複雜的事請, 會幫你處理握手動作: handshaking (close, ping, pong) ping + pong = 心跳, 對於 websocket 來講, 都是以 frame 進行傳輸的, 不同的數據類型對應的 frame 也不同.
        pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
        // 自定義的 handler
        pipeline.addLast(new ChatHandler());
    }

}

4.3 助手類

ChatHandler: 處理類.

  1. 獲取客戶端信息
  2. 判斷消息的類型, 根據不同的類型處理不同的業務
    2.1 當 WebSocket 第一次 open 的時候, 初始化 channel, 把用的 channel 和 userID 關聯起來. (主要用於兩個用戶之間的傳輸數據, 每個用戶對應一個 channel)
    2.2 聊天類型的消息, 把聊天記錄保存到數據庫中, 同時標記消息的簽收狀態 [未簽收] (將聊天記錄存在數據庫中)
    2.3 簽收消息類型, 針對具體的消息進行簽收, 修改數據庫中對應的消息的簽收狀態 [已簽收] (修改數據庫中信息存在的狀態)
  3. 處理異常
package com.springCloud.netty.config.webSocket;

import com.springCloud.common.util.JsonUtils;
import com.springCloud.common.util.SpringUtil;
import com.springCloud.netty.service.impl.ChatServiceImpl;
import com.springCloud.netty.util.enums.MsgActionEnum;
import com.springCloud.netty.util.enums.MsgSignFlagEnum;
import com.springCloud.netty.pojo.Chat;
import com.springCloud.netty.pojo.WebSocket;
import com.springCloud.netty.service.ChatService;
import com.springCloud.netty.util.relation.UserChannelRel;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.util.concurrent.GlobalEventExecutor;
import org.apache.commons.lang3.StringUtils;

import java.util.ArrayList;
import java.util.List;

/**
 * 用於處理消息的 handler
 * 由於它的傳輸數據的載體時 frame, 這個 frame 在 netty 中, 是用於 websocket 專門處理文本對象的, frame 是消息的載體, 此類叫做: TextWebSocketFrame
 */
public class ChatHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    // 用於記錄和管理所有客戶端的 Channel
    private static ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);

    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) {
        // 獲取客戶端所傳輸的信息
        String content = textWebSocketFrame.text();
        // 獲取消息的內容
        WebSocket webSocket = JsonUtils.jsonToPojo(content, WebSocket.class);
        assert webSocket != null;
        Integer action = webSocket.getAction();

        /* *
         * 判斷消息的類型, 根據不同的類型處理不同的業務
         * 1. 當 WebSocket 第一次 open 的時候, 初始化 channel, 把用的 channel 和 userID 關聯起來.
         * 2. 聊天類型的消息, 把聊天記錄保存到數據庫中, 同時標記消息的簽收狀態 [未簽收]
         * 3. 簽收消息類型, 針對具體的消息進行簽收, 修改數據庫中對應的消息的簽收狀態 [已簽收]
         * 4. 心跳類型消息
         */
        Channel channel = channelHandlerContext.channel();
        if (action.equals(MsgActionEnum.CONNECT.type)) {
            String sendId = webSocket.getChat().getSendId();
            UserChannelRel.put(sendId, channel);
            // 測試輸出
            UserChannelRel.output();
        } else if (action.equals(MsgActionEnum.CHAT.type)) {
            // 獲取發送過來的聊天參數
            Chat chat = webSocket.getChat();
            String message = chat.getMessage();
            String sendId = chat.getSendId();
            String reviverId = chat.getReceiveId();
            // 得到 bean, 保存數據庫
            ChatServiceImpl chatServiceImpl = (ChatServiceImpl) SpringUtil.getBean("chatServiceImpl");
            Chat saveChat = chatServiceImpl.save(new Chat("", sendId, reviverId, message, MsgSignFlagEnum.unSign.type));

            WebSocket webSocketMsg = new WebSocket();
            webSocketMsg.setChat(saveChat);

            // 發送消息
            Channel reviverChannel = UserChannelRel.get(reviverId);
            if (reviverChannel == null) {
                // 離線用戶
                System.out.println("離線用戶");
            } else {
                // 從 ChannelGroup 查找對應的組是否存在
                Channel findChannel = clients.find(reviverChannel.id());
                if (findChannel != null) {
                    // 用戶在線
                    reviverChannel.writeAndFlush(
                            new TextWebSocketFrame(
                                    JsonUtils.objectToJson(webSocket)
                            )
                    );
                } else {
                    // 離線用戶
                    System.out.println("離線用戶");
                }
            }
        } else if (action.equals(MsgActionEnum.SIGNED.type)) {
            ChatService chatService = (ChatService) SpringUtil.getBean("chatService");
            // 擴展字段 SIGNED 在類型消息中, 代表需要去簽收的信息 id 號. 逗號間隔
            String msgStr = webSocket.getExtend();
            String[] msgStrId = msgStr.split(",");
            List<String> msgStrIdList = new ArrayList<>();
            for (String mid : msgStrId) {
                if (StringUtils.isNotBlank(mid)) {
                    msgStrIdList.add(mid);
                }
            }
            // 批量 update
            if (!msgStrIdList.isEmpty()) {
                chatService.updateChatList(msgStrIdList);
            }
        } else if (action.equals(MsgActionEnum.KEEP_ALIVE.type)) {
            System.out.println("心跳類型消息");
        }
    }

    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        super.handlerAdded(ctx);
        clients.add(ctx.channel());
    }

    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        super.handlerRemoved(ctx);
        clients.remove(ctx.channel()); // 這句話沒有必要寫
        System.out.println("客戶端斷開, Channel 對應的長 ID 爲: " + ctx.channel().id().asLongText());
        System.out.println("客戶端斷開, Channel 對應的短 ID 爲: " + ctx.channel().id().asShortText());
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        super.exceptionCaught(ctx, cause);
        cause.printStackTrace();
        // 發生了異常, 之後關閉連接, 移除
        ctx.channel().close();
        clients.remove(ctx.channel());
    }
}

4.3.1 POJO 類

Chat: 和數據庫對應的類, 基於存儲信息的類.
WebSocket: 這個沒有和數據庫中表連接, 只是源於方便用的.

Chat

package com.springCloud.netty.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;


@Table(name = "chat")
@AllArgsConstructor
@NoArgsConstructor
@Entity
@ToString
@Data
public class Chat implements Serializable {

    @Id
    private String id;

    @Column
    private String sendId;

    @Column
    private String receiveId;

    @Column
    private String message;

    @Column
    private Integer status;
}

WebSocket

package com.springCloud.netty.pojo;

import lombok.Data;
import lombok.ToString;

import java.io.Serializable;

/**
 * 聊天內容:
 * id: 聊天的 id 號,
 * action: 聊天的動作,
 * chatMsg: 聊天的信息,
 * extend: 擴展內容.
 */

@ToString
@Data
public class WebSocket implements Serializable {

    private String id;

    private Integer action;

    private String extend;

    private Chat chat;

}

4.3.2 枚舉類

處理類中用到了兩個枚舉類:

  1. MsgActionEnum: 信息行爲
  2. MsgSignFlagEnum: 信息轉狀態 (已讀, 未讀)

MsgActionEnum

package com.springCloud.netty.util.enums;

/**
 * 發送消息的動作 枚舉
 */
public enum MsgActionEnum {

    CONNECT(1, "第一次(或重連)初始化連接"),
    CHAT(2, "聊天消息"),
    SIGNED(3, "消息簽收"),
    KEEP_ALIVE(4, "客戶端保持心跳"),
    PULL_FRIEND(5, "拉取好友");

    public final Integer type;
    public final String content;

    MsgActionEnum(Integer type, String content) {
        this.type = type;
        this.content = content;
    }

    public Integer getType() {
        return type;
    }

}

MsgSignFlagEnum

package com.springCloud.netty.util.enums;

/**
 * 消息簽收狀態 枚舉
 */
public enum MsgSignFlagEnum {

    unSign(0, "未簽收"),
    signed(1, "已簽收");

    public final Integer type;
    public final String content;

    MsgSignFlagEnum(Integer type, String content) {
        this.type = type;
        this.content = content;
    }

    public Integer getType() {
        return type;
    }
}

4.3.3 關聯類

用戶 id 號與 Channel 關聯起來, 一一對應.

package com.springCloud.netty.util.relation;

import io.netty.channel.Channel;

import java.util.HashMap;
import java.util.Map;

/**
 * 用戶 id 號與 Channel 關聯起來
 */
public class UserChannelRel {

    private static HashMap<String, Channel> manage = new HashMap<>();

    public static void put(String userId, Channel channel) {
        manage.put(userId, channel);
    }

    public static Channel get(String userId) {
        return manage.get(userId);
    }

    public static void output() {
        for (Map.Entry<String, Channel> stringChannelEntry : manage.entrySet()) {
            System.out.println("UserId: " + stringChannelEntry.getKey() + ", ChannelId: " + stringChannelEntry.getValue().id().asLongText());
        }
    }

}

5. 前端信息

<template>
  <div id="msg-outer">

    <div class="msg">
      <div v-for="(message, index) in msgFor" :key="index">
        <!-- 好友的信息 -->
        <div class="msg-left" v-if="message.receiveShow">
          <div>
            <img v-if="friend.headIcon" :src="friend.headIcon" class="head"/>
            <span>{{message.data}}</span>
            <div class="popper-arrow"></div>
          </div>
        </div>
        <!-- 我的信息 -->
        <div class="msg-right" v-if="message.sendShow">
          <div>
            <span>{{message.data}}</span>
            <div class="popper-arrow"></div>
            <img v-if="my.headIcon" :src="my.headIcon" class="head"/>
          </div>
        </div>
      </div>
    </div>

    <div class="footer">
      <el-input
        type="textarea"
        :rows="2"
        placeholder="請輸入內容"
        v-model="sendMessage">
      </el-input>
      <el-button type="primary" @click="sendWebSocket(sendMessage)">發送</el-button>
    </div>
  </div>

</template>

<script>
  import ElInput from '../../../node_modules/element-ui/packages/input/src/input.vue'
  import ElButton from '../../../node_modules/element-ui/packages/button/src/button.vue'

  export default {
    components: {
      ElButton,
      ElInput
    },
    props: {},
    data () {
      return {
        chat: {
          id: '',
          friendId: ''
        },
        // 我的信息
        my: {},
        // 好友的信息
        friend: {},
        // 聊天的信息
        msgFor: [],
        // 發送的信息
        sendMessage: '',
        // 信息行爲
        action: {
          // "第一次 (或重連) 初始化連接"
          CONNECT: 1,
          // "聊天消息"
          CHAT: 2,
          // "消息簽收"
          SIGNED: 3,
          // "客戶端保持心跳"
          KEEP_ALIVE: 4,
          // "拉取好友"
          PULL_FRIEND: 5
        },
        webSocket: null
      }
    },
    mounted () {
      this.chat.id = this.$route.query.id
      this.chat.friendId = this.$route.query.friendId
      // 我的信息
      this.my = JSON.parse(localStorage.getItem('user'))
      // 好友的信息
      this.$axios({
        url: 'http://localhost:8498/user/' + this.chat.friendId,
        method: 'get',
        headers: {
          'Content-Type': 'application/json',
          'Access-Control-Allow-Methods': 'POST, OPTIONS',
          'Access-Control-Allow-Origin': '*'
        },
        proxy: {
          host: 'localhost',
          port: 8498
        }
      }).then(res => {
        if (res.data.flag) {
          this.friend = res.data.data
        }
        console.log(res.data.data)
      })
      // 設置聊天記錄爲最後一條
      let elementsByClassName = document.getElementsByClassName('msg')[0]
      elementsByClassName.scrollTop = elementsByClassName.offsetHeight + elementsByClassName.scrollHeight
    },
    watch: {},
    created () {
      this.initWebSocket()
    },
    destroyed () {
      // 離開路由之後斷開 webSocket 連接
      this.webSocket.close()
    },
    methods: {
      // 發送信息的函數
      sendMessageWebSocket (action, extend, sendId, receiveId, message, status) {
        return {
          action: action,
          extend: extend,
          chat: {
            sendId: sendId,
            receiveId: receiveId,
            message: message,
            status: status
          }
        }
      },
      // 初始化 webSocket
      initWebSocket () {
        if (window.WebSocket) {
          // 如果 WebSocket 狀態已經是鏈接是時候無需再次創建鏈接
          if (this.webSocket !== null && this.webSocket !== undefined && this.webSocket.readyState === this.webSocket.OPEN) {
            return false
          }
          // 創建 WebSocket 對象
          this.webSocket = new WebSocket('ws://127.0.0.1:8009/ws')
          this.webSocket.onopen = this.onOpenWebSocket
          this.webSocket.onmessage = this.onMessageWebSocket
          this.webSocket.onerror = this.onErrorWebSocket
          this.webSocket.onclose = this.closeWebSocket
        } else {
          this.$message.error('您的瀏覽器的版本過低, 請儘快上級版本!')
        }
      },
      // 連接建立之後執行 send 方法發送數據
      onOpenWebSocket () {
        console.log('鏈接建立成功!')
        let wsMsg = this.sendMessageWebSocket(this.action.CONNECT, null, this.my.id, null, null, null)
        this.webSocket.send(JSON.stringify(wsMsg))
      },
      // 連接建立失敗重連
      onErrorWebSocket () {
        this.initWebSocket()
      },
      // 數據發送
      sendWebSocket (data) {
        if (data === '') {
          this.$message.warning('請輸入數據')
          return false
        } else if (data.length > 3000) {
          this.$message.warning('輸入數據太長')
          return false
        }

        // 如果 WebSocket 狀態失鏈, 需要重新鏈接再次發送
        if (this.webSocket !== null && this.webSocket !== undefined && this.webSocket.readyState === this.webSocket.OPEN) {
          let message = {
            data: data,
            receiveShow: false,
            sendShow: true
          }
          this.msgFor.push(message)
          let wsMsg = this.sendMessageWebSocket(this.action.CHAT, this.friend.id, this.my.id, this.friend.id, data, 0)
          console.log(wsMsg)
          this.webSocket.send(JSON.stringify(wsMsg))
        } else {
          this.initWebSocket()
          let message = {
            data: data,
            receiveShow: false,
            sendShow: true
          }
          this.msgFor.push(message)
          let wsMsg = this.sendMessageWebSocket(this.action.CHAT, this.friend.id, this.my.id, this.friend.id, data, 0)
          console.log(wsMsg)
          this.webSocket.send(JSON.stringify(wsMsg))
          setTimeout(() => {
            this.webSocket.send(data)
          }, 1000)
        }
        // 設置聊天記錄爲最後一條
        setTimeout(() => {
          // 設置聊天記錄爲最後一條
          let elementsByClassName = document.getElementsByClassName('msg')[0]
          elementsByClassName.scrollTop = elementsByClassName.offsetHeight + elementsByClassName.scrollHeight
        }, 250)
        // 數據發送完, 清空數據
        this.sendMessage = ''
      },
      // 數據接收
      onMessageWebSocket (e) {
        // 建立信息展示對象
        let data = JSON.parse(e.data).chat
        console.log(data)
        let message = {
          data: data.message,
          receiveShow: true,
          sendShow: false
        }
        this.msgFor.push(message)
        setTimeout(() => {
          // 設置聊天記錄爲最後一條
          let elementsByClassName = document.getElementsByClassName('msg')[0]
          elementsByClassName.scrollTop = elementsByClassName.offsetHeight + elementsByClassName.scrollHeight
        }, 250)
      },
      // 關閉
      closeWebSocket (e) {
        console.log('斷開連接', e)
      }
    }
  }
</script>

<style scoped>
  #msg-outer {
    width: 70%;
    margin: 20px auto;
  }

  .msg {
    padding: 9px;
    height: 300px;
    overflow-y: auto;
  }

  .msg .msg-left, .msg .msg-right {
    display: flow-root;
    width: 100%;
    height: 100%;
  }

  .msg .msg-left > div {
    float: left;
  }

  .msg .msg-right > div {
    float: right;
  }

  .msg .head {
    width: 40px;
    height: 40px;
    border-radius: 45%;
  }

  .msg .msg-left > div,
  .msg .msg-right > div {
    position: relative;
  }

  .msg .msg-left > div span,
  .msg .msg-right > div span {
    display: inline-block;
    background-color: #dbdada;
    padding: 5px 25px;
    border-radius: 5px;
    max-width: 550px;
    white-space: pre-wrap;
    word-break: break-all;
    overflow-wrap: break-word;
  }

  .msg .msg-left > div span {
    margin-left: 10px;
  }

  .msg .msg-right > div span {
    margin-right: 10px;
  }

  .msg .msg-left > div .popper-arrow,
  .msg .msg-right > div .popper-arrow {
    display: block;
    width: 19px;
    height: 10px;
    background-color: #dbdada;
    border-radius: 500%;
    position: absolute;
    bottom: 9px;
    filter: drop-shadow(0 2px 12px rgba(0,0,0,.03));
    z-index: -1;
  }

  .msg .msg-left > div .popper-arrow {
    left: 42px;
    transform: rotateZ(20deg);
  }

  .msg .msg-right > div .popper-arrow {
    right: 42px;
    transform: rotateZ(-20deg);
  }

  .footer {
    margin-top: 20px;
  }

  #msg-outer {
    background-color: rgba(0, 0, 0, 0.03);
  }

  #msg-outer >>> textarea,
  #msg-outer >>> button {
    border-radius: 0;
  }

  #msg-outer >>> button {
    float: right;
  }
</style>

6. 瀏覽器展示

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章