前言
主管让我做一个实时推送的需求,我直接汗如雨下,赶快到网上搜索相关内容。
实时推送?那最基本的就是websocket啦!
本文系我个人看了十余篇博客整理而成,属于最基本的websocket讲解,适合没学过websocket的Javaer阅读。
什么是websocket,为什么使用webSocket
WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。
在某些业务场景,我们需要页面对于后台的操作进行实时的刷新,这时候就需要使用websocket。
如消息通知,直播,弹幕,位置分享等实时推送的情况。
WebSocket 的特点
- 建立在 TCP 协议之上;
- 与 HTTP 协议有着良好的兼容性:默认端口也是 80(ws) 和 443(wss,运行在 TLS 之上),并且握手阶段采用 HTTP 协议;
- 较少的控制开销:连接创建后,ws 客户端、服务端进行数据交换时,协议控制的数据包头部较小,而 HTTP 协议每次通信都需要携带完整的头部;
- 可以发送文本,也可以发送二进制数据;
- 没有同源限制,客户端可以与任意服务器通信;
- 协议标识符是 ws(如果加密,则为 wss),服务器网址就是 URL;
- 支持扩展:ws 协议定义了扩展,用户可以扩展协议,或者实现自定义的子协议(比如支持自定义压缩算法等);
WebSocket 与 HTTP、TCP
HTTP、WebSocket 等协议都是处于 OSI 模型的最高层:应用层。而 IP 协议工作在网络层,TCP 协议工作在传输层。
HTTP、WebSocket 等应用层协议,都是基于 TCP 协议来传输数据的,因此其连接和断开,都要遵循 TCP 协议中的三次握手和四次挥手 ,只是在连接之后发送的内容不同,或者是断开的时间不同。
为啥不用轮询?
在 WebSocket 出现之前,如果我们想实现实时通信,比较常采用的方式是 Ajax 轮询,即在特定时间间隔(比如每秒)由浏览器发出请求,服务器返回最新的数据。这样子的轮询方式有什么缺陷呢?
- HTTP 请求一般包含的
头部信息比较多,其中有效的数据可能只占很小的一部分,导致带宽浪费; - 服务器被动接收浏览器的请求然后响应,数据没有更新时仍然要接收并处理请求,导致
服务器 CPU 占用;
WebSocket 的出现可以对应解决上述问题:
- WebSocket 的头部信息少,通常只有 2Bytes 左右,能节省带宽;
- WebSocket 支持服务端主动推送消息,更好地支持实时通信;
WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。
如何使用webSocket
websocket有两种方式:编程式;注解式。
本文会采用注解式,其他可参考文章www.zifangsky.cn/1355.html
简单描述
对于前端而言,websocket使用大体如下
<script>
let ws = new WebSocket("ws://localhost/chat")
//建立连接时使用
ws.onopen = function(){
};
ws.onmessage = function(evt){
//通过evt.data 可以获取到服务器发送的数据
};
//关闭时使用
ws.onclose = function(){};
</script>
对于javaer而言,大体使用如下
@ServerEndpoint("/chat")
@Component
public class Endpoint{
@OnOpen
public void onOpen(Session session){
}
@OnMessage
public void onMessage(String message){
}
@OnMesssage
public void onclose(){
}
}
是不是巨简单!
Javaer搭建
引入依赖
springboot包含了了相关集成
先引入父依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.11</version>
</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>
</dependency>
代码原型
有了依赖后,则开始拓展后端代码
添加Endpoint
使用 @ServerEndpoint 注解,将此类标为Endpoint,
什么是Endpoint?相当于将此类作为服务端的一端,value是表示当前和前端对应的是哪一个路径
使用@Component将此类交于IOC管理。
@ServerEndpoint("/chat")
@Component
public class Endpoint{
@OnOpen
public void onOpen(Session session){
//连接时,会调用此方法
}
@OnMessage
public void onMessage(String message){
//发送消息时,会调用此方法
}
@OnMesssage
public void onclose(){
//关闭时,会调用此方法
}
}
编写配置类
此配置类的作用是:扫描包含@ServerEndPoint注解的类
/**
* 开启WebSocket支持
*/
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
拓展
拓展onOpen方法(无非是做一些初始化操作,以及连接时的处理)。
所以我们目前要做的是,
- 添加一个map,用来存储websocketSession的对应关系。将session和对应的客户端进行绑定,这样的在多个客户端发起请求时,我们就知道是哪一个客户端进行处理。
- 添加一个字段标识当前连接数。
private static final ConcurrentHashMap<String, Session> manager = new ConcurrentHashMap<String, Session>();//key是客户端唯一标识;value是session
private static final Integer onlineCount = 0;
唯一标识(可不用添加)
那么key可以是什么?可以是用户的token或者用户名等。
那么如果是token,该如何获取?
可以新建一个配置类,并且在ServerEndpoint注解中引入对应的配置类
配置类如下
/**
* @description: 获取session信息
* @author: hjw
* @create: 2023-11-19 16:53
**/
public class GetHttpSessionConfigurator extends ServerEndpointConfig.Configurator{
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
HttpSession httpSession = (HttpSession)request.getHttpSession();
//在握手时把httpSession放入配置中,当onOpen时,即可调用放进去的信息
sec.getUserProperties().put(HttpSession.class.getName(), httpSession);
super.modifyHandshake(sec, request, response);
}
}
所以可以将建立连接和断开连接时进行处理
其中@ServerEndpoint注解上要加上指定的配置类,这样此Endpoint在建立连接时就会调用指定的配置类
@ServerEndpoint(value = "/chat", configurator = GetHttpSessionConfigurator.class)
@Component
public class Endpoint{
private static ConcurrentHashMap<String, Session> manager = new ConcurrentHashMap<String, Session>();
private HttpSession httpSession;
private static final Integer onlineCount = 0;
@OnOpen
public void onOpen(Session session, EndpointConfig config){
//连接时,会调用此方法
this.httpSession =(HttpSession) config.getUserProperties().get(HttpSession.class.getName());
String token = (String) this.httpSession.getAttribute("token");
manager.put(token, session);
}
@OnMessage
public void onMessage(String message){
//发送消息时,会调用此方法
}
@OnClose
public void onclose(){
//关闭时,会调用此方法
String token = (String)this.httpSession.getAttribute("token");
manager.remove(token);
}
}
添加发送逻辑
可以在此类中加入一些处理逻辑,比如发给谁,单发还是群发。
**/****
* @description: 聊天webSocket
* @author: hjw
* @create: 2023-11-19 16:51
**/
@ServerEndpoint(value = "/chat", configurator = GetHttpSessionConfigurator.class)
@Component
public class Endpoint {
private static ConcurrentHashMap<String, Session> manager = new ConcurrentHashMap<String, Session>();
private static Integer onlineCount;
private HttpSession httpSession;
@OnOpen
public void onOpen(Session session, EndpointConfig config) {
//连接时,会调用此方法
this.httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
String token = (String) this.httpSession.getAttribute("token");
manager.put(token, session);
addOnlineCount();
}
@OnClose
public void onclose() {
//关闭时,会调用此方法
String token = (String) this.httpSession.getAttribute("token");
manager.remove(token);
subOnlineCount();
}
/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息*/
@OnMessage
public void onMessage(String message, Session session) throws IOException {
//群发消息
for (Session value : manager.values()) {
session.getBasicRemote().sendText("你好呀");
}
}
/**
*
* @param session
* @param error
*/
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
subOnlineCount();
}
/**
* 实现服务器主动推送
*/
public static void sendMessage(Session session, String message) throws IOException {
session.getBasicRemote().sendText(message);
}
/**
* 群发自定义消息
* */
public static void sendInfo(String message,@PathParam("token") String token) throws IOException {
manager.forEach((k,v) ->{
try {
//这里可以设定只推送给这个sid的,为null则全部推送
if(token==null) {
sendMessage(v, message);
}else if(k.equals(token)){
sendMessage(v, message);
}
} catch (IOException e) {
e.getStackTrace();
}
});
}
public static synchronized int getOnlineCount() {
return onlineCount;
}
public static synchronized void addOnlineCount() {
endpoint.onlineCount++;
}
public static synchronized void subOnlineCount() {
endpoint.onlineCount--;
}
}
测试
由于没有做登录页面
所以需要将上述的token获取都注释掉,使用静态的key
//注释掉获取token
@OnOpen
public void onOpen(Session session, EndpointConfig config) {
//连接时,会调用此方法
//this.httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
// String token = (String) this.httpSession.getAttribute("token");
manager.put("token", session);
addOnlineCount();
}
//注释掉获取token
public class GetHttpSessionConfigurator extends ServerEndpointConfig.Configurator{
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
// HttpSession httpSession = (HttpSession)request.getHttpSession();
// //在握手时把httpSession放入配置中,当onOpen时,即可调用放进去的信息
// sec.getUserProperties().put("token", httpSession);
super.modifyHandshake(sec, request, response);
}
}
测试的前端页面采用在线测试网站websocket.jsonin.com/
测试方式
- 填写Endpoint路径
- 点击连接,右方提示已连接则连接成功
- 点击发送,测试onMessage
业务代码测试
测试的controller代码
@RestController
@RequestMapping("/test")
public class TestController {
@Autowired
private Endpoint endpoint;
@RequestMapping(value = "/sendUser",method = RequestMethod.POST)
public void sendUser(String token) throws IOException {
//业务代码。。。 用token获取到session,然后发送消息
Session session = Endpoint.manager.get("token");
endpoint.sendMessage(session, "业务执行完毕,我要推送一些东西给你");
}
//使用测试
}
使用postman等工具,向testController发起请求
可以在测试网站上,看到testController发起请求后,推送的内容。
Session共享问题
如果项目是分布式环境,登录的用户被Nginx的反向代理分配到多个不同服务器,那么在其中一个服务器建立了WebSocket连接的用户如何给在另外一个服务器上建立了WebSocket连接的用户发送消息呢?
使用redis替代map来存储session
可参考文章:juejin.cn/post/684490…
结束!
参考: