Springboot+websocket实现服务器消息推送

Springboot + webSocket实现服务器消息推送

WebSocket协议

为什么需要WebSocket协议

因为HTTP协议有一个缺陷:通信只能由客户端发起。

这种单向请求的特点,注定了服务器如果有连续的变化,客户端想要获知就非常麻烦。我们只能用轮询的方式来了解服务器有没有最新的消息。

websocket协议简介

WebSocket协议最大特点就是服务器可以主动向客户端推送消息,客户端也可以主动向服务器发送消息。是服务器推送技术的一种。

lCcxUK.png

对比HTTP协议,WeSocket协议使得长连接变成了一个真正的长连接。通过第一个HTTP request建立了TCP连接之后,之后的交换数据都不需要再发HTTP request。并且和HTTP-keep-alive的区别在于WebSocket协议省去了大量的HTTP-Header信息,使得信息的传输更加的高效。

和传统的服务器推送技术对比

http long poll和ajax轮询都可以实现服务器推送。首先介绍一下long poll和ajax轮询。

ajax轮询的原理非常简单,让浏览器隔几秒就发送一次请求,询问服务器是否有新信息。

long poll原理也是采用轮询的方式,不过和ajax轮询的区别在于采取的是阻塞模型,即客户端发起连接之后,如果没有消息,就一直不返回Response。

上述两种方式都是非常消耗资源的。

  • ajax轮询 需要服务器有很快的处理速度和资源。(速度)
  • long poll 需要有很高的并发,也就是说同时接待客户的能力。

而web Socket经过一次HTTP请求进行协议升级之后,就可以做到源源不断的信息传送。

WebSocket建立连接

WebSocket协议服用了Http的握手通道,具体指客户端通过HTTP请求与WebSocket服务端协商升级协议。

  1. 客户端:申请协议升级
    1
    2
    3
    4
    5
    6
    7
    GET / HTTP/1.1
    Host: localhost:8080
    Origin: http://127.0.0.1:3000
    Connection: Upgrade
    Upgrade: websocket
    Sec-WebSocket-Version: 13
    Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==

    重点请求头部意义如下:

    • Connection: Upgrade:表示升级协议
    • Upgrade:websocket:表示要升级到websocket协议
    • Sec-WebSocket-Version:表示websocket协议的版本,如果服务端不支持该版本,返回一个Sec-WebSocket-VersionHeader,包含服务端支持的版本号
    • Sec-WebSocket-Key:和后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供最基本的安全保障
  2. 服务端:响应升级协议
    1
    2
    3
    4
    5
    6
    Request URL: ws://localhost:8080/demo/notification/993/r2b4sth6/websocket
    Request Method: GET
    Status Code: 101
    Connection:Upgrade
    Upgrade: websocket
    Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=

    服务器返回内容如下,状态码101表示协议切换,到此完成协议升级。

  3. Sec-WebSocket-Accept的计算

    Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来。

    计算公式为:

    1. Sec-WebSocket-Key258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
    2. 通过SHA1计算出摘要,并转成base64字符串。

数据帧格式

略…

数据传递

略…

连接保持+心跳

WebSocket为了保持客户端,服务端的实时双向通信,需要确保客户端,服务端之间的TCP信道保持连接。

如果客户端和服务端长时间没有数据往来,但是需要保持连接,可以通过心跳来实现。

  • 发送方->接收方:ping
  • 接收方->发送方:pong

ping、pong的操作,对应的是WebSocket的两个控制帧,opcode分别是0x90xA

SpringBoot+WebSocket+sockjs+stompjs实现服务器推送例子

websocket,sockjs,stompjs三者关系

websocket:是底层协议,基于TCP,可以看作对http协议的一种补充

sockjs: sockjs是一个javascript库,为了应对许多浏览器不支持websocket协议的问题,设计备选了Sockjs。如果websocket不可用,自动降为轮询的方式。

stompjs: Simple Text Oriented Message Protocol,来为浏览器和server的通信增加适当的语义。

三者关系:websocket是底层协议,sockjs是websocket的备选方案,也是底层协议,而stompjs是基于websocket(sockjs)的上层协议。

  1. HTTP协议解决了web浏览器发起请求以及web服务器响应请求的细节,假设HTTP协议不存在,只能用TCP套接字来编写web应用。
  2. 直接使用 WebSocket(SockJS) 就很类似于 使用 TCP 套接字来编写 web 应用,因为没有高层协议,就需要我们定义应用间所发送消息的语义,还需要确保连接的两端都能遵循这些语义。
  3. 同HTTP在TCP 套接字上添加请求-响应模型层一样,STOMP在WebSocket 之上提供了一个基于帧的线路格式层,用来定义消息语义;

配置websocket服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.shine.websocket.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocket
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {


/**
* 注册一个/notification端点,前端通过它来连接
* @param registry 注册
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/notification")
//解决跨域问题
.setAllowedOrigins("*")
.withSockJS();
}

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
//定义了一个客户端订阅地址的前缀,也就是客户端接收服务端发送消息的前缀信息
registry.enableSimpleBroker("/topic");
}
}

配置websocket常量

1
2
3
4
5
6
7
8
package com.shine.websocket.common;

/**
* webSocket常量
*/
public interface WebSocketConsts {
String PUSH_SERVER = "/topic/server";
}

定义服务器定时推送任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.shine.websocket.task;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.lang.Dict;
import cn.hutool.json.JSONUtil;
import cn.hutool.log.Log;
import cn.hutool.log.dialect.log4j2.Log4j2Log;
import com.shine.websocket.common.WebSocketConsts;
import com.shine.websocket.model.Server;
import com.shine.websocket.payload.ServerVO;
import com.shine.websocket.util.ServerUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.Date;

/**
* 服务器定时推送任务
*/
@Component
public class ServerTask {
private static Log log = new Log4j2Log(ServerTask.class);

@Autowired
private SimpMessagingTemplate wsTemplate;

/**
* 按照标准时间,每隔4s执行一次
*/
@Scheduled(cron = "0/4 * * * * ?")
public void webSocket() throws Exception{
log.info("【推送消息】开始执行:{}", DateUtil.formatDateTime(new Date()));
//获取系统信息
Server server = new Server();
server.copyTo();
ServerVO serverVO = ServerUtil.wrapServerVO(server);
Dict dict = ServerUtil.wrapServerDict(serverVO);
wsTemplate.convertAndSend(WebSocketConsts.PUSH_SERVER, JSONUtil.toJsonStr(dict));
log.info("【推送消息】执行结束:{}", DateUtil.formatDateTime(new Date()));
}
}

前端订阅websocket消息推送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<script>
const wsHost = "http://localhost:8080/demo/notification";
const wsTopic = "/topic/server";

const app = new Vue({
el: '#app',
data: function () {
return {
isConnected: false,
stompClient: {},
socket: {},
server: {
cpu: [],
mem: [],
jvm: [],
sys: [],
sysFile: []
}
}
},
methods: {
_getServerInfo() {
axios.get('/demo/server')
.then((response) => {
this.server = response.data
});
},
_initSockJs() {
this._getServerInfo();
this.socket = new SockJS(wsHost);
this.stompClient = Stomp.over(this.socket);

this.stompClient.connect({}, (frame) => {
console.log('websocket连接成功:' + frame);
this.isConnected = true;
this.$message('websocket服务器连接成功');

// 另外再注册一下消息推送
this.stompClient.subscribe(wsTopic, (response) => {
this.server = JSON.parse(response.body);
});
});
},
_destroySockJs() {
if (this.stompClient != null) {
this.stompClient.disconnect();
this.socket.onclose;
this.socket.close();
this.stompClient = {};
this.socket = {};
this.isConnected = false;
this.server.cpu = [];
this.server.mem = [];
this.server.jvm = [];
this.server.sys = [];
this.server.sysFile = [];
}
console.log('websocket断开成功!');
this.$message.error('websocket断开成功!');
}
},
mounted() {
this._initSockJs();
},
beforeDestroy() {
this._destroySockJs();
}

})
</script>

参考文章

WebSocket 教程

WebSocket 是什么原理?为什么可以实现持久连接?

深入浅出Websocket(一)Websocket协议

WebSocket协议:五分钟从入门到精通

websocket+sockjs+stompjs详解及实例