55 WebSocket
参考资料
前言
- WebSocket是HTML5下一种新的协议(websocket协议本质上是一个基于tcp的协议)
- 它实现了浏览器与服务器全双工通信,能更好的节省服务器资源和带宽并达到实时通讯的目的
- WebSocket是一个持久化的协议
- WebSocket约定了一个通信的规范,通过一个握手的机制,客户端和服务器之间能建立一个类似tcp的连接,从而方便它们之间的通信
- 在WebSocket出现之前,web交互一般是基于http协议的短连接或者长连接
- WebSocket是一种全新的协议,不属于HTTP无状态协议,协议名为"ws"
WebSocket和HTTP
相同点
- 都是基于TCP协议即都是可靠性传输协议;
- 都是应用层协议;
不同点
- WebSocket是双向通信协议,模拟Socket协议,可以双向发送或接受信息
- HTTP是单向的
- WebSocket是需要浏览器和服务器握手进行建立连接的
- 而HTTP是浏览器发起向服务器的连接,服务器预先并不知道这个连接
WebSocket在建立握手时,数据是通过HTTP传输的。但是建立之后,在真正传输时候是不需要HTTP协议的
总结(总体过程):
- 首先,客户端发起http请求,经过3次握手后,建立起TCP连接;http请求里存放WebSocket支持的版本号等信息,如:Upgrade、Connection、WebSocket-Version等;
- 然后,服务器收到客户端的握手请求后,同样采用HTTP协议回馈数据;
- 最后,客户端收到连接成功的消息后,开始借助于TCP传输信道进行全双工通信。
WebSocket解决的问题
HTTP存在的问题
如果是基于HTTP协议,在我们开发的时候,如果后端服务器想要给客户端发送信息是无法实现的,我们只能等客户端发送请求后才能把该信息发送给客户端。
这是因为HTTP协议是无状态的,每当一次会话完成后,服务端都不知道下一次的客户端是谁,需要每次知道对方是谁,才进行相应的响应,因此本身对于实时通讯就是一种极大的障碍。
http协议采用一次请求,一次响应,每次请求和响应就携带有大量的header头,对于实时通讯来说,解析请求头也是需要一定的时间,因此,效率也更低下。
最重要的是,需要客户端主动发,服务端被动发,也就是一次请求,一次响应,不能实现主动发送。
long poll(长轮询)
HTTP为了解决这个问题提出来一种技术,那就是long poll(长轮询),基于HTTP的特性,简单点说,就是客户端发起长轮询,如果服务端的数据没有发生变更,会 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询。
很显然这样会占用很多的资源,如果用户量很大,很多请求都在等待,这需要很大的并发量。
Ajax轮询
同样也是为了解决这个问题,提出了Ajax轮询,即让前端页面每隔一段时间就向后端发送一个请求。
- 总的来看,Ajax轮询存在的问题:
- 推送延迟。
- 服务端压力。配置一般不会发生变化,频繁的轮询会给服务端造成很大的压力。
- 推送延迟和服务端压力无法中和。降低轮询的间隔,延迟降低,压力增加;增加轮询的间隔,压力降低,延迟增高
websocket的改进
一旦WebSocket连接建立后,后续数据都以帧序列的形式传输。在客户端断开WebSocket连接或Server端中断连接前,不需要客户端和服务端重新发起连接请求。在海量并发及客户端与服务器交互负载流量大的情况下,极大的节省了网络带宽资源的消耗,有明显的性能优势,且客户端发送和接受消息是在同一个持久连接上发起,实现了“真·长链接”,实时性优势明显。
WebSocket建立连接是通过TCP的三次握手,其接下来的通信也是通过升级HTTP来达到效果的。
连接建立过程
WebSocket协议的建立连接过程如下:
- 客户端发送一个HTTP请求到服务器,请求中包括希望升级为WebSocket协议的信息,即在请求头中包含Upgrade字段,值为"websocket";
- 服务器收到请求后,会返回一个HTTP 101状态码,表示同意升级为WebSocket协议,同时在响应头中添加Upgrade字段和Connection字段,告知客户端已升级为WebSocket协议;
- 客户端收到服务器的响应后,会通过TCP通道进行传输通信。
在SpringBoot中实现WebSocket实时通信
为了加深对WebSocket的了解以及它的使用,为此用一个简单的小例子来使用WebSocket,前端基于vue2,后端基于SpringBoot、WebSocket来简单的做一个好友聊天的功能页面。
具体页面情况如下:
WebSocket
下面就一步一步来介绍其实现。
WebSocket依赖注入问题
在 Spring Boot WebSocket 中,依赖注入可能会出现问题,尤其是在 WebSocket 端点类中使用 @Autowired
注解时。原因主要与 WebSocket 的生命周期管理和 Spring 的管理机制之间的差异有关。
WebSocket 实例由 Web 容器管理而非 Spring 容器。
解决方案:
package com.fang.screw.chat.config;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import javax.websocket.server.ServerEndpointConfig;
/**
* @FileName CustomSpringConfigurator
* @Description 这段代码的主要作用是将Spring框架与WebSocket集成,使得WebSocket端点能够通过Spring的依赖注入机制获取Spring管理的Bean。
* 具体来说,它通过实现ServerEndpointConfig.Configurator接口和ApplicationContextAware接口,将Spring的ApplicationContext
* 注入到WebSocket配置中,从而允许WebSocket端点使用Spring的依赖注入功能。
* @Author yaoHui
* @date 2024-10-08
**/
public class CustomSpringConfigurator extends ServerEndpointConfig.Configurator implements ApplicationContextAware {
private static volatile BeanFactory context;
@Override
public <T> T getEndpointInstance(Class<T> clazz) {
return context.getBean(clazz);
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
CustomSpringConfigurator.context = applicationContext;
}
}
@Component
@Slf4j
@ServerEndpoint("/websocket/{myUserId}")
public class WebSocket {
private static MessageMapper messageMapper;
@Autowired
public void setMessageMapper(MessageMapper messageMapper){
WebSocket.messageMapper = messageMapper;
}
}
前端
更详细的代码请点击查看
点击查看
<template>
<div id="app">
<div class="main1">
.
</div>
<div class="main">
<div class="contact">
<div class="top">
<div class="left">
<img class="avatar" src="" alt="" />
</div>
<div class="right">
{{ user.username }}
</div>
<!-- 添加好友按钮 -->
<button @click="showModal = true" class="add-friend-button">Add Friend</button>
<!-- 弹出的小页面(模态框) -->
<div v-if="showModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Add a Friend</h2>
<span @click="showModal = false" class="close">×</span>
</div>
<!-- 搜索框 -->
<div class="search-section">
<input v-model="searchQuery" type="text" placeholder="Search by username or phone number"
class="search-input" @keyup.enter="searchFriends" />
</div>
<!-- 搜索结果展示 -->
<div class="friend-list">
<div v-for="friend in filteredFriends" :key="friend.id" class="friend-item">
<div class="friend-avatar">
<img :src="friend.avatar" :alt="friend.username" />
</div>
<div class="friend-info">
<div class="friend-name">{{ friend.username }}</div>
<button @click="addFriend(friend)" class="add-button">Add</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="friends.length" class="bottom">
<div v-for="(friend, i) in friends" class="friend" :class="{activeColor: isActive(i)}"
@click="selectFriend(friend)">
<div class="left">
<img class="avatar" src="" alt="" />
</div>
<div class="right">
{{ friend.userBName }}
</div>
</div>
</div>
<div v-else class="info">
<div class="msg">
还没有好友~~~
</div>
</div>
</div>
<div v-if="selectedFriend" class="dialog">
<div class="top1">
<div class="name">
{{ selectedFriend.userBName }}
</div>
</div>
<div class="middle" @mouseover="over" @mouseout="out">
<div v-if="msgList.length">
<div v-for="msg in msgList">
<div class="msg"
:style="msg.sendUser === selectedFriend.userB ? 'flex-direction: row;' : 'flex-direction: row-reverse;'">
<div class="avatar">
<img alt="" src="" />
</div>
<div v-if="msg.sendUser === selectedFriend.userB" style="flex: 13;">
<div class="bubble-msg-left" style="margin-right: 75px;">
{{ msg.message }}
</div>
</div>
<div v-else style="flex: 13;">
<div class="bubble-msg-right" style="margin-left: 75px;">
{{ msg.message }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="line"></div>
<div class="bottom">
<label>
<textarea class="messageText" maxlength="256" v-model="msg" :placeholder="hint"
@keydown.enter="sendMsg($event)"></textarea>
</label>
<button class="send" :class="{emptyText: isEmptyText}" title="按下 ENTER 发送" @click="sendMsg()">发送</button>
</div>
</div>
<div v-else class="info">
<div class="msg">
找个好友聊天吧~~~
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data () {
return {
showModal: false, // 控制模态框的显示与隐藏
searchQuery: '', // 搜索框的输入内容
msg: '',
friends: [],
selectedFriend: null,
chatHistory: [],
newMessage: '',
websocket: null,
hint: '',
bubbleMsg: '',
interval: null,
isEmptyText: true,
msgList: [],
filteredFriends: [] // 搜索到的好友
};
},
computed: {
user () {
return JSON.parse(localStorage.getItem('currentUser'))
}
},
watch: {
msgList () {
const mid = document.querySelector('.middle')
this.$nextTick(() => {
mid && (mid.scrollTop = mid.scrollHeight)
document.querySelector('.messageText').focus()
})
},
msg () {
this.isEmptyText = !this.msg
}
},
methods: {
searchFriends () {
// 模拟通过用户名或电话号码搜索好友
this.$http.get(this.$constant.baseURL + "/upm/user/getUserByUserNameOrEmail/" + this.searchQuery)
.then((res) => {
if (!this.$common.isEmpty(res.data)) {
this.filteredFriends = res.data;
}
})
.catch((error) => {
this.$message({
message: error.message,
type: "error"
});
});
},
addFriend (friend) {
alert(`Friend request sent to ${friend.username}`);
this.$http.post(this.$constant.baseURL + "/chat/addFriend", {
"id": friend.id
})
.then((res) => {
alert("添加成功");
})
.catch((error) => {
this.$message({
message: error.message,
type: "error"
});
});
// 执行添加好友的逻辑
},
over () {
this.setColor('#c9c7c7')
},
out () {
this.setColor('#0000')
},
setColor (color) {
document.documentElement.style.setProperty('--scroll-color', `${color}`)
},
getFriends () {
this.$http.get(this.$constant.baseURL + "/chat/getFriendsListById")
.then((res) => {
if (!this.$common.isEmpty(res.data)) {
this.friends = res.data
}
})
.catch((error) => {
this.$message({
message: error.message,
type: "error"
});
});
},
selectFriend (friend) {
this.selectedFriend = friend;
this.loadChatHistory(friend);
},
loadChatHistory (friend) {
this.$http.post(this.$constant.baseURL + "/chat/message/getMessage", friend)
.then((res) => {
if (!this.$common.isEmpty(res.data)) {
this.msgList = res.data;
}
})
.catch((error) => {
this.$message({
message: error.message,
type: "error"
});
});
},
sendMsg (e) {
if (e) {
e.preventDefault()
}
if (!this.msg) {
this.hint = '信息不可为空!'
return
}
let entity = {
sendUser: JSON.parse(localStorage.getItem('currentUser')).id,
receiveUser: this.selectedFriend.userB,
message: this.msg.trim(),
// time: new Date()
}
this.websocket.send(JSON.stringify(entity))
var ha = JSON.stringify(entity);
this.msgList.push(entity);
this.msg = ''
this.hint = ''
}
,
sendMessage () {
if (this.newMessage.trim() === '') return;
const message = { content: this.newMessage, isMine: true };
this.chatHistory.push(message);
let entity = {
sendUser: JSON.parse(localStorage.getItem('currentUser')).id,
receiveUser: this.selectedFriend.userB,
message: this.newMessage.trim(),
// time: new Date()
}
this.websocket.send(JSON.stringify(entity)); // Send message through WebSocket
this.newMessage = '';
},
setupWebSocket () {
this.websocket = new WebSocket(`ws://localhost:56281/websocket/${JSON.parse(localStorage.getItem('currentUser')).id}`);
this.websocket.onmessage = (event) => {
const receivedMessage = JSON.parse(event.data);
this.msgList.push(receivedMessage);
};
},
setContact (index) {
this.active = index
delete this.friendList[index].password
this.$emit('set-contact', this.friendList[index])
},
isActive (index) {
return this.active === index
}
},
mounted () {
this.setupWebSocket();
this.getFriends();
},
};
</script>
<style scoped>
.contact {
width: 360px;
height: 100%;
float: left;
border-right: #d0d0d0 1px solid;
}
.top {
width: 100%;
height: 80px;
display: flex;
align-items: center;
border-bottom: #e0dfdf 1px solid;
}
.activeColor {
background-color: #c9cbcb;
}
.top .left {
flex: 1;
text-align: center;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 4px;
}
.top .right {
flex: 3;
color: black;
/* 设置字体颜色为黑色 */
}
.friend {
width: 360px;
height: 60px;
line-height: 60px;
display: flex;
align-items: center;
border-bottom: #faf7f7 1px solid;
}
.friend .left {
flex: 1;
margin-top: 24px;
text-align: center;
}
.friend .right {
flex: 3;
color: #575454;
font-size: 14px;
color: black;
/* 设置字体颜色为黑色 */
}
.friend .avatar {
width: 36px;
height: 36px;
}
.info {
margin-top: 230px;
}
.info .msg {
text-align: center;
}
#app {
width: 100%;
height: 100%;
background-size: cover;
background-image: url("../assets/img/chat-bg.jpg");
}
.main {
width: 1080px;
height: 648px;
margin-top: 72px;
margin-left: auto;
margin-right: auto;
border-radius: 5px;
background-color: #efeded;
border: #d0d0d0 1px solid;
box-shadow: 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04);
/* 新增的 Flexbox 样式 */
display: flex;
/* 启用 Flexbox 布局 */
flex-direction: row;
/* 水平方向排列子元素 */
justify-content: space-between;
/* 子元素间的距离自动分配,或根据需要调整 */
}
:root {
--scroll-color: #0000;
}
.dialog {
width: 719px;
height: 100%;
float: right;
}
.name {
position: relative;
top: 22px;
left: 25px;
}
.info {
width: 719px;
height: 100%;
display: flex;
align-items: center;
}
.info .msg {
flex: 1;
text-align: center;
}
.top1 {
width: 100%;
height: 60px;
border-bottom: #d0d0d0 1px solid;
}
.top1::after {
content: " ";
float: right;
position: relative;
top: 40px;
border: 4px solid #0000;
border-top-color: #8e9292;
}
.middle {
height: 432px;
overflow: auto;
padding: 10px;
margin: 6px 0 11px 0;
}
.middle::-webkit-scrollbar {
width: 8px;
height: 1px;
}
.middle::-webkit-scrollbar-thumb {
border-radius: 8px;
background-color: var(--scroll-color);
}
.middle::-webkit-scrollbar-track {
background: #efeded;
border-radius: 4px;
}
.middle .msg {
display: flex;
}
.avatar {
margin: 8px;
flex: 1;
}
.avatar img {
width: 36px;
height: 36px;
border-radius: 4px;
}
.bubble-msg-left,
.bubble-msg-right {
padding: 10px;
font-size: 14px;
margin-top: 10px;
line-height: 24px;
border-radius: 5px;
width: fit-content;
line-break: anywhere;
}
.bubble-msg-left {
float: left;
color: black;
margin-left: -12px;
text-indent: -0.5em;
background-color: white;
}
.bubble-msg-right {
float: right;
color: white;
background-color: #1e6ee1;
}
.bubble-msg-right::before {
content: " ";
float: right;
position: relative;
left: 18px;
border: 4px solid #0000;
border-left-color: #1e6ee1;
}
.bubble-msg-left::before {
content: " ";
float: left;
position: relative;
left: -18px;
border: 4px solid #0000;
border-right-color: white;
}
.line {
width: 100%;
height: 0;
position: relative;
top: -6px;
border-top: #d0d0d0 1px solid;
}
.dialog .bottom {
padding-left: 10px;
padding-right: 25px;
}
.messageText {
position: relative;
margin-right: 2px;
font: 14px/1.5 Helvetica, Arial, Tahoma, 微软雅黑;
width: 100%;
height: 106px;
outline: none;
background: #efeded;
border: 0 none;
overflow-y: auto;
-webkit-box-sizing: border-box;
box-sizing: border-box;
resize: none;
vertical-align: middle;
display: inline-block;
}
.dialog .bottom::after {
content: " ";
float: right;
position: relative;
top: -121px;
left: 75px;
border: 4px solid #0000;
border-bottom-color: #8e9292;
}
.send {
float: right;
position: relative;
top: -20px;
left: 10px;
background-color: #51a5e6;
border: #87ceeb;
color: #fff;
font-size: 12px;
width: 50px;
height: 22px;
border-radius: 3px;
}
.send:focus {
outline: none;
}
.emptyText {
background-color: #d0d0d0;
}
.name {
color: black;
/* 设置字体颜色为黑色 */
}
/* 主体按钮样式 */
.add-friend-button {
padding: 10px 20px;
background-color: #007bff;
color: white;
font-size: 18px;
border: none;
border-radius: 20px;
cursor: pointer;
transition: background-color 0.3s;
}
.add-friend-button:hover {
background-color: #0056b3;
}
/* 模态框背景 */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
/* 模态框内容 */
.modal-content {
background-color: #fff;
padding: 20px;
border-radius: 15px;
width: 400px;
max-width: 100%;
box-shadow: 0px 0px 20px rgba(0, 0, 0, 0.2);
}
/* 模态框头部 */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h2 {
margin: 0;
font-size: 24px;
font-family: 'KaiTi', serif;
}
.close {
cursor: pointer;
font-size: 24px;
}
/* 搜索框样式 */
.search-input {
width: 100%;
padding: 10px;
border: 2px solid #ccc;
border-radius: 10px;
font-size: 18px;
margin-bottom: 20px;
}
/* 好友列表样式 */
.friend-list {
display: flex;
flex-direction: column;
}
/* 单个好友项 */
.friend-item {
display: flex;
align-items: center;
margin-bottom: 15px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 10px;
}
.friend-avatar img {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 15px;
}
.friend-info {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.friend-name {
font-size: 18px;
font-family: 'KaiTi', serif;
}
/* 添加好友按钮 */
.add-button {
padding: 8px 15px;
background-color: #28a745;
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.add-button:hover {
background-color: #218838;
}
</style>
后端
更详细的代码请点击查看
点击
@Component
@Slf4j
@ServerEndpoint("/websocket/{myUserId}")
public class WebSocket {
/**
* 与客户端的连接会话,需要通过他来给客户端发消息
*/
private Session session;
/**
* 当前用户ID
*/
private Integer userId;
/**
* concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。
* 虽然@Component默认是单例模式的,但springboot还是会为每个websocket连接初始化一个bean,所以可以用一个静态set保存起来。
*/
private static final CopyOnWriteArraySet<WebSocket> webSockets =new CopyOnWriteArraySet<>();
/**
*用来存在线连接用户信息
*/
private static final ConcurrentHashMap<Integer,Session> sessionPool = new ConcurrentHashMap<Integer,Session>();
private static MessageMapper messageMapper;
@Autowired
public void setMessageMapper(MessageMapper messageMapper){
WebSocket.messageMapper = messageMapper;
}
/**
* 连接成功方法
* @param session 连接会话
* @param userId 用户编号
*/
@OnOpen
public void onOpen(Session session , @PathParam("myUserId") Integer userId){
try {
this.session = session;
this.userId = userId;
webSockets.add(this);
sessionPool.put(userId, session);
log.info("【websocket消息】 用户:" + userId + " 加入连接...");
} catch (Exception e) {
log.error("---------------WebSocket连接异常---------------");
}
}
/**
* 关闭连接
*/
@OnClose
public void onClose(){
try {
webSockets.remove(this);
sessionPool.remove(this.userId);
log.info("【websocket消息】 用户:"+ this.userId + " 断开连接...");
} catch (Exception e) {
log.error("---------------WebSocket断开异常---------------");
}
}
@OnMessage
public void onMessage(String body){
try {
//将Body解析
MessageEntityDecode messageEntityDecode = new MessageEntityDecode();
MessageVO messageVO = messageEntityDecode.decode(body);
log.info(messageVO.toString());
sendOneMessage(messageVO.getReceiveUser(),messageVO.getMessage());
MessagePO messagePO = messageVO.transformPO();
messageMapper.insert(messagePO);
// }
} catch (Exception e) {
log.error("---------------WebSocket消息异常---------------");
e.printStackTrace();
}
}
/**
* 单点消息
* @param userId
* @param message
*/
public void sendOneMessage(Integer userId, String message) {
Session session = sessionPool.get(userId);
if (session != null&&session.isOpen()) {
try {
log.info("【websocket消息】 单点消息:"+message);
MessageVO messageVO = new MessageVO();
messageVO.setMessage(message);
messageVO.setSendUser(this.userId);
messageVO.setReceiveUser(userId);
MessageEntityEncode messageEntityEncode = new MessageEntityEncode();
session.getAsyncRemote().sendText(messageEntityEncode.encode(messageVO));
} catch (Exception e) {
log.error("---------------WebSocket单点消息发送异常---------------");
}
}
}
}