目录
理论部分
不要急!理论谁看不迷糊,急的话直接跳过,看下面的代码案例,先体验体验,回头再上来看看。下面后端代码都有注释,包看抱会(一半是正经说法的注释,一半个人理解写的注释,包看包会)
最快速了解:
websocket是双向协议,服务器可以主动向客户端发送消息,基于这一点实现聊天功能。websocket有五个重要点:建立连接、接收数据、发送数据、关闭连接,连接异常。
就是能建立连接,收、发数据。
什么是websocket
WebSocket 是一种网络通信协议。RFC6455定义了它的通信标准。
WebSocket 是HTML5开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。
HTTP 协议是一种无状态的、无连接的、单向的应用层协议。它采用了请求/响应模型。通信请求只能由客户端发起,服务端对请求做出应答处理。
这种通信模型有一个弊端: HTTP 协议无法实现服务器主动向客户端发起消息。这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。大多数 Web 应用程序将通过频繁的异步 AJAX 请求实现长轮询。轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)
websocket协议握手、通信例图
websocket协议本协议有两部分:握手和数据传输。
握手是基于http协议的,来自客户端的握手看起来像如下形式:
GET ws : / / loca7host/chat HTTP/1.12
Host: localhost
upgrade: websocket4
connection: upgrade
sec-websocket-Key: dGh1IHNhbXBSZSBub25jzQ==
sec-websocket-Extensions: permessage-deflate
sec-websocket-version: 13
来自服务器的握手看起来像如下形式:
HTTP/1.1 101 switching Protocols
upgrade: websocket
connection: upgrade
sec-websocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOO=sec-websocket-Extensions : permessage-def1ate
字段说明:
头名称 | 说明 |
connection:Upgrade | 标识该HTTP请求是一个协议升级请求 |
Upgrade:websocket | 协议升级为websocket协议 |
sec-websocket-version: 13 | 客户端支持websocket的版本 |
sec-websocket-Key: | 客户端采用base64编码的24位随机字符序列,服务器接受客户端HTTP协议升级的证明。要求服务端响应一个对应加密的sec-websocket-Accept头信息作为应答 |
sec-websocket-Extensions | 协议扩展类型 |
客户端实现
实现webSockets的 web浏览器将通过websocket对象公开所有必需的客户端功能(主要指支持Html5的浏览器)。
用形如类似以下 API用于创建websocket对象:
var ws = new websocket(url);
(参数url格式说明: ws : / /ip地址:端口号/资源名称)
websocket事件
事件 | 事件处理程序 | 描述 |
open | websocket对象.onopen | 连接建立时触发 |
message | websocket对象.onmessage | 客户端接收服务端数据时触发 |
error | websocket对象.onerror | 通信发生错误时触发 |
close | websocket对象.onclose | 连接关闭时触发 |
websocket对象的相关方法
方法 | 描述 |
send ( ) | 使用连接发送数据 |
服务端实现
omcat的7.0.5版本开始支持websocket,并且实现了Java webSocket规范(JSR356)。
Java WebSocket应用由一系列的webSocketEndpoint组成。Endpoint是一个java对象,代表webSocket链接的一端,对于服务端,我们可以视为处理具体websocket消息的接口,就像Servlet之与http请求一样。
服务端类Endpoint
可以通过两种方式定义Endpoint
第一种是编程式,即继承类javax.websocket.Endpoint并实现其方法。
第二种是注解式,即定义一个PoJo,并添加@serverEndpoint相关注解。
Endpoint实例在webSocket握手时创建,并在客户端与服务端链接过程中有效,最后在链接关闭时结束。在Endpoint接口中明确定义了与其生命周期相关的方法,规范实现者确保生命周期的各个阶段调用实例的相关方法。生命周期方法如下:
方法 | 含义描述 | 注解 |
onclose | 当会话关闭时调用。 | @onclose |
onopen | 当开启一个新的会话时调用,该方法是客户端与服务端握手成功后调用的方法 | @onopen |
onError | 当连接过程中异常时调用。 | @onError |
服务端接收客户端数据
通过为客户端的 Session 添加MessagelTandler消息处理器来接收消息,当采用注解方式定义Endpoint时,我们还可以通过onMessage 注解指定接收消息的方法。
服务端推送数据给客户端
发送消息则由RemoteEndpoint完成,其实例由session维护,根据使用情况session.getBasicRemote获取同步消息发送的实例,然后调用其sendXxx()方法就可以发送消息,可以通过session.getAsyncRemote 获取异步消息发送实例。
服务端客户端通信总体流程
实操部分
下面就展示一下我个人写的一个很简单、不完整、漏洞百出,但基础聊天功能(一个人同时与多个用户聊天)实现的demo,请多多指教
如果有用数据库记录聊天记录的朋友,可以看下最后一点
导入依赖坐标:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
创建配置类:
package com.fjut.jjl3211311109.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
//websocket要做的配置类
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
}
java代码(有详细解释):
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fjut.jjl3211311109.Utils.SpringContextUtils;
import com.fjut.jjl3211311109.mapper.ChatMapper;
import com.fjut.jjl3211311109.pojo.Chat;
import com.fjut.jjl3211311109.pojo.Follow;
import com.fjut.jjl3211311109.pojo.Message;
import com.fjut.jjl3211311109.service.ChatService;
import com.fjut.jjl3211311109.service.FollowService;
import com.fjut.jjl3211311109.service.impl.ChatServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.util.CharsetMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
//这个value后面是建立连接的名字,后面可以跟参数,前端传送,用来做消息发送对象或者创建客户端连接的名字
@ServerEndpoint(value = "/websocket/{username}/{followName}")
@Component
public class ChatEndPoint {
//用线程安全的map来保存当前用户
//静态map,明白吧,整个程序运行中就这一个,当成全局变量
private static Map<String,ChatEndPoint> onLineUsers = new ConcurrentHashMap<>();
//声明一个session对象,通过该对象可以发送消息给指定用户,不能设置为静态,每个ChatEndPoint有一个session才能区分(websocket的session)
//注意哦,这个session和登录的那个session可不是同一个哦
private Session session;
//保存当前登录浏览器的用户
//就是本次创建连接客户端的名字啦
private String username;
//保存被发起方
//对方名字咯
private String followName;
private static ChatMapper chatMapper;
@Autowired
public static void setChatMapper(ChatMapper chatMapper) {
ChatEndPoint.chatMapper = chatMapper;
}
//建立连接时发送系统广播
@OnOpen
public void onOpen(Session session, @PathParam(value="username")String username, @PathParam(value="followName")String followName){
System.err.println("建立客户端连接");
this.username = username;
this.followName = followName;
//将局部的session对象赋值给成员session
this.session = session;
//将对象存储到容器中
//将用户名称以自己和对方的名字加起来命名,查找简单
System.err.println("上线用户名称:"+username+followName);
log.info("上线用户名称:"+username+followName)
;
//新建立的客户端要保存起来哦
onLineUsers.put(username+followName, this);
}
//获取当前登录的用户
private Set<String> getNames(){
return onLineUsers.keySet();
}
//用户之间的信息发送
@OnMessage
//接收到客户端数据被调用
public void onMessage(String message, Session session){
try{
//将json字符串转对象,前端传送参数的时候也要注意哦
Message msg = JSON.parseObject(message, Message.class);
System.err.println(msg.getFrom()+msg.getToWho()+"向"+msg.getToWho()+msg.getFrom()+"发送: "+msg.getMessage());
//从容器中获取bean
//这个bean是因为在当前类里不能直接用IOC容器注入,用来将数据存入数据库用的,下次可以看见聊天记录,可以不写哦,即时聊天也是聊天hh
ChatMapper chatMapper = (ChatMapper) SpringContextUtils.getBean(ChatMapper.class);
//新建聊天记录
//这个chat很容易理解吧,我就不放上来了
Chat chat = new Chat();
//发送人
chat.setPromoter(msg.getFrom());
//接收人
chat.setRecipient(msg.getToWho());
//消息
chat.setMessage(msg.getMessage());
//时间
chat.setCreatDate(LocalDateTime.now().toString());
//保存聊天记录,将这个数据存入数据库中
chatMapper.insert(chat);
//判断接收方是否在线,发送消息
//对方是否在线的意思是,对方是否也创建了(他名字+你名字)客户端,没有的话不能发送消息哦,会报错
if(onLineUsers.get(msg.getToWho()+msg.getFrom()) != null){ //若接收方在线,直接发送
onLineUsers.get(msg.getToWho() + msg.getFrom()).session.getBasicRemote().sendText(msg.getMessage());
}else{ //若接收方不在线,添加提醒,不发送,只保存记录到数据库,接收方上线了就可以看到
FollowService followService = (FollowService) SpringContextUtils.getBean(FollowService.class);
//下面代码到方法结束可以不用注意,这个只是我写的一种用数据库查询查看是否有新消息的方式
Follow one = followService.getOne(Wrappers.<Follow>lambdaQuery().eq(Follow::getFollowName, msg.getFrom()).eq(Follow::getUserName, msg.getToWho()));
if(one.getRemind().equals("2")) {
followService.AddRemind(msg.getToWho(), msg.getFrom(),"3");
} else {
followService.AddRemind(msg.getToWho(), msg.getFrom(), "1");
}
}
}catch (Exception e){
e.printStackTrace();
}
}
//用户断开连接的断后操作
@OnClose
//连接关闭时被调用
public void onClose(Session session){
log.info("离线用户:"+ username+followName);
System.err.println(username+followName+": 断开");
if (username+followName!= null){
onLineUsers.remove(username+followName);
}
}
}
前端代码(Vue)
(饶了我吧,这是我在gpt帮助下,能写出的最好的前端了(˚ ˃̣̣̥᷄⌓˂̣̣̥᷅ ))
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Interface</title>
<link rel="stylesheet" href="chat.css">
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<div id="app">
<p>{{followName}}</p>
<button class="clean-button" @click="deleteChat" >清除聊天记录</button>
<div class="chat-container">
<div class="chat-box">
<div v-for="message in messages" :key="message.id" class="message">
<div v-if="message.isSent" class="sent-message align-right">
{{ message.content }}
</div>
<div v-else class="received-message align-left">
{{ message.content }}
</div>
</div>
</div>
<div class="input-box">
<textarea class="input-field" v-model="inputMessage" @keyup.enter="sendME"></textarea>
<button class="send-button" @click="sendM" >发送</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script>
var username;
var followName;
var ws;
new Vue( {
el: '#app',
data() {
return {
messages: [],
inputMessage: '',
}
},
created() {
const urlParams = new URLSearchParams(window.location.search);
followName = urlParams.get('followName')
console.log(followName)
this.getCurrentUser();
},
destroyed: function () { // 离开页面生命周期函数
this.websocketclose();
},
methods: {
sendM() {
if (this.inputMessage) {
this.messages.push({
id: this.messages.length + 1,
content: this.inputMessage,
isSent: false
});
var json = {"from": username ,"toWho": followName ,"message": this.inputMessage};
ws.send(JSON.stringify(json))
this.scrollToBottom();
this.inputMessage = '';
}
},
sendME(event) {
if (event.shiftKey) {
event.preventDefault(); // 阻止默认的换行行为
} else {
this.sendM();
}
},
scrollToBottom() {
//滑动到最下方
this.$nextTick(() => {
const chatBox = document.querySelector(".chat-box");
chatBox.scrollTop = chatBox.scrollHeight;
});
},
getCurrentUser: function() {
axios.post("http://localhost:8083/user/getCurrentUser",).then(resp => {
Result = resp.data;
username=Result.msg;
console.log(username);
//初始化websocket
this.initWebSocket()
//获取聊天记录
this.getChatList();
}
).catch(e => {
console.log(e)
})
},
deleteChat: function() {
axios.post("http://localhost:8083/chat/delete?from="+username+"&toWho="+followName,).then(resp => {
this.messages=[];
}
).catch(e => {
console.log(e)
})
},
getChatList: function() {
axios.post("http://localhost:8083/chat/get?from="+username+"&toWho="+followName).then(resp => {
Result = resp.data;
console.log(Result);
// 遍历聊天记录
Result.data.forEach(chat => {
let isSent = false;
if (chat.promoter === username) {
isSent = false;
} else if (chat.recipient === username) {
isSent = true;
}
// 将聊天记录添加到 messages 数组
this.messages.push({
id: this.messages.length + 1,
content: chat.message,
isSent: isSent
});
});
//滑动到最新消息
this.scrollToBottom();
}).catch(e => {
console.log(e);
});
},
initWebSocket: function () { // 建立连接
// WebSocket与普通的请求所用协议有所不同,ws等同于http,wss等同于https
var host = window.location.host;
ws = new WebSocket("ws://"+host+"/websocket/"+username+"/"+followName);
console.log(ws);
ws.onopen = this.websocketonopen;
ws.onerror = this.websocketonerror;
ws.onmessage = this.websocketonmessage;
ws.onclose = this.websocketclose;
},
// 连接成功后调用
websocketonopen: function () {
console.log("WebSocket连接成功");
},
// 发生错误时调用
websocketonerror: function (e) {
console.log("WebSocket连接发生错误");
},
// 接收服务端消息
websocketonmessage: function (e) {
var data = e.data;
this.messages.push({
id: this.messages.length + 1,
content: data,
isSent: true
});
//滑动到最新消息
this.scrollToBottom();
},
// 关闭连接时调用
websocketclose: function (e) {
console.log("WebSocket连接关闭");
},
}
})
</script>
</body>
</html>
css
body {
background-image:url('980.jpg');
background-color: #f2f2f2;
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
}
.chat-box {
margin-top: 40px;
width: 600px;
height: 800px;
background-color: #fff;
border-radius: 20px;
overflow-y: scroll;
padding: 15px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.sent-message {
background-color: #f3f3f3;
padding: 15px;
border-radius: 16px;
color: #333;
font-size: 16px;
word-wrap: break-word; /* 自动换行 */
}
.sent-message.align-right {
text-align: left;
margin-left: 0;
margin-right: auto;
}
.received-message {
background-color: #d4f4ff;
padding: 15px;
border-radius: 16px;
color: #333;
font-size: 16px;
word-wrap: break-word; /* 自动换行 */
}
.received-message.align-left {
text-align: right;
margin-right: 0;
margin-left: auto;
}
.input-field {
flex-grow: 1;
border: none;
outline: none;
font-size: 16px; /* 更新字体大小值 */
padding: 5px;
}
.send-button {
background-color: #4e8aff;
color: #fff;
border: none;
outline: none;
padding: 8px 15px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.send-button:hover {
background-color: #1565c0;
}
.message {
margin-bottom: 10px;
}
.input-box {
width: 600px;
margin-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
border-radius: 16px;
padding: 10px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
body {
background-image:url('980.jpg');
background-color: #f2f2f2;
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
}
.chat-container {
height: 90vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.chat-box {
margin-top: 40px;
width: 600px;
height: 700px;
background-color: #fff;
border-radius: 20px;
overflow-y: scroll;
padding: 15px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.sent-message {
background-color: #f3f3f3;
padding: 15px;
border-radius: 16px;
color: #333;
font-size: 16px;
word-wrap: break-word; /* 自动换行 */
}
.sent-message.align-right {
text-align: left;
margin-left: 0;
margin-right: auto;
}
.received-message {
background-color: #d4f4ff;
padding: 15px;
border-radius: 16px;
color: #333;
font-size: 16px;
word-wrap: break-word; /* 自动换行 */
}
.received-message.align-left {
text-align: right;
margin-right: 0;
margin-left: auto;
}
.input-field {
flex-grow: 1;
border: none;
outline: none;
font-size: 16px; /* 更新字体大小值 */
padding: 5px;
}
.send-button {
background-color: #4e8aff;
color: #fff;
border: none;
outline: none;
padding: 8px 15px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.send-button:hover {
background-color: #1565c0;
}
.clean-button {
background-color: #cecece;
color: #fff;
border: none;
outline: none;
padding: 8px 15px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.clean-button:hover {
background-color: #b6b6b6;
}
.message {
margin-bottom: 10px;
}
.input-box {
width: 600px;
margin-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
border-radius: 16px;
padding: 10px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
编写过程记录
前端:
前端太难了,555,特别是实例、组件方法啥的,花了好久时间(˚ ˃̣̣̥᷄⌓˂̣̣̥᷅ )
后端代码:
将客户端的命名格式为:当前用户+聊天用户。可以避免当一个人同时与多个人聊天的时候,若只用当前用户名作为客户端名,会出现串聊的问题(用户A同时建立多个命名为A的客户端,多个客户端对的客户端A发消息,会对所有名字为A的客户端发消息,就串聊了),但是我突然意识到,每与一个人聊天,就创建一个客户端,这消耗。。。还是得以当前用户名创建一个客户端,至于怎么区分不同用户的消息可以再做判断(前端貌似能做)
目前版本能完成对单人同时与多人单独聊天,没做平台检测,要是同时登录多个会出错(˚ ˃̣̣̥᷄⌓˂̣̣̥᷅ ),而且一个浏览器只能同时登录一个账号。还有好多问题一时间没想起来
遇到的一个获取bean的问题:
我使用mybatisplus来访问存取数据库,使用bean注入的时候发现了问题,注入的bean都是空的,websocket类加上了@Component注解,但还是无法通过@Resource和@Autowire注入spring容器管理下的bean(但是websocket类可以被别的类注入),所以采用通过ApplicationContext
接口的getBean()
方法获取Bean对象,工具类代码如下。
package com.fjut.jjl3211311109.Utils;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
public class SpringContextUtils implements ApplicationContextAware {
/**
* 上下文对象实例
*/
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringContextUtils.applicationContext = applicationContext;
}
/**
* 获取applicationContext
*/
public static ApplicationContext getApplicationContext() {
//判断是否为null
if (applicationContext == null) {
throw new IllegalStateException("applicaitonContext未注入,请在applicationContext.xml中定义SpringContextHolder.");
}
return applicationContext;
}
/**
* 通过name获取Bean
*/
public static Object getBean(String name) {
return getApplicationContext().getBean(name);
}
/**
* 通过class获取Bean
*/
public static <T> T getBean(Class<T> clazz) {
return getApplicationContext().getBean(clazz);
}
/**
* 通过name和class获取Bean
*/
public static <T> T getBean(String name, Class<T> clazz) {
return getApplicationContext().getBean(name, clazz);
}
}
最终效果展示
来看看最终成功吧~~
成功!可以一个人同时与多个人单独聊天!
参考: