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: 處理類.
- 獲取客戶端信息
- 判斷消息的類型, 根據不同的類型處理不同的業務
2.1 當 WebSocket 第一次 open 的時候, 初始化 channel, 把用的 channel 和 userID 關聯起來. (主要用於兩個用戶之間的傳輸數據, 每個用戶對應一個 channel)
2.2 聊天類型的消息, 把聊天記錄保存到數據庫中, 同時標記消息的簽收狀態 [未簽收] (將聊天記錄存在數據庫中)
2.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 枚舉類
處理類中用到了兩個枚舉類:
- MsgActionEnum: 信息行爲
- 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>