SpringBoot整合WebSocket

WebSocket简介

WebSockets是一种在Web浏览器和服务器之间建立持久连接的技术标准。与HTTP请求不同,WebSockets允许双向通信,使得服务器能够主动向客户端推送数据,而不需要客户端发起请求。这种双向通信的特性使得WebSockets非常适合需要实时数据更新的应用,比如在线游戏、即时聊天和股票报价等。

双向通信:通过WebSockets,服务器和客户端可以同时发送和接收数据,这种实时性非常适合需要及时更新的应用场景。

持久连接:WebSockets建立一次连接后会一直保持开放状态,而不像HTTP需要在每次通信时重新建立连接。这降低了连接开销并提高了效率。

协议和API:WebSockets建立在TCP协议之上,通过使用WebSocket API在浏览器和服务器之间建立连接。

安全性:WebSockets通过类似HTTPS的安全套接层(SSL/TLS)来保护数据传输的安全性,确保数据不被窃听或篡改。

应用场景:WebSockets被广泛应用在需要实时通信和及时更新的应用中,比如在线游戏、即时通讯、协作工具、实时数据可视化以及股票市场等。

HTTP和WebSockets都是基于TCP协议的应用层协议。

WebSocket协议

本协议有两部分:握手和数据传输。

WebSocket握手阶段就是利用HTTP来协商和确认建立WebSocket连接。一旦握手成功,HTTP协议就不再起作用,连接就会升级为WebSocket协议,之后的通信将遵循WebSocket的规范,允许双向实时通信。

客户端发送WebSocket握手请求:客户端会发送一个HTTP请求到服务器,示意要升级连接为WebSocket协议。这个请求会包含一些特殊的头部字段,比如UpgradeConnectionSec-WebSocket-Key等。

GET ws://localhost:8080/chat HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chat,superchat
Sec-WebSocket-Version: 13

服务器响应WebSocket握手:如果服务器支持WebSocket协议,并且检查请求头部的一致性后,会返回一个HTTP响应,表明允许升级为WebSocket连接。响应头中包含UpgradeConnection字段,还有Sec-WebSocket-Accept字段,这是根据客户端发送的Sec-WebSocket-Key生成的。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept:s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

握手成功:一旦客户端收到这个响应,握手成功。此时连接就升级为WebSocket协议,后续的通信将遵循WebSocket协议规范,可以发送和接收WebSocket数据帧。

SpringBoot整合WebSocket

引入maven依赖

1
2
3
4
5
<!--    websocket    -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

WebSocket配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfig {
/*
* 注入一个ServerEndpointExporter,该Bean会自动注册使用@ServerEndpoint注解申明的websocket endpoint
* */
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
}

WebSocket操作类

通过该类WebSocket可以进行群推送以及单点推送,类似controller。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;


/**
* @author websocket服务
*/
@Component
@ServerEndpoint(value = "/imserver/{username}") // 请求路径
public class WebSocketServer {

private static final Logger log = LoggerFactory.getLogger(WebSocketServer.class);

/**
* 记录当前在线连接数
*/
public static final Map<String, Session> sessionMap = new ConcurrentHashMap<>();

/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("username") String username) {
sessionMap.put(username, session);
log.info("有新用户加入,username={}, 当前在线人数为:{}", username, sessionMap.size());
JSONObject result = new JSONObject();
JSONArray array = new JSONArray();
result.put("users", array);
for (Object key : sessionMap.keySet()) {
JSONObject jsonObject = new JSONObject();
jsonObject.put("username", key);
// {"username", "zhang", "username": "admin"}
array.add(jsonObject);
}
// {"users": [{"username": "zhang"},{ "username": "admin"}]}
sendAllMessage(result.toString()); // 后台发送消息给所有的客户端
}

/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose(Session session, @PathParam("username") String username) {
sessionMap.remove(username);
log.info("有一连接关闭,移除username={}的用户session, 当前在线人数为:{}", username, sessionMap.size());
}

/**
* 收到客户端消息后调用的方法
* 后台收到客户端发送过来的消息
* onMessage 是一个消息的中转站
* 接受 浏览器端 socket.send 发送过来的 json数据
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, Session session, @PathParam("username") String username) {
log.info("服务端收到用户username={}的消息:{}", username, message);
JSONObject obj = JSONObject.parseObject(message);
String toUsername = obj.getString("to"); // to表示发送给哪个用户,比如 admin
String text = obj.getString("text"); // 发送的消息文本 hello
// {"to": "admin", "text": "聊天文本"}
Session toSession = sessionMap.get(toUsername); // 根据 to用户名来获取 session,再通过session发送消息文本
if (toSession != null) {
// 服务器端 再把消息组装一下,组装后的消息包含发送人和发送的文本内容
// {"from": "zhang", "text": "hello"}
JSONObject jsonObject = new JSONObject();
jsonObject.put("from", username); // from 是 zhang
jsonObject.put("text", text); // text 同上面的text
this.sendMessage(jsonObject.toString(), toSession);
log.info("发送给用户username={},消息:{}", toUsername, jsonObject.toString());
} else {
log.info("发送失败,未找到用户username={}的session", toUsername);
}
}

@OnError
public void onError(Session session, Throwable error) {
log.error("发生错误");
error.printStackTrace();
}

/**
* 服务端发送消息给客户端
*/
private void sendMessage(String message, Session toSession) {
try {
log.info("服务端给客户端[{}]发送消息{}", toSession.getId(), message);
toSession.getBasicRemote().sendText(message);
} catch (Exception e) {
log.error("服务端发送消息给客户端失败", e);
}
}

/**
* 服务端发送消息给所有客户端
*/
private void sendAllMessage(String message) {
try {
for (Session session : sessionMap.values()) {
log.info("服务端给客户端[{}]发送消息{}", session.getId(), message);
session.getBasicRemote().sendText(message);
}
} catch (Exception e) {
log.error("服务端发送消息给客户端失败", e);
}
}

}

注意:如果后端服务配置了拦截器,需要放行/imserver/**

Vue前端简易实现代码

前端WebSocket使用:

  • 创建一个WebSocket对象:var Socket = new WebSocket(url)
  • WebSocket方法:
    • Socket.send():向服务器发送数据
    • Socket.close():关闭连接
  • WebSocket事件:
    • Socket.onopen:连接建立时触发
    • Socke.onmessage:客户端接受服务端数据时触发
    • Socket.onclose:连接关闭时触发
    • Socket.onerror:通信错误时触发
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
<template>
<div style="padding: 10px; margin-bottom: 50px">
<el-row>
<el-col :span="4">
<el-card style="width: 300px; height: 300px; color: #333">
<div style="pading-bottom: 10px; border-bottom: 1px solid #ccc">在线用户<span style = "font-size: 12px">(点击天气泡开始聊天)</span></div>
<div style="padding: 10px 0" v-for="user in users" :key="user.username">
<span>{{ user.username }}</span>
<i class="el-icon-chat-dot-round" style="margin-left: 10px; font-size: 16px; cursor: pointer"
@click="chatUser ? chatUser = '' : chatUser = user.username"></i>
<span style="font-size: 12px; color: limegreen; margin-left: 5px;" v-if="user.username === chatUser">chatting...</span>
</div>
</el-card>
</el-col>

<el-col :span="20">
<div style="width: 800px; margin: 0 auto; background-color: white; border-radius: 5px; box-shadow: 0 0 10px #ccc;">
<div style="text-align: center; line-height: 50px;">
Web聊天室({{ chatUser }})
</div>
<div style="height: 350px; overflow: auto; border-top: 1px solid #ccc;" v-html="content"></div>
<div style="height: 200px;">
<textarea v-model="text" style="height: 160px; width: 100%; padding: 20px; border: none; border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc; outline: none;" @keydown.enter="send"></textarea>
<div style="text-align: right; padding-right: 10px;">
<el-button type="primary" size="mini" @click="send">发送</el-button>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
</template>

<script>

let socket;

export default {
name: "Chart",
data() {
return {
// 用户头像
circleUrl: this.$store.state.user.avatar,
// 当前用户
user: {},
isCollapse: false,
// 可以聊天的所有用户
users: [],
// 正在聊天的用户
chatUser: '',
// 要发送的文字
text: '',
// 所有消息
messages: [],
// 聊天内容
content: '',
// websocket地址
url: 'ws://localhost:8080/imserver/'
}
},
created() {
this.init();
},
methods: {
// 发送消息
send() {
if (!this.chatUser) {
this.$message({type: 'warning', message: "请选择聊天对象"})
return;
}
if (!this.text) {
this.$message({type: 'warning', message: "请输入内容"})
} else {
if (typeof (WebSocket) == 'undefined') {
console.log("浏览器不支持 WebSocket")
} else {
console.log("浏览器支持 WebSocket")
let message = {from: this.user, to: this.chatUser, text: this.text}
// 发送消息
socket.send(JSON.stringify(message));
this.messages.push({user: this.user, text: this.text})
// 构建消息内容,本人的消息
this.createContent(null, this.user, this.text)
this.text = '';
}
}
},
// 组装聊天内容
createContent(remoteUser, nowUser, text) {
console.log("用户头像:", this.$store.state.user.avatar)
let html
// 当前用户消息
if (nowUser) {
html = "<div class=\"el-row\" style=\"padding: 5px 0\">\n" +
" <div class=\"el-col el-col-22\" style = \"text-align: right; padding-right: 10px\">\n" +
" <div class=\"tip left\">" + text + "</div>\n" +
" </div>\n" +
" <div class=\"el-col el-col-2\">\n" +
" <span class=\"el-avatar el-avatar--circle\" style=\"height: 40px; width: 40px; line-height: 40px;\">\n" +
" <img src=\"" + this.circleUrl + "\"style=\"object-fit: cover;\">\n" +
" </span>\n" +
" </div>\n" +
"</div>";
} else if (remoteUser) {
html = "<div class=\"el-row\" style=\"padding: 5px 0\">\n" +
" <div class=\"el-col el-col-2\" style = \"text-align: right;\">\n" +
" <span class=\"el-avatar el-avatar--circle\" style=\"height: 40px; width: 40px; line-height: 40px;\">\n" +
" <img src=\"" + this.circleUrl + "\"style=\"object-fit: cover;\">\n" +
" </span>\n" +
" </div>\n" +
" <div class=\"el-col el-col-22\" style=\"text-align: left; padding-left: 10px\">\n" +
" <div class=\"tip right\">" + text + "</div>\n" +
" </div>\n" +
"</div>";
}
console.log("消息:", this.messages)
this.content += html;
},
// 进入页面初始化,建立WebSocket连接
init() {
console.log("用户名:", this.$store.state.user.name)
// 从 store 中获取用户名
this.user = this.$store.state.user.name
let _this = this;
if (typeof (WebSocket) == "undefined") {
console.log("您的浏览器不支持WebSocket");
} else {
console.log("您的浏览器支持WebSocket");
let socketUrl = this.url + this.user;
if (socket != null) {
socket.close();
socket = null;
}
// 建立 WebSocket 连接
socket = new WebSocket(socketUrl);
//打开事件,对应后端的 @OnOpen 注解的方法
socket.onopen = function () {
console.log("websocket已打开");
}
//获得消息事件,对应后端 @OnMessage 注解方法
socket.onmessage = function (msg) {
console.log("数据:", msg.data)
let data = JSON.parse(msg.data)
if (data.users) { // 获取在线人员信息
// 过滤自己
_this.users = data.users.filter(user => user.username !== _this.user)
} else {
if (data.from === _this.chatUser) {
_this.messages.push(data)
// 构建消息内容
_this.createContent(data.from, null, data.text)

}
}
}
//关闭事件,对应后端 @OnClose 注解方法
socket.onclose = function () {
console.log("websocket已关闭");
}
//发生了错误事件,对应后端 @OnError 注解方法
socket.onerror = function () {
console.log("websocket发生了错误")
}
}
}
}
};
</script>

// 聊天气泡样式
<style>
.tip {
color: white;
text-align: center;
border-radius: 10px;
font-family: sans-serif;
padding: 10px;
width:auto;
display:inline-block !important;
display:inline;
}

.right {
background-color: deepskyblue;
}

.left {
background-color: forestgreen;
}

</style>