写在前面
WebSocket 简称 ws
本文通过介绍ws,然后一步步的使用ws协议进行前后端开发测试,最后使用SpringBoot
和vue
利用ws协议达实现一个在线聊天室的小demo(源码在文章末尾)。
看完此篇后将能够完成这样的小demo(需要会使用springboot和vue2以及element-ui)
WebSocket介绍
WebSocket是在HTML5
开始提供的一种能在单个TCP
连接上进行全双工
通讯的网络通信协议
与HTTP协议可以简单理解为:
WebSocket协议与HTTP协议对比:
- HTTP协议:HTTP协议是一种
无状态(请求完成后,连接彻底断开,节省资源)
、无连接(客户端端向服务端请求数据,获取数据后即断开连接)
、单向(连接建立只能由客户端发起)
的应用层通信协议,采用请求/响应模型。- 特性:客户端有事向服务器询问,服务器告诉它,告诉完毕后客户端没有问题就消失的无影无踪,服务器再也找不到它,只能等到客户端下次有问题去询问服务器它才会出现。服务器在明处,客户端在暗处。
-
弊端,客户端有事向服务端询问可以,但服务端有事却找不到客户端,因为客户端完成数据获取后,连接被彻底断开,而掌握
建立连接的特权只在客户端
手中,只能把事憋到客户端与服务端再次建立请求时一并告诉它。当有要紧事的时候,也无法联系客户端,即服务端无法向客户端推送数据
-
解决思路:让客户端频繁的去与服务端建立连接,这样服务端的数据就不用憋太久
- 存在的问题:多久去与客户端建立一次连接呢?首先频繁的建立耗费系统大量的资源,而服务端却很少有数据传输;其次,多久建立一次连接呢?连接建立的过于频繁,客户端会承受不住,如果频率低,服务器的数据还是要憋一会,还是打达不到
实时
。频繁访问耗费巨大资源洒下大网只有极少收获。
- 存在的问题:多久去与客户端建立一次连接呢?首先频繁的建立耗费系统大量的资源,而服务端却很少有数据传输;其次,多久建立一次连接呢?连接建立的过于频繁,客户端会承受不住,如果频率低,服务器的数据还是要憋一会,还是打达不到
要是有一种客户端能向服务器端发送数据,服务器端也能向客户端发送数据的协议就好了!即全双工通讯
。这种协议客户端不能与服务端断开连接,使得服务端端能够找到客户端继续发送数据即有状态连接
虽然会耗费资源,但在特定需求下,比HTTP协议那种频繁连接要好上不少。这种协议就是WebSocket协议
。总结webSocket就是支持持久
连接使得服务端与客户端都能够实时
通信的协议
WebSocket的应用场景
- 及时通信的Web应用:例如要求网页数据实时刷新的
实时数据展示网站
- 游戏应用:屏幕动画能够实时刷新
- 聊天应用:发送消息能够及时接收,推送
推荐文章
WebSocket实现
HTTP方式:我们编写前后端分离的代码时,后端编写@RestController
接口,用来接收请求,前端编写封装axios
用来发送请求获取后端数据。其实这种方式就是遵守HTTP协议进行数据交互的,前端使用axios
发送的是HTTP请求,后端接口接收的也是HTTP请求。
WS方式:
因为常使用的@RestController
接口和axios
请求工具都是为HTTP协议服务的
,因此WS
虽然数据交互是这一流程(后端指定接口,前端获取数据),但要有自己的一套实现工具。使用@ServerEndpoint
到达类似于@RestController
的效果,使用WebSocket
对象达到类似于axios
的效果,具体使用后面会介绍。 Endpoint与webScoket的关系就像 Servlet
与Http的关系一样
生命周期
生命周期使我理解的,原话为 websocket事件
前端
let ws=new webSocket(ws://ip:端口号/资源名称)
函数名 | 描述 |
---|---|
ws.onopen | 建立连接 时触发 |
ws.onmessage |
接收 到后端消息时触发 |
ws.onerror | 通信过程发生错误 时触发 |
ws.onclose | 连接关闭 时触发 |
发送数据时 ws.send()
后端
注解名 | 描述 |
---|---|
@onClose | 连接关闭时触发 |
@onOpen | 初始化时触发 |
@onError | 出现错误时触发 |
@onMessage | 初始化后自动触发,获取前端传递的数据 |
ws实现流程概括
服务端不再像过去一样只负责客户端的数据响应,还可以主动向客户端推送数据
ws前后端都有一个生命周期的概念,后端的各个生命周期相当于把一个@RestController
进行拆分同时多了一个连接者的容器。
- 进行请求映射使用
@ServerEndpoint("xx")
- 请求初始化时将此请求放入所有连接者的容器,方便在发送请求时,能够找到当前与系统建立连接的各个连接,使用
@OnOpen
标记 - 获取前端的数据
@OnMessage
,可以使用同@RequestMapping()
的注解方式获取数据,如@PathParam("xxxx")
;注意每次前端发送数据,此方法都会被调用。但初始化时不会调用 - 返回给前端数据 不在直接使用
return
,而是使用获取连接池中保存的连接session,通过session将返回的信息进行传递。 - 消息的接收、返回都已经完成,其余的就是补充。关闭连接是调用
@OnClose
标注下的方法,出现错误时调用@OnError
标注下的方法
webSocket有自己获取参数的获取方式,与HTTP的Controller的方式很像
- 从请求
路径
参数中获取数据@PathParam("xxx")
使用起来与@PathValue("xxx")
一样
有的注解下的方法必须含有某些参数
-
@OnError
标识下的方法列表中必须含有Throwable
对象,否则启动报错 -
@OnMessage
此注解标识下的方法,在前端向后端发送消息时自动调用,要求此方法必须含有参数,否则启动报错
问题?
-
在后端向前端发送信息时,怎样去调用?
整个生命周期在连接时能够自动调用的有两个方法,一个是在初始化时调用@OnOpen
标注下的方法,一次是在接受前端请求时 调用@OnMessage
下的方法(此方法前端每次发送数据都会被调用)。可以选用@OnMessage
标注下的方法作为后端向前端返回数据的引子,在此方法中调用返回前端的数据方法。 -
怎样发到即时通讯效果?
-
解决后端怎样向前端发送数据问题,即可以解决此问题。当A通过ws将发给B的消息传递到服务器,在接收前端数据的方法中自动调用返回前端数据的方法,在此方法中获取消息内容和目的方,然后去连接者容器中找到目的方的连接对象,然后将A发送的数据作为返回给B连接对象返回的数据。**这样思路就要求 B要在连接者的容器中,即B要保持连接 **
-
怎样使得前台一直接收后端自己产生的数据
后端自己产生的数据就代表前台只能触发一次,其余的数据不断返回前端都要靠着这一次触发。可以在第一次触发时调用一个类似于监听阻塞阻塞队列的方法,将产生的数据不断的放入队列中,方法中监听到数据就取出返回给前端。这样就是由第一次请求调用监听器,数据产生监听的去触发数据返回 -
前端怎样接收后端返回的数据?
当后端有数据返回时前端的对象的onmessage()
会被触发,在这里可以就行数据的赋值,页面的渲染
ws参数传递
-
路径传参
:在初始化时通过路径进行参数传递 后端使用要在路径的映射中使用 例如:login/{xxx}
,然后在方法参数中使用@PathParam("xxx")
去接收 -
消息传参
:使用对象发送消息ws.send(“xxx”)
,可以将一些内容转为JSON形式进行发送,然后后端在@OnMessage
下的方法参数中进行接收和转换 -
请求头传参
:使用请求头,因为以上都已经介绍过,这里不加赘述。请求头才是这里重点-
前端添加请求头
ws的请求不像HTTP请求那样灵活,没有api进行传参,且不可以自定义请求头。那怎样进行传递呢?- 利用websocket请求头有含
Sec-WebSocket-Protocol
这个属性,可以在为属性中赋值,后端从这个属性中取出。这个属性赋值没有单独的Api,只能在构造ws对象能够进行赋值
。
例如 : let ws = new WebSocket("ws://localhost:8888/xxxx", "请求参数1"); 后端获取请求头 Sec-WebSocket-Protocol 得到的就是 请求参数1 let ws = new WebSocket("ws://localhost:8888/xxxx", ["请求参数1","请求参数2"]); 后端获取请求头 Sec-WebSocket-Protocol 得到的就是 请求参数1 请求参数2
- 利用websocket请求头有含
-
后端获取请求头
后端这里指的就是springBoot,后端获取请求头没有HTTP协议那样直接在参数列表中使用注解@RequestHeader
直接获取,而是要继承的指定方法中获取
*@Configuration @Slf4j public class WebSocketConfig extends ServerEndpointConfig.Configurator { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { //获取请求头 请求头的名字是固定的 List<String> list = request.getHeaders().get("Sec-WebSocket-Protocol"); //当Sec-WebSocket-Protocol请求头不为空时,需要返回给前端相同的响应 response.getHeaders().put("Sec-WebSocket-Protocol",list); super.modifyHandshake(sec, request, response); } }
- 然后 还要在
@ServerEndpoint
注释中指名配置类
注意:此类的中方法只会在创建连接时执行,在发送消息、出现错误、断开连接均不执行
个人理解:既然ws是创建一次就可以多次发送消息,在创建第一次时进行身份校验,同一个连接是不是只校验一次就可以了?http使用拦截器也是校验一次,因为其频繁连接才会频繁的验证?即在这个方法中充当http时的拦截器使用? -
前端添加请求头
模拟测试
后端模拟
后端使用springBoot
,前端使用postman
发送ws请求
后端准备: 注意要tomcat7
以后才支持webSocket协议
-
后端除去环境依赖外,要导入关键的WebSocket依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>
-
环境依赖
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.12.RELEASE</version> <relativePath/> </parent> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>
-
向容器中注入 ServerEndpointExporter对象
创建一个配置类,在类中将对象注入容器import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter; @Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }
-
接下来就可以写类似于接口的应用了
前端准备
- 在postMan中创建ws类型的连接 (不能使用原HTTP的方式)
第一个测试:建立连接后,前端向后端发送数据,后端接收数据,并向前端发送数据;只有一个连接 (不使用连接的session们容器)
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
@ServerEndpoint(value = "/wsserver/{username}")
@Component
@Slf4j
public class WebSocketServer {
private Session session;
@OnOpen
public void onOpen(@PathParam("username") String username, Session session){
this.session=session;
log.info(username);
log.info("连接创建初始化");
}
@OnMessage
public void onMessage(Session session,String message) throws IOException, InterruptedException {
log.info("接收到数据"+message);
Thread.sleep(10000);
for (int i=0;i<10;i++){
this.session.getBasicRemote().sendText("来自后端的数据");
}
}
@OnError
public void onError(Session session, Throwable error) {
log.error("发生错误");
error.printStackTrace();
}
@OnClose
public void onClose(){
log.info("关闭连接");
}
}
注释
测试结果
第二个测试:创建两个独立连接分别为A、B。A向B发送数据,B向A发送数据,在数据中要指定发送方
实现思路:
- 数据中指明
发送方
、接收方
以及数据
。在路径参数中指明发送方,在数据格式中设定规则,提取出接收方和数据。 - 每一个连接在初始化时都会向公共的缓存中添加自己的
标识
和session对象
,这样就方便根据数据的接收方去找到其连接的session,然后将数据通过接收方的session进行返回
@ServerEndpoint(value = "/wsserver/{username}")
@Component
@Slf4j
public class WebSocketServer {
public static final Map<String,Session> sessionMap=new ConcurrentHashMap<>();
@OnOpen
public void onOpen(@PathParam("username") String username, Session session){
sessionMap.put(username,session);
log.info("数据来自"+username);
log.info("连接创建初始化");
for (Map.Entry<String,Session> entry:sessionMap.entrySet()) {
System.out.println(entry);
}
}
@OnMessage
public void onMessage(Session session,String message) throws IOException, InterruptedException {
log.info("接收到数据"+message);
String[] split = message.split(":");
sessionMap.get(split[0]).getBasicRemote().sendText(split[1]);
}
@OnError
public void onError(Session session, Throwable error) {
log.error("发生错误");
error.printStackTrace();
}
@OnClose
public void onClose(){
log.info("关闭连接");
}
}
注释
测试结果
注意:每一个请求对应一个WebSocketServer
对象,每次变动都会调用对象中指定的方法,例如:每个请求连接时都会触发一次@onOpen下的方法、每个请求发送数据都会调用一次@onMessage标识下的方法…
前端页面
创建一个vue项目,因为后面项目会用到。这里也可以不创建,只要有一个能够运行js的环境即可,最简单的方式就是创建一个.html
页面然后在浏览器中打开
第一个测试 与服务器建立连接
创建一个ws
对象,利用此对象可以向后端发送数据
,获取后端数据
等。创建方式很简单,在创建时就会进行连接(是指执行到这里时,而不是指new出来后)
页面运行后,服务端会立即创建连接
第二个测试 在postman中的A向浏览中的C发送数据,C接收数据并打印到页面;
要在上文已经完成后端测试二的基础上
注意:onmessage是一直进行监听服务端发送过来的数据的
第三个测试 在测试二的基础上向postMan的A发送数据
利用wc对象的send(“xxx”)方法
<template>
<div>
{{receptionData}}
<el-input size="mini" v-model="data"></el-input>
<el-button @click="toSendMsg()">发送</el-button>
</div>
</template>
<script>
export default {
data(){
return{
data:'',
ws:undefined,
receptionData:undefined
}
},
created(){
this.ws= new WebSocket('ws://localhost:8080/wsserver/C');
this.ws.onmessage=(msg)=>{
this.receptionData=msg.data
}
},
methods:{
toSendMsg(){
this.ws.send("A:cccccc")
}
}
}
</script>
测试结果:
注意:当前端网页完毕时,会自动触发后端的关闭处理
此外还有 sc.close()
手动进行关闭的一些前端处理、 sc.onerror()
出现异常的一些处理,用法与onmessage
相同,都是为其赋值一个函数
在测试过程中发现:数据只能被消费一次,即有多个客户端进行连接时,只能有一个客户端接收到服务端的数据。因为不同的客户端连接就有不同的连接session。由于服务端的代码使用map存储,key为唯一标识,value为session。当一个用户在多个客户端登录时,map中相同的key的旧值会被最新的值覆盖,就出现只有最后打开的网页能接收到服务端的数据。
在线聊天系统开发
本系统为自己原创,基于HTTP协议(例如:登录、注册)与ws协议(例如:实时聊天)的混合开发,目的是熟练的使用ws协议,所以在其他方面较为省略,可能会有bug或不对的地方,还望指正
前端部分
前端使用vue和Element组价库(非必要
),使用axios发送HTTP的请求,使用webScocket对象发送ws请求。
- 使用npm创建一个vue项目
- 下载Element-ui并在main.js文件中进行导入
- 下载axios,创建一个工具类去封装axios请求,然后在需要的页面中的js部分导入。
- 配置路由
- 删除无用页面
- 然后测试项目能否正常运行,element是否导入成功等
项目的vue项目初始化部分这里不再一一介绍。
登录样式布局
代码部分:
<template>
<div class="wrapper">
<div class="login_div">
<span class="title"><h2> 在线聊天室</h2></span>
<div class="form_div">
<el-form ref="form" :model="form" label-width="80px">
<el-form-item>
<el-input v-model="form.name" size="mini" placeholder="输入姓名"></el-input>
</el-form-item>
<el-form-item>
<el-input v-model="form.password" size="mini" placeholder="输入密码" type="password"></el-input>
</el-form-item>
<el-form-item>
<a href="#" style="color:blue">还没有账号?点我去注册</a>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm()" size="small" style="width:100%">提交</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
<script>
import {login} from '../api/userApi'
export default {
data() {
return {
form:{
name:'',
password:''
},
mock:{
}
}
},
methods: {
submitForm(){
login(this.form).then(res=>{
})
}
}
}
</script>
<style scoped>
.wrapper {
height: 100vh;
background-image: linear-gradient(to bottom right, #FC466B, #3F5EFB);
width: 100%;
overflow: hidden;
position: relative;
}
.login_div{
width:500px;
height: 300px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -70%);
background-color:aliceblue;
opacity:0.9;
}
.title{
text-align: center ;
}
.form_div{
margin-right: 100px;
margin-top: 50px;
}
</style>
聊天页面布局
用css画一个渐变色的背景,然后在画出页面的整体布局
<template>
<div style="min-height: 100vh" class="window_div">
<div class="chat_div">
<div class="chat_header">
<span class="hrader_title">当前用户:xxx</span>
<span class="hrader_friend">正在和xxx 聊天</span>
</div>
<div class="chat_left">
<div class="message_div"></div>
<div class="input_div"></div>
</div>
<div class="chat_right">
<div class="friend_div">
<div class="friend_list_title"></div>
<div class="friend_list"></div>
</div>
<div class="sys_info_div">
<div class="sys_info_title"></div>
<div class="sys_info"></div>
</div>
</div>
</div>
<div>
</div>
</div>
</template>
<script>
export default {
}
</script>
<style scoped>
.window_div{
background-image: linear-gradient(to bottom right, #b1b1c5, #3F5EFB);
overflow: hidden;
position: relative;
}
.chat_div{
width: 60%;
height: 65vh;
border: 1px solid red;
margin: 10vh auto;
}
.chat_header{
display: flex;
width: 100%;
height: 10vh;
border: 1px solid red;
}
.chat_left{
width: 70%;
height: 55vh;
border: 1px solid red;
float: left;
}
.chat_right{
width: 30%;
height: 55vh;
border: 1px solid red;
float: right;
}
.hrader_title{
display: flexbox;
line-height: 10vh;
margin-left: 6%;
color: white;
width: 30%;
border: 1px solid red;
}
.hrader_friend{
display: inline-block;
margin-top: 6vh;
height: 2vh;
color: white;
width: 25%;
font-size: 12px;
border: 1px solid red;
}
.friend_list_title{
width: 100%;
height: 5vh;
border: 1px solid red;
}
.friend_list{
width: 100%;
height: 20vh;
border: 1px solid red;
}
.friend_div{
width: 100%;
height: 25vh;
border: 1px solid black;
}
.sys_info_div{
width: 100%;
height: 30vh;
border: 1px solid black;
}
.sys_info_title{
width: 100%;
height: 5vh;
border: 1px solid red;
}
.sys_info{
width: 100%;
height: 25vh;
border: 1px solid red;
}
.message_div{
width: 100%;
height: 40vh;
border: 1px solid red;
}
.input_div{
width: 100%;
height: 15vh;
border: 1px solid red;
}
</style>
去掉边线进行填充后
此时前端样式除了消息部分已经完成,剩下去调试后端
后端部分
设计功能:
- 登录
- 显示在线的用户列表
- 实时聊天
- 上线提醒、下线提醒
设计用户姓名要唯一
流程图
消息类型
用户之间发送的消息、用户上线通知消息、用户下线通知消息、好友列表消息、系统通知(一些错误等)
消息体
发送发、接收方、消息类型、消息内容
思路总结:
- 怎样确保安全性?原本计划向http请求那样每次请求携带请求头在拦截器中进项校验,经过测试发现,http请求时因为每一次都是不同的连接,才需要每次进行校验。ws可以在创建连接时只校验一次。在登录时返回一个token,服务端使用一个map保存一个着token记录。在ws创建连接时携带此token,服务端从map中进行查找,根据是否找到判断此连接是否合法。
- 消息的发送方怎么确定?在发送消息时,在请求体中设置发送发 (可能不咋安全)
- 怎样将用户连接放入session的map中?在校验ws的token通过后,将查找到的用户姓名放入LocalThrad中,在放入sessionMap时,从LocalThread取出用户姓名作为key,值为此连接的session
- 初始化进行怎样的操作?在初始化时将连接放入session的map中,然后将朋友列表通过此session进行返回,因为此次代码中是使用name作为key的,所以只需要遍历session的map所有的key即可。然后向所有用户发送系统消息,该用户已经上线
- 怎样区分不同类型的消息?设置消息格式能将不同类型的消息进行区分,将系统消息,好友列表、好友消息采用统一格式进行发送。
- 在线朋友列表怎样更新?初始连接时由后端向此连接发送当前所有在线用户,后续由系统提示上下线时,由前端进行更新
- 出现异常和断开连接怎样处理?封装一个通知其他session,此用户已经下线的方法,在此方法中根据参数列表中的session,去sessionMap中根据值去查找key,将key作为消息,同时将消息类型设置为用户下线向其他session中发送。前端接收到用户下线类型的消息,进行提示,同时在 在线朋友列表中去除此name。
代码注释
后端核心代码:(由于篇幅太长,这里只做注释,详细代码已经上传到gitee中,可以自行访问查看)
到这里,后端所有功能都已经基本完成,只差前端接收不同类型的消息,并将其赋值后进行渲染
此处控制台测试发送消息、接收消息均已成功,接下来就是本次项目最让人头疼的消息显示
采用循环数组的方式,这种方式会和平常聊天习惯存在点差异,这个版本就不去改了
此外在线人员列表也采用同种方式
在线聊天1.0 版本到此完成
源码
完整源码已经上传到gitee中 请点击访问 https://gitee.com/wang-yongyan188/websocketchat.git
提示:因为ws创建请求的token存储在了localStorage中,所以同一个浏览器即使的登录不同的账号也是同一个用户,所以要使用不同的浏览器去测试。如果只测试后端,更推荐使用postman去测试
文章来源:https://www.toymoban.com/news/detail-758338.html
所有代码皆为原创,博客和代码共用了两天时间,任何错误欢迎提示,如果觉得还可以,就请点个赞吧。
此项目的是理解ws协议,并能够使用springboot实现两种协议的混合开发。
代码页面比较简陋、功能也不完善(注册需要手动去数据库添加,只能和在线用户聊天等),如果喜欢,后续会推出比如:添加好友、消息未读提示、群聊、消息保存等,进一步完善成为一个小项目。文章来源地址https://www.toymoban.com/news/detail-758338.html
到了这里,关于WebSocket协议理解并实现在线聊天的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!