全双工通信协议WebSocket——使用WebSocket实现智能学习助手/聊天室功能
一.什么是WebSocket?
WebSocket是基于TCP的一种新的网络协议。它实现了浏览器与服务器的全双工通信——浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输
HTTP 协议是一种无状态的、无连接的、单向的应用层协议。它采用了请求/响应模型。通信请求只能由客户端发起,服务端对请求做出应答处理。
这种通信模型有一个弊端:HTTP 协议无法实现服务器主动向客户端发起消息。
这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。大多数 Web 应用程序将通过频繁的异步 AJAX 请求实现长轮询。轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。
http协议:
websocket协议:
二.HTTP协议和WebSocket协议对比:
- HTTP是短连接
- WebSocket是长连接
- HTTP通信是单向的,基于请求响应模式 WebSocket支持双向通信
- HTTP和WebSocket底层都是TCP连接
三.基于WebSocket的智能学习助手功能实现
1.需求
通过 websocket 实现一个简易的聊天室功能
当点击智能学习助手选项,会进入一个聊天室,小助手自动向用户问好,用户可以向小助手提问问题,小助手后端查询到问题答案后会进行回复
2.前端
前端环境:vue3+element-plus+pinia
因为我的这个项目用户端和管理端共用该功能,所以URL上带上了从pinia中获取的当前登录用户的信息,用于与后端建立唯一的连接标识
下面的代码是只对于学习小助手组件的.vue文件,读者有需自行扩展
<script setup>
import { ref } from "vue";
import { ElMessage } from "element-plus";
import useUserInfoStore from "@/stores/userInfo.js";
const userInfoStore = useUserInfoStore();
const userInfo = ref({ ...userInfoStore.info });
// 发送的信息
const say = ref("");
// 内容
const content = ref("");
// 管理员1,普通用户0
const role = userInfo.value.role == "管理员" ? 1 : 0;
// 发送给后端的URL
const url = ref("ws://localhost:8080/char/" + role);
var websocket = null;
//判断当前浏览器是否支持WebSocket
if ("WebSocket" in window) {
//连接WebSocket节点
websocket = new WebSocket(url.value);
} else {
ElMessage.error("Not support websocket");
}
//连接发生错误的回调方法
websocket.onerror = function () {
ElMessage.error("连接错误");
};
//连接成功建立的回调方法
websocket.onopen = function () {
ElMessage.success("连接成功");
createContent(false, "你好,我是智能学习小助手~");
};
//接收到消息的回调方法
websocket.onmessage = function (event) {
createContent(false, event.data);
};
//连接关闭的回调方法
websocket.onclose = function () {
ElMessage.success("连接关闭");
};
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function () {
websocket.close();
};
//发送消息
function send() {
websocket.send(say.value);
createContent(true, say.value);
say.value = "";
}
//关闭连接
function closeWebSocket() {
websocket.close();
}
// 构造消息框
const createContent = (isMyMsg, msg) => {
let html;
// 当前用户消息
if (isMyMsg) {
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 style="color: white;text-align: center;border-radius: 10px;font-family: sans-serif;padding: 10px;width:auto;display:inline-block !important;display:inline;background-color: deepskyblue;">' +
msg +
"</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="' +
userInfo.value.userUrl +
'" style="object-fit: cover;">\n' +
" </span>\n" +
" </div>\n" +
"</div>";
} else {
// 助手信息
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="' +
"https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" +
'" 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 style="color: white;text-align: center;border-radius: 10px;font-family: sans-serif;padding: 10px;width:auto;display:inline-block !important;display:inline;background-color: deepskyblue;">' +
msg +
"</div>\n" +
" </div>\n" +
"</div>";
}
content.value += html;
};
</script>
<template>
<el-card class="page-container">
<template #header>
<div class="block text-center">
<el-row :gutter="20">
<el-col :span="6" :offset="11"><span>智能学习小助手</span></el-col>
</el-row>
<!-- 分割线 -->
<el-divider />
<!-- 走马灯 -->
<el-carousel height="150px">
<el-carousel-item v-for="item in 4" :key="item">
<h3 class="small justify-center" text="2xl">
韩磊大帅哥{{ item }}
</h3>
</el-carousel-item>
</el-carousel>
</div>
</template>
<!-- 滚动条 -->
<el-scrollbar height="460px">
<div v-html="content"></div>
</el-scrollbar>
<template #footer>
<el-row>
<el-input
style="width: 100%"
:autosize="{ minRows: 4, maxRows: 8 }"
type="textarea"
v-model="say"
placeholder="有困难就找汪汪队~"
/>
</el-row>
<el-row style="margin-top: 10px">
<el-col :span="2" :offset="20">
<div class="grid-content ep-bg-purple" />
<el-button type="primary" @click="send()">发送</el-button>
</el-col>
<el-col :span="1">
<div class="grid-content ep-bg-purple" />
<el-button type="primary" @click="say = ''">清空文本</el-button>
</el-col>
</el-row>
</template>
</el-card>
</template>
<style lang="scss" scoped>
.page-container {
min-height: 100%;
box-sizing: border-box;
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.el-carousel__item h3 {
color: #475669;
opacity: 0.75;
line-height: 150px;
margin: 0;
text-align: center;
}
.img {
width: 100%;
height: 460px;
}
.el-carousel__item:nth-child(2n) {
background-color: #99a9bf;
}
.el-carousel__item:nth-child(2n + 1) {
background-color: #d3dce6;
}
.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>
3.后端
后端环境:springboot+lombok+web+mybatisplus+websocket
3.1.Springboot 添加Pom依赖
<!--websocket-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
3.2.添加Websocket配置
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
/**
* WebSocket配置类,用于注册WebSocket的Bean
*/
@Configuration
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
3.3.构建数据库
3.3.1.配置并连接数据库并创建问题表(自行完成)
3.3.2.pojo类
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("lw_answers")
public class Answer {
@TableId(type = IdType.AUTO)
private Long answerId; // 问题ID
private String issue; // 问题
private String answer; // 回答
}
3.3.3.注册Mapper接口
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hl.pojo.entity.Answer;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AnswerMapper extends BaseMapper<Answer> {
}
3.3.4.注册WebSocketServer接口
因为我们每个建立会话的对象要唯一!所以对于不同的用户我们根据用户角色和用户id来建立唯一标识
package com.hl.server.websocket;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hl.common.constant.MessageConstant;
import com.hl.common.context.BaseContext;
import com.hl.common.enumeration.UserRole;
import com.hl.pojo.entity.Answer;
import com.hl.server.mapper.AnswerMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket服务
*/
@Slf4j
@Component
@ServerEndpoint("/char/{role}")
public class WebSocketServer {
/**
* WebSocket API 是独立于任何特定框架的标准,
* 它的生命周期和管理是由 WebSocket 容器负责的,而不是由 Spring 容器负责。
* 因此,WebSocket 容器不会识别或处理 Spring 的依赖注入注解。
*/
private static AnswerMapper answerMapper;
@Autowired
private void setAnswerMapper(AnswerMapper answerMapper) {
WebSocketServer.answerMapper = answerMapper;
}
//存放会话对象
private static Map<String, Session> sessionMap = new ConcurrentHashMap<>();
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session,@PathParam("role") Integer role) {
String key=getUserInfo(role);
log.info("WebSocketServer onOpen role:{}",key);
sessionMap.put(key, session);
}
/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message,@PathParam("role") Integer role) {
String key=getUserInfo(role);
log.info("WebSocketServer onMessage user:{} message:{}",key,message);
LambdaQueryWrapper<Answer> queryWrapper=new LambdaQueryWrapper<Answer>()
.like(Answer::getIssue,message);
Answer ans = answerMapper.selectOne(queryWrapper);
String answer = ans==null? MessageConstant.BU_ZHIDAO:ans.getAnswer();
sessionMap.get(key).getAsyncRemote().sendText(answer);
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose(@PathParam("role") Integer role) {
String key=getUserInfo(role);
log.info("WebSocketServer onClose:{}",key);
sessionMap.remove(key);
}
/**
* 获取唯一标识key
*/
public String getUserInfo(Integer role) {
String key="";
// 根据LocalThread获取当前登录用户id
Long id= BaseContext.getCurrentId();
if(role== UserRole.COMMON.getValue()){
//普通用户
key=UserRole.COMMON.getDesc()+id;
}else{
//管理员
key=UserRole.ADMIN.getDesc()+id;
}
return key;
}
}
注: WebSocket API 是独立于任何特定框架的标准, 它的生命周期和管理是由 WebSocket 容器负责的,而不是由 Spring 容器负责。 因此,WebSocket 容器不会识别或处理 Spring 的依赖注入注解,所以我们不能使用字段注入!