服务器推送技术
1. 主流服务器推送技术说明
⼀、需求与背景
若⼲年前,所有的请求都是由浏览器端发起,浏览器本身并没有接受请求的能⼒。所以⼀些特殊需求都是⽤ ajax 轮询的⽅式来实现的。⽐如:
- 股价展示页面实时的获取股价更新
- 赛事的文字直播,实时更新赛况
- 通过页面启动一个任务,前媏想知道任务后台的实时运行状态
通常的做法就是需要以较⼩的间隔,频繁的向服务器建⽴ http 连接询问任务状态的更新,然后刷新⻚⾯显示状态。但这样做的后果就是浪费⼤量流量,对服务端造成了⾮常⼤的压⼒。
⼆、服务端推送常⽤技术
在 html5 被⼴泛推⼴之后,我们可以使⽤服务端主动推送数据,浏览器接收数据的⽅式来解决上⾯提到的问题。下⾯我们就为⼤家介绍两种服务端数据推送技术。
全双⼯通信:WebSocket
全双⼯就是双向通信。如果说 http 协议是“对讲机”之间的通话(你⼀句我⼀句,有来有回),那 websocket 就是移动电话(可以随时发送信息与接收信息,就是全双⼯)。
本质上是⼀个额外的 tcp 连接,建⽴和关闭时握⼿使⽤ http 协议,其他数据传输不使⽤ http 协议 ,更加复杂⼀些,⽐较适⽤于需要进⾏复杂双向实时数据通讯的场景。在 web ⽹⻚上⾯的客服、聊天室⼀般都是使⽤ WebSocket 协议来开发的。
服务端主动推送:SSE (Server Send Event)
html5 新标准,⽤来从服务端实时推送数据到浏览器端, 直接建⽴在当前 http 连接上,本质上是保持⼀个 http ⻓连接,轻量协议 。客户端发送⼀个请求到服务端 ,服务端保持这个请求连接直到⼀个新的消息准备好,将消息返回⾄客户端。除⾮主动关闭,这个连接会⼀直保持。
- 建⽴连接
- 服务端 -> 浏览器(连接保持)
- 关闭连接
SSE 的⼀⼤特⾊就是重复利⽤ 1 个连接来接收服务端发送的消息(⼜称 event),从⽽避免不断轮询请求建⽴连接,造成服务资源紧张。
三、websocket 与 SSE ⽐较
1 | 是否基于新协议 | 是否双向通信 | 是否支持跨域 |
---|---|---|---|
SSE | 否(Http) | 否(单向) | 否(Firefox ⽀持跨域) |
WebSocket | 是(ws) | 是 | 是 |
但是 IE 和 Edge 浏览器不⽀持 SSE,所以 SSE ⽬前的应⽤场景⽐较少。 虽然
websocket 在很多⽐较旧的版本浏览器上⾯也不兼容,但是总体上⽐ SSE 要好不少。另外还有⼀些开源的 JS 前端产品,如 SockJS, Socket.IO,在浏览器
端提供了更好的 websocket 前端 js 编程体验,与浏览器有更好的兼容性。
2. 服务端推送事件 SSE
⼀、模拟⽹络⽀付场景
⼤家应该都⽤过⽀付系统,⽐如淘宝买⼀个产品之后进⾏扫码⽀付。如果结
合 SSE,该如何实现这个过 程?
⽤户扫码向⽀付系统(⽀付宝)进⾏⽀付
⽀付完成之后,告知商户系统(淘宝卖家系统)我已经发起⽀付了(建⽴ SSE 连接)
⽀付系统(⽀付宝)告诉商户系统(淘宝卖家系统),这个⽤户确实⽀付成功了
商户系统(淘宝卖家系统)向⽤户发送消息:你已经⽀付成功,跳转到⽀付成功⻚⾯。(通过 SSE 连接,由服务器端告知⽤户客户端浏览器)
注意:在返回最终⽀付结果的操作,实现了服务端向客户端的事件推送,以
使⽤ SSE 来实现
⼆、SSE 起步练习
package top.syhan.boot.websocket.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
/**
* @description: SSE练习
* @author: syhan
* @date: 2022-04-18
**/
@Slf4j
@Controller
public class SseController {
@RequestMapping(value = "/server/info", method = {RequestMethod.GET}, produces = "text/event-stream;charset=UTF-8")
public ResponseBodyEmitter pushMsg() {
final SseEmitter emitter = new SseEmitter(0L);
log.info("emitter push message .....");
List<String> list = new ArrayList<>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
try {
emitter.send(list.toString(), MediaType.TEXT_EVENT_STREAM);
} catch (IOException e) {
e.printStackTrace();
}
return emitter;
}
@RequestMapping(value = "/server/data", method = {RequestMethod.GET}, produces = "text/event-stream;charset=UTF-8")
public ResponseBodyEmitter push() {
final SseEmitter emitter = new SseEmitter(0L);
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
double money = Math.random() * 10;
DecimalFormat df = new DecimalFormat(".00");
String param = df.format(money);
try {
emitter.send("⽩菜价格⾏情:" + param + "元" + "\n\n", MediaType.TEXT_EVENT_STREAM);
} catch (IOException e) {
e.printStackTrace();
}
return emitter;
}
}
对于服务器端向浏览器发送的数据,浏览器端需要在 JavaScript 中使⽤ EventSource 对象来进⾏处理。EventSource 使⽤的是标准的事件监听器⽅式,只需要在对象上添加相应的事件处理⽅法即可。EventSource 提供了三个标准事件
事件名称 | 事件触发说明 | 事件处理方法 |
---|---|---|
open | 当服务器向浏览器第⼀次发送数据 | onopen |
message | 当收到服务器发送的消息时产⽣ | onmessage |
error | 当出现异常时产⽣ | onerror |
除了使⽤标准的事件处理⽅法,还可以使⽤addEventListener⽅法对事件进⾏监听。
var es = new EventSource('事件源名称') ; //与事件源建⽴连接
//标准事件处理⽅法,还有onopen、onerror
es.onmessage = function(e) {
};
//可以监听⾃定义的事件名称
es.addEventListener('⾃定义事件名称', function(e) {
});
public/sse-demo1.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>sse-demo1</title>
</head>
<body>
<div id="msg_from_server"></div>
<script>
if (!!window.EventSource) {
let source = new EventSource("http://localhost:8080/server/info");
let s = "";
source.addEventListener(
"open",
() => {
console.log("连接打开.");
},
false
);
source.addEventListener("message", (e) => {
s += e.data + "<br/>";
document.getElementById("msg_from_server").innerHTML = s;
});
source.addEventListener(
"error",
(e) => {
if (e.readyState === EventSource.CLOSED) {
console.log("连接关闭");
} else {
console.log(e.readyState);
}
},
false
);
} else {
alert(4);
console.log("没有sse");
}
</script>
</body>
</html>
运⾏效果
public/sse-demo2.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>sse-demo2</title>
</head>
<body>
<div id="result"></div>
<script type="text/javascript">
//需要判断浏览器⽀不⽀持,可以去w3c进⾏查看
const source = new EventSource("http://localhost:8080/server/data");
source.onmessage = function (event) {
console.info(event.data);
document.getElementById("result").innerText = event.data;
};
</script>
</body>
</html>
运⾏效果:1 秒后出现服务端推送的数据
3. 双向实时通信 websocket
⼀、整合 websocket
- 添加依赖
<!-- 引⼊websocket依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
编写配置类,开启 websocket 功能
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
⼆、兼容 HTTPS 协议
- WebSocket 的 ws 协议是基于 HTTP 协议实现的
- WebSocket 的 wss 协议是基于 HTTPS 协议实现的
⼀旦你的项⽬⾥⾯使⽤了 https 协议,websocket 就要使⽤ wss 协议才可以。
在 TomcatCustomizer 配置的基础之上加上如下的代码,就可以⽀持 wss 协议。
@Bean
public TomcatContextCustomizer tomcatContextCustomizer() {
return new TomcatContextCustomizer() {
@Override
public void customize(Context context) {
context.addServletContainerInitializer(new WsSci(), null);
}
};
}
三、WebSocket 编程基础
3.1 连接的建⽴
前端 js 向后端发送 wss 连接建⽴请求
socket = new WebSocket("wss://localhost:8888/ws/asset");
SpringBoot 服务端 WebSocket 服务接收类定义如下:
@Component
@Slf4j
@ServerEndpoint(value = "/ws/asset")
public class WebSocketServer {
}
3.2 全双工数据交互
前端后端都有
onopen 事件监听,处理连接建⽴事件
onmessage 事件监听,处理对⽅发过来的消息数据
onclose 事件监听,处理连接关闭
onerror 事件监听,处理交互过程中的异常
3.3 数据发送
浏览器与服务器交换数据
前端 JS
socket.send(message);
后端 Java,向某⼀个 javax.websocket.Session ⽤户发送消息。
/**
* 发送消息,每次浏览器刷新,session会发⽣变化。
* @param session session
* @param message 消息
*/
private static void sendMessage(Session session, String message) throws
IOException{
session.getBasicRemote().sendText(String.format("%s (From Server,
Session ID=%s)",message,session.getId()));
}
⼀个⽤户向其他⽤户群发
服务器向所有在线的 javax.websocket.Session ⽤户发送消息。
/**
* 群发消息
* @param message 消息
*/
public static void broadCastInfo(String message) throws IOException {
for (Session session : SessionSet) {
if(session.isOpen()){
sendMessage(session, message);
}
}
}
四、websocket 实现简单聊天
WebSocketServer 核⼼代码
@ServerEndpoint(value = “/ws/asset”)表示 websocket 的接⼝服务地址
@OnOpen 注解的⽅法,为连接建⽴成功时调⽤的⽅法
@OnClose 注解的⽅法,为连接关闭调⽤的⽅法
@OnMessage 注解的⽅法,为收到客户端消息后调⽤的⽅法
@OnError 注解的⽅法,为出现异常时调⽤的⽅法
package top.syhan.boot.websocket.server;
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;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author syhan
* @description: webSocket服务层,连接webSocket的时候,路径中传⼀个参数值id,⽤ 来区分不同⻚⾯推送不同的数据
* @date 2022-04-18
*/
@ServerEndpoint(value = "/socket/{id}")
@Component
@Slf4j
public class WebSocketServer {
/**
* 静态变量,⽤来记录当前在线连接数,线程安全
*/
private static int onlineCount = 0;
/**
* concurrent包的线程安全Set,⽤来存放每个客户端对应的MyWebSocket对象
*/
public static ConcurrentHashMap<Integer, WebSocketServer>
webSocketSet = new ConcurrentHashMap<>();
/**
* 与某个客户端的连接会话,需要通过它来给客户端发送数据
*/
private Session session;
/**
* 传过来的id
*/
private Integer id = 0;
/**
* 连接建⽴成功调⽤的⽅法
*/
@OnOpen
public void onOpen(@PathParam(value = "id") Integer param, Session
session) {
//接收到发送消息的⼈员编号
this.id = param;
this.session = session;
//加⼊set中
webSocketSet.put(param, this);
//在线数加1
addOnlineCount();
log.info("有新连接加⼊!当前在线⼈数为" + getOnlineCount());
sendMessage("-连接已建⽴-");
}
/**
* 连接关闭调⽤的⽅法
*/
@OnClose
public void onClose() {
if (id != null && id != 0) {
//从set中删除
webSocketSet.remove(id);
//在线数减1
subOnlineCount();
log.info("有⼀连接关闭!当前在线⼈数为" + getOnlineCount());
}
}
/**
* 收到客户端消息后调⽤的⽅法
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String message, Session session) {
log.info("来⾃客户端的消息:" + message);
this.sendMessage(message);
}
/**
* 发⽣错误时调⽤
**/
@OnError
public void onError(Session session, Throwable error) {
log.info("发⽣错误");
error.printStackTrace();
}
public void sendMessage(String message) {
try {
getSession().getBasicRemote().sendText(message);
} catch (IOException e) {
log.info("发⽣错误");
e.printStackTrace();
}
}
/**
* 给指定的⼈发送消息
*
* @param id id
* @param message message
*/
public void sendToMessageById(Integer id, String message) {
if (webSocketSet.get(id) != null) {
webSocketSet.get(id).sendMessage(message);
} else {
log.info("webSocketSet中没有此key,不推送消息");
}
}
/**
* 群发⾃定义消息
*/
public void broadcastInfo(String message) {
for (WebSocketServer item : webSocketSet.values()) {
item.sendMessage(message);
}
}
public Session getSession() {
return session;
}
public static synchronized int getOnlineCount() {
return onlineCount;
}
public static synchronized void addOnlineCount() {
WebSocketServer.onlineCount++;
}
public static synchronized void subOnlineCount() {
WebSocketServer.onlineCount--;
}
}
客户端代码,public/websocket.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>websocket⻚⾯</title>
</head>
<body>
<div id="app">
<div>
<label>
输⼊昵称
<input
type="text"
v-model="nickname"
id="nickname"
placeholder="输⼊昵称"
/>
</label>
<button @click="open">连接websocket</button>
</div>
<div>
<label>
输⼊内容
<input
type="text"
v-model="content"
id="content"
placeholder="输⼊内容"
/>
</label>
<button @click="sendMsg">发送消息</button>
</div>
<hr />
<div>
<h3>{{message}}</h3>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script>
const vm = new Vue({
el: "#app",
data() {
return {
ws: null,
content: "",
message: "",
nickname: "",
};
},
methods: {
open() {
if (this.nickname === "") {
alert("昵称不能为空");
return;
}
ws = new WebSocket(
`ws://localhost:8888/websocket?nickname=${this.nickname}`
);
ws.onopen = () => {
console.log("websocket已经连接");
};
ws.onclose = () => {
console.log("websocket已经关闭");
};
ws.onerror = () => {
console.log("websocket出现异常");
};
ws.onmessage = (msg) => {
console.log(msg);
this.message = this.message.concat(msg.data);
};
},
sendMsg() {
ws.send(this.content);
console.log("发送成功");
this.content = "";
},
},
});
</script>
</body>
</html>
运⾏效果