websocket是什么?
一般做系统开发前后端交互使用最多的就是http协议,但http协议是无状态协议每一次前端发起的请求都认为是一次单独的请求和之前的请求无任何关系,所以我们需要http协议分别用户信息时,就需要使用cookie、session或者现在常用的token等让后端自己实现用户识别等,如果前后端交互非常频繁每次携带公有信息会占用更多的服务器资源,好处是协议本身不需要做信息保存相对可以减少资源开销;其次前端每请求一次后端返回一次信息且只能由客户端发起请求,没办法后端主动推送数据给前端,数据应答之后http链接结束断开,那么想要及时感知后端数据的变化一般做法是前端定时轮询,后端不管数据是否变化都应答请求,但会出现数据更新到前端延迟的问题并占用服务器资源,另外一种做法是长轮询也就是客户端发起http请求之后服务端收到请求后如果数据发生变化则立即应答数据否则先在服务端保留下这个请求的引用当数据发生变化时或超过hold时间再应答数据(一般服务端持有请求引用的超时(hold)时间要小于http请求的超时时间,否则每次无变化就timeout不是很合理)长轮询同样有数据更新延迟的问题只是比前端轮询延迟相对来说较小,同样占用服务器资源(虽然服务器只是保存的请求的引用,然后线程就可以处理其他请求了,但是也需要分配一个或多个线程来处理这些hold住的请求),当长轮询的请求应答或超时之后本次请求建立的http链接结束断开;其次http是半双工协议(即同一时刻只能由建立链接的一方传输数据,只有一方传输完毕,则另一方才能传输数据)这就意味着数据传输不会很及时,虽然这个延迟是没什么影响的,但如果对数据更新要求非常高,半双工协议就会出现延迟的影响;而基于TCP协议实现的全双工(即同一时刻建立链接的双方都可以发送数据和接收数据)websocket只要客户端和服务端建立连接之后,不主动断开的情况下,链接一直存在双方可以随时发送数据,解决了http协议中服务端无法主动推送数据的问题,或推送不及时的问题。
websocket主要有以下特点:
1、建立在 TCP 协议之上,服务器端实现比较容易
2、与 HTTP 协议有着良好的兼容性。默认端口也是80(ws)和443(wss),并且握手阶段用 HTTP 协议(第一次握手依赖于http,后续数据传输则是通过tcp实现),因此握手时不容易屏蔽,能通过各种HTTP 代理服务器
3、数据格式比较轻量(相对于http协议来说请求头更小),性能开销小,通信高效
4、可以发送文本,也可以发送二进制数据
5、没有同源限制,客户端可以与任意服务器通信
6、客户端服务器建立连接之后会一直保持连接,而http请求一次响应一次就结束连接
websocket协议建立连接请求及应答
springboot整合websocket服务端
1、引入依赖jar包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<!-- 版本可不写,因为springboot 项目 pom一般指定了 父pom为spring-boot-starter-parent其中有指定spring-boot-starter-websocket的版本-->
<version>2.3.5.RELEASE</version>
</dependency>
<!--下面的两个jar包不是websocket需要的仅仅只是为了使用其中的api方便处理业务导入的-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.12</version>
</dependency>
2、定义 WebSocketConfig
package com.test.websocket.websockettest.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
/**
* 如果是springboot项目且直接使用springboot的内置容器,才需要ServerEndpointExporter
* 注入ServerEndpointExporter,作用是ServerEndpointExporter的bean会自动注册使用@ServerEndpoint注解声明的Websocket Endpoint
* 否则项目可以正常启动但是无法连接上@ServerEndpoint注解定义的websocket地址的服务端
*/
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
}
3、定义接收客户端消息参数类
package com.test.websocket.websockettest.dto;
import lombok.Data;
/**
* 接收客户端消息参数类
*/
@Data
public class WebSocketMessageDTO {
private Integer userId;
private String message;
}
4、定义服务端类 WebSocketServer
package com.test.websocket.websockettest.service;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.json.JSONUtil;
import com.test.websocket.websockettest.dto.WebSocketMessageDTO;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @ServerEndpoint 指定当前类为websocket服务器端并指定 spring boot 暴露的 ws 接口路径,和@RequestMapping作用类似,但不是http的接口而是websocket的接口
* 比如当前类指定的websocket服务端地址是 ws://主机地址:服务端口/websocket/test/{userId} 其中{userId}要替换成当前发起连接的客户端的标记
* 如 ws://127.0.0.1:8080/websocket/test/1 主机地址:服务端口 相关信息有域名映射也可以使用域名 客户端即使用该地址连接websocket服务端
* @Component 默认是单例模式,但springboot会为每个websocket连接初始化一个当前类的 bean,所以如果要在当前类中定义变量保存所有的客户端连接就需要静态的变量,
* 比如这里的concurrentHashMap变量,否则每次建立新的连接实例化新的WebSocketServer 的bean,里面的concurrentHashMap都是全新的,没办法访问到其他的客户端
*/
@Log4j2
@Component
@ServerEndpoint("/websocket/test/{userId}")
public class WebSocketServer {
/**
* 当前客户端与服务端的websocket连接
*/
private Session session;
/**
* 保存客户端与服务端的每个连接,当需要给某个或某些客户端推送消息时取出对应的session进行发送信息给客户端,不保存所有客户端的连接则
* 只能实现当前客户端与服务端的通信,不能将某条消息推送到其他客户端,注意通过获取ConcurrentHashMap的size获得客户端连接数量数据可能是不准确的
* 因为ConcurrentHashMap只保证了写的线程安全,在写入元素后增加size的时候并没有在sync里面,而且size的统计是基于baseCount和数组每个元素值的累加
* 设计的目的是为了提升修改size的并发性,因为修改size时是通过cas操作修改,那么当并发量很高时可能出现在增加size时比较耗时,所以设计为baseCount和数组
* 的形式,并发量不高时就累加baseCount,并发高时再使用一个数组累加每个数组元素值,比如数组长度为2,那么此时进行cas增加size时就有三个锁可以抢占累加值,
* 那么最终的size也就是由这三个值的和决定的,所以在获取size的时候可能出现put了元素,但是size还未增加上,所以获取的size可能是不准确的
*/
public static ConcurrentHashMap<Integer,WebSocketServer> concurrentHashMap = new ConcurrentHashMap();
/**
* 统计当前连接的客户端数量
*/
private static AtomicInteger onlineCount=new AtomicInteger(0);
/**
* 接收客户端websocket连接 建立连接成功后会触发@OnOpen注解修饰的方法
* @param session 当前客户端和服务端的连接
* @param userId 当前客户端标记,获取websocket接口路径中的参数只能用 @PathParam 注解获取,而不是 @PathVariable 注解或者不添加注解都会报错
* @PathParam 注解的value就是路径中的形参名称
*/
@OnOpen
public void onOpen(Session session,@PathParam("userId") Integer userId){
this.session = session;
concurrentHashMap.put(userId,this);
log.info("有新的客户端连接了:userId:"+userId+" 当前客户端连接总数:"+onlineCount.incrementAndGet());
}
/**
* 关闭客户端websocket连接 建立的连接断开后会触发@OnClose注解修饰的方法
* @param userId
* @param session
*/
@OnClose
public void onClose(Session session, @PathParam("userId") Integer userId){
try {
concurrentHashMap.remove(userId);
session.close();
log.info("有客户端关闭了连接:userId:"+userId+" 当前客户端连接总数:"+onlineCount.decrementAndGet());
} catch (IOException e) {
log.error("关闭客户端连接失败:"+e.getMessage());
e.printStackTrace();
}
}
/**
* 客户端和服务端建立连接或通信时出现异常会触发@OnError注解修饰的方法
* @param session
* @param error 具体错误信息的封装
* @param userId
*/
@OnError
public void onError(Session session,Throwable error,@PathParam("userId") Integer userId){
log.error("websocket发生错误,userId:"+userId+" 错误信息:"+error.getMessage());
error.printStackTrace();
}
/**
* 接收客户端通过websocket发送的消息 客户端发送消息到服务端时,会触发@OnMessage注解修饰的方法
* @param session
* @param message 客户端发送给服务端的消息
* @param userId
*/
@OnMessage
public void onMessage(Session session,String message,@PathParam("userId")Integer userId){
log.info("收到客户端发送的消息!"+message+" 客户端userId:"+userId);
//服务端收到客户端的消息之后,后续逻辑按照业务进行处理,我这里是回复客户端消息并将消息转发给目标客户端
//这种方式可以作为websocket实现多个客户端之间通信的方式,如果不需要通过服务端转发消息给目标客户端下面的方法调用可以删除
try {
session.getBasicRemote().sendText("我是服务端已收到你发送的消息,现在将你的消息转发给目标客户端!");
} catch (IOException e) {
e.printStackTrace();
}
WebSocketMessageDTO webSocketMessageDTO = JSONUtil.toBean(JSONUtil.toJsonStr(message), WebSocketMessageDTO.class);
log.info("转换后的客户端消息:"+webSocketMessageDTO.toString());
sendMessage(webSocketMessageDTO.getMessage(),webSocketMessageDTO.getUserId());
}
/**
* 通过服务端保存的websocket给客户端发送消息
* @param message 服务端要发送给客户端的消息
* @param userId 接收服务端推送消息的客户端标记即给哪个客户端推送消息
*/
public void sendMessage(String message,Integer userId){
if (userId == null || StringUtils.isEmpty(message)){
log.info("userId或消息为空!");
return;
}
if (CollUtil.isEmpty(concurrentHashMap)){
log.info("没有任何可发送消息的客户端!");
return;
}
if (userId.equals(0)){
//如果userId是0的话我们可以当作是给所有客户端发送该消息,可以基于自己的逻辑定义
log.info("全部客户端推送当前消息:"+message);
concurrentHashMap.forEach((userIdTemp,webSocketServerTemp)->{
sendMessageSession(webSocketServerTemp,message,userIdTemp);
});
}else {
//userId不是0则给指定某个客户端单独发送消息
log.info("指定客户端推送当前消息:"+message);
WebSocketServer webSocketServer = concurrentHashMap.get(userId);
sendMessageSession(webSocketServer,message,userId);
}
}
/**
* 给某个websocket客户端发送消息
* @param webSocketServer 某个客户端和服务端连接的WebSocketServer信息
* @param message 服务端要发送客户端的消息
*/
private void sendMessageSession(WebSocketServer webSocketServer,String message,Integer userId){
if (webSocketServer != null && webSocketServer.session.isOpen()){
try {
webSocketServer.session.getBasicRemote().sendText(message);
log.info("消息发送完毕,客户端userId:"+userId+" 消息内容:"+message);
} catch (IOException e) {
log.error("发送客戶端websocket消息失败,userId:"+userId+" 错误信息:"+e.getMessage());
e.printStackTrace();
}
}else {
log.error("客户端已断开不可发送消息,客户端userId:" + userId);
}
}
}
到此websocket服务端的定义就已经完成了,下面写的是模拟数据变化时给客户端发送消息的接口,和websocket本身没关系
package com.test.websocket.websockettest.controller;
import cn.hutool.core.collection.CollUtil;
import com.test.websocket.websockettest.service.WebSocketServer;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/websocket")
public class TestWebSocketController {
/**
* 通过调用该接口模仿服务端数据产生变化主动将消息推送给目标客户端
* 实际上数据的变化可能来源于redis或mq里面的数据变化消费等,导致业务上做一定处理需要给客户端通知消息
* @param userId 目标客户端标识
* @param message 消息内容
*/
@GetMapping("/send/{userId}/{message}")
public String sendMessage(@PathVariable("userId") Integer userId,@PathVariable("message") String message){
if (CollUtil.isNotEmpty(WebSocketServer.concurrentHashMap)){
WebSocketServer.concurrentHashMap.get(WebSocketServer.concurrentHashMap.keys().nextElement()).sendMessage(message,userId);
return "消息发送完毕!";
}else {
return "没有客户端可发送!";
}
}
}
测试功能:
可以使用该链接进行测试,这样就不用单独再写客户端 websocket客户端调试地址 只要服务端可以访问该链接就可以使用,比如本地或部署在云服务器上的服务端。
两个客户端发送消息时,是json格式且带了userId和message而不是直接的消息是因为需要指定将当前客户端发送的消息转发给目标客户端,所以userId是目标客户端的标记,message则是要发送给目标客户端的消息,就像微信私聊一样,发送的消息得有一个接受消息的好友,只是这块添加好友标记的逻辑即这里的userId可以交由代码自动实现,我这里因为测试网站没有该功能,只能输入userId来模拟相关数据,userId为0则是将消息发送给所有客户端,相当于将消息发送给所有微信好友,如果只是当前客户端和服务端的通信,那么只需要发送消息即可不需要指定userId,当然服务端的 @OnMessage 方法只需要回复客户端即可,不需要做转发操作需要做相关逻辑调整。
客户端1
客户端2
模拟服务端数据变化主动推送消息给所有客户端
服务端日志
错误解决
1、项目启动时报错如下:
原因是websocket相关类导入的包错了,正确应该是 javax.websocket 包下面的类,我是错误的导入了 jakarta.websocket 包下面的类,具体原因是因为项目指定的 spring-boot-starter-parent 的版本有问题,可以在 https://mvnrepository.com 网页中查看spring-boot-starter-parent有哪些版本,一般建议采用使用量最多的RELEASE版本的包,因为release版的包是稳定发行版本的包,其他版本的包可能是不稳定存在一些问题的包,有问题时导入的是 3.0.3 版本,当编写websocket服务端时并没有 javax.websocket 这个包只有 jakarta.websocket 包所以报错,将 spring-boot-starter-parent 的版本换成 2.3.5.RELEASE 之后重启项目即解决。
2、项目启动报错 @PathParam
意思是在被websocket连接和消息相关注解标记的方法中,形参除了固定的参数比如 Session、Throwable、message等之外的形参,都需要使用 @PathParam 注解标记,使用其他注解标记或不写注解标记都会报错如上,@PathParam 注解的 value 值就是 @ServerEndpoint 注解定义的地址中的参数名称。