| 日期 | 更新说明 |
|---|---|
| 2025年8月13日 | 增加: SSE K线图实时更新示例 |
| 2025年7月29日 | 初版发布 |
前言
提到服务器主动推送消息给客户端方式,大家可能第一反应想到是WebSocket(当然你说客户端轮询也可行,只不过不是那么的优雅而已);而今天要提到的新的一种通信方式:SSE (Server-Sent Events)。尤其在当前AI大模型爆火的年代,LLM聊天机器人几乎已成成为日常必备的工具,其背后消息的推送主要即使基于SSE (Server-Sent Events)来实现消息推送的;如果你有兴趣让我们一起来探讨下这项技术背后的内容。
概述
是什么(What)
SSE (Server-Sent Events),服务器发送的事件: 通过 HTTP 为 Web 应用程序提供从服务器到客户端的事件流的异步通信。服务器可以向客户端发送非定向消息/事件,并且可以异步更新客户端,目前主流浏览器都支持该特性(IE 除外)。
为了便于理解,通俗一些理解,客户端和服务端建立链接以后,服务端可以向重复客户端发送数据流;可以理解成平时看视频,服务器可以提前推送给你推送流数据用于视频缓冲;只不过这个过程单工通信方式;下图方式便于理解,
为什么(Why)
既然有了WebSocket还要推出SSE (Server-Sent Events)呢?
SSE (Server-Sent Events)的本质是啥?有何特点,这里直接给你对比下区别:
| Server-Sent Events | WebSocket | |
|---|---|---|
| 通信模式 | 单向通道(HTTP长连接) | 双向实时通道独立协议 |
| 开发实现 | 简单,浏览器原生支持,服务端发送Content-Type: text/event-stream响应头 | 复杂,需在客户端/服务端分别实现握手、帧解析、心跳维持等逻辑 |
| 文本格式 | 文本 | 文本和二进制 |
其实简单的总结一下,如果你刚好需要服务主动推送数据的,如果追求强交互、强实时、协议定制化建议使用WebSocke,如果当初需要服务推送数据的话使用Server-Sent Events是个优雅的选择。
实践(How)
实时更新消息更新案例
下面将演示服务端每个一秒中发送带时间的消息给浏览器(客户端),我们分别会使用 Java和Rust分别演示,先看效果:
问:为啥要rust演示 SSE呢?
答:作者在学 元神语言:rust
- 浏览器编码:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>SSE handler Rust</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="">
</head>
<body>
<!--[if lt IE 7]>
<p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="#">upgrade your browser</a> to improve your experience.</p>
<![endif]-->
<h1>Server Sent Events Power By Rust</h1>
<div id="con"></div>
<script lang="javascript">
let chat = document.getElementById("con");
var source = new EventSource("/events");
source.onmessage = function(event) {
chat.innerHTML += event.data + '<br/>';
console.log("Got:", event.data);
};
</script>
</body>
</html>
2. 服务端
SseEmitter sseEmitter = sseEmitterMap.get(uid);
if (sseEmitter == null) {
log.info("消息推送失败uid:[{}],没有创建连接,请重试。", uid);
return false;
}
try {
sseEmitter.send(SseEmitter.event().id(messageId).reconnectTime(Duration.ofSeconds(1).toMillis()).data(message));
log.info("用户{},消息id:{},推送成功:{}", uid, messageId, message);
return true;
} catch (Exception e) {
sseEmitterMap.remove(uid);
log.info("用户{},消息id:{},推送异常:{}", uid, messageId, e.getMessage());
sseEmitter.complete();
return false;
}
Java 部分只需要引入 ·SpringMvc·其实可以了
TypedHeader(user_agent): TypedHeader<headers::UserAgent>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
info!("`{}` connected", user_agent.as_str());
// A `Stream` that repeats an event every second
//
// You can also create streams from tokio channels using the wrappers in
// https://docs.rs/tokio-stream
let stream = stream::repeat_with(|| {
let data = format!("hi! now:{}", Local::now().to_string());
info!("send message: [{}]", data);
Event::default().data(data)
})
.map(Ok)
.throttle(Duration::from_secs(1));
Sse::new(stream).keep_alive(
axum::response::sse::KeepAlive::new()
.interval(Duration::from_secs(1))
.text("keep-alive-text"),
)
rust 需要引入axum
rust 的 SSE 案例工程链接:github.com/will-we/blo…
Java的 SSE 案例工程链接:github.com/will-we/blo…
SSE K线图实时更新 案例
想看效果,后端崽前端画的比较简陋,基本可用哈
@CrossOrigin
@GetMapping("/stream")
public SseEmitter createKlineStream() {
String uid = IdUtil.fastUUID();
// 启动一个线程模拟K线数据更新
new Thread(() -> {
try {
// 读取初始K线数据
ClassPathResource resource = new ClassPathResource("static/kline-data.json");
String klineData = FileUtil.readString(resource.getFile(), StandardCharsets.UTF_8);
// 先发送初始数据,使用事件名称而不是ID
sseClient.sendMessage(uid, null, klineData, "init");
// 从初始数据中获取最后一个收盘价
double lastClose = 169.73; // 默认初始收盘价
try {
// 尝试解析JSON获取最后一个数据点的收盘价
if (klineData.contains("close")) {
int lastCloseIndex = klineData.lastIndexOf("close");
if (lastCloseIndex > 0) {
int valueStart = klineData.indexOf(":", lastCloseIndex) + 1;
int valueEnd = klineData.indexOf(",", valueStart);
if (valueEnd == -1) { // 可能是JSON数组中最后一个对象的最后一个属性
valueEnd = klineData.indexOf("}", valueStart);
}
if (valueStart > 0 && valueEnd > valueStart) {
String closeValue = klineData.substring(valueStart, valueEnd).trim();
lastClose = Double.parseDouble(closeValue);
log.info("从初始数据中获取到最后收盘价: {}", lastClose);
}
}
}
} catch (Exception e) {
log.warn("解析初始收盘价失败,使用默认值: {}", e.getMessage());
}
// 每秒更新一次数据
while (true) {
// 模拟新的K线数据点
double change = (random.nextDouble() - 0.5) * 5; // 随机变化
double open = lastClose;
double close = open + change;
double high = Math.max(open, close) + random.nextDouble() * 2;
double low = Math.min(open, close) - random.nextDouble() * 2;
int volume = 15000 + random.nextInt(5000);
// 更新最后收盘价
lastClose = close;
String newDataPoint = String.format(
"{"time": "%s", "open": %.2f, "high": %.2f, "low": %.2f, "close": %.2f, "volume": %d}",
LocalDateTime.now(), open, high, low, close, volume
);
sseClient.sendMessage(uid, null, newDataPoint, "update");
Thread.sleep(1000);
}
} catch (Exception e) {
log.error("K线数据推送异常", e);
sseClient.closeSse(uid);
}
}).start();
return sseClient.createSse(uid);
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>SSE K线图示例</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
#chart-container {
width: 100%;
height: 600px;
}
.status {
margin-top: 10px;
padding: 10px;
background-color: #f5f5f5;
border-radius: 4px;
}
</style>
</head>
<body>
<h1>SSE K线图实时更新示例</h1>
<div id="chart-container"></div>
<div class="status" id="connection-status">连接状态: 未连接</div>
<div class="status" id="data-status">最新数据: 无</div>
<script>
// 初始化图表
const chartDom = document.getElementById('chart-container');
const myChart = echarts.init(chartDom);
const connectionStatus = document.getElementById('connection-status');
const dataStatus = document.getElementById('data-status');
const upColor = "#ec0000";
const upBorderColor = "#8A0000";
const downColor = "#00da3c";
const downBorderColor = "#008F28";
let klineData = [];
// 配置K线图
const option = {
title: {
text: 'SSE实时K线图',
left: 'center'
},
// 图例配置
legend: {
data: ['K线图', 'MA5', 'MA10', 'MA20', 'MA30'],
top: '30px'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
},
formatter: function (params) {
console.log(params);
const data = params[0].data;
return [
'时间: ' + params[0].axisValue + '<br/>',
'开盘: ' + data[1] + '<br/>',
'最高: ' + data[2] + '<br/>',
'最低: ' + data[3] + '<br/>',
'收盘: ' + data[4] + '<br/>'
].join('');
}
},
grid: {
left: '3%',
right: '3%',
bottom: '15%'
},
xAxis: {
type: 'category',
data: [],
scale: true,
boundaryGap: false,
axisLine: { onZero: false },
splitLine: { show: false },
splitNumber: 20,
min: 'dataMin',
max: 'dataMax'
},
yAxis: {
scale: true,
splitArea: {
show: true
}
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100
},
{
show: true,
type: 'slider',
top: '90%',
start: 0,
end: 100
}
],
series: [
{
name: 'K线图',
type: 'candlestick',
data: [],
itemStyle: {
color: '#ec0000',
color0: '#00da3c',
borderColor: '#8A0000',
borderColor0: '#008F28'
}
},
{
name:"MA5",
type:"line",
data:[],
smooth:true,
lineStyle:{opacity:0.5}
},
{
name:"MA10",
type:"line",
data:[],
smooth:true,
lineStyle:{opacity:0.5}
},
{
name:"MA20",
type:"line",
data:[],
smooth:true,
lineStyle:{opacity:0.5}
},
{
name:"MA30",
type:"line",
data:[],
smooth:true,
lineStyle:{opacity:0.5}
},
]
};
// 初始化图表并应用配置
myChart.setOption(option);
// 窗口大小变化时自动调整图表大小
window.addEventListener('resize', function() {
myChart.resize();
});
// 计算移动平均线
function calculateMA(dayCount, data) {
const result = [];
for (let i = 0; i < data.length; i++) {
if (i < dayCount - 1) {
// 数据不足以计算MA
result.push('-');
continue;
}
let sum = 0;
for (let j = 0; j < dayCount; j++) {
// 使用收盘价计算MA
sum += data[i - j].close;
}
result.push((sum / dayCount).toFixed(2));
}
return result;
}
// 格式化K线数据为ECharts所需格式
function formatKlineData(data) {
return data.map(item => {
const timeStr = new Date(item.time).toLocaleTimeString();
return {
time: timeStr,
open: item.open,
close: item.close,
low: item.low,
high: item.high,
volume: item.volume
};
});
}
// 更新图表数据
function updateChart(data) {
const formattedData = formatKlineData(data);
const times = formattedData.map(item => item.time);
// 计算各个周期的移动平均线
const ma5 = calculateMA(5, formattedData);
const ma10 = calculateMA(10, formattedData);
const ma20 = calculateMA(20, formattedData);
const ma30 = calculateMA(30, formattedData);
myChart.setOption({
xAxis: {
data: times
},
series: [
{
name: 'K线图',
type: 'candlestick',
data: formattedData.map(item => [
item.open, // 开盘价
item.high, // 最高价
item.low, // 最低价
item.close // 收盘价
]),
itemStyle: {
color: upColor,
color0: downColor,
borderColor: upBorderColor,
borderColor0: downBorderColor
}
},
{
name: 'MA5',
type: 'line',
data: ma5,
smooth: true,
lineStyle: {opacity: 0.5}
},
{
name: 'MA10',
type: 'line',
data: ma10,
smooth: true,
lineStyle: {opacity: 0.5}
},
{
name: 'MA20',
type: 'line',
data: ma20,
smooth: true,
lineStyle: {opacity: 0.5}
},
{
name: 'MA30',
type: 'line',
data: ma30,
smooth: true,
lineStyle: {opacity: 0.5}
}
]
});
}
// 添加新的数据点
function addDataPoint(dataPoint) {
try {
const newPoint = JSON.parse(dataPoint);
klineData.push(newPoint);
// 保持最多显示30个数据点
if (klineData.length > 30) {
klineData.shift();
}
updateChart(klineData);
dataStatus.innerHTML = `最新数据: ${new Date().toLocaleTimeString()} - 开盘: ${newPoint.open.toFixed(2)}, 收盘: ${newPoint.close.toFixed(2)}`;
console.log('新数据点:', newPoint);
} catch (e) {
console.error('解析数据失败:', e, dataPoint);
}
}
// 连接SSE
if (window.EventSource) {
connectionStatus.innerHTML = '连接状态: 正在连接...';
// 创建SSE连接
const eventSource = new EventSource('/kline/stream');
eventSource.onopen = function() {
connectionStatus.innerHTML = '连接状态: 已连接';
connectionStatus.style.backgroundColor = '#d4edda';
};
eventSource.addEventListener('message', function(event) {
if (event.data) {
try {
console.log('收到SSE消息:', event);
// 添加新的数据点
console.log('新数据点原始数据:', event.data);
addDataPoint(event.data);
} catch (e) {
console.error('处理数据失败:', e, event.data);
}
}
});
// 监听初始数据事件
eventSource.addEventListener('init', function(event) {
try {
console.log('收到初始数据:', event);
klineData = JSON.parse(event.data);
console.log('解析后的初始数据:', klineData);
updateChart(klineData);
dataStatus.innerHTML = `初始数据已加载 - ${klineData.length}个数据点`;
} catch (e) {
console.error('处理初始数据失败:', e, event.data);
}
});
// 监听更新数据事件
eventSource.addEventListener('update', function(event) {
try {
console.log('收到更新数据:', event);
addDataPoint(event.data);
} catch (e) {
console.error('处理更新数据失败:', e, event.data);
}
});
eventSource.onerror = function() {
connectionStatus.innerHTML = '连接状态: 连接失败或已断开';
connectionStatus.style.backgroundColor = '#f8d7da';
};
} else {
connectionStatus.innerHTML = '连接状态: 浏览器不支持SSE';
connectionStatus.style.backgroundColor = '#f8d7da';
}
// 窗口大小变化时调整图表大小
window.addEventListener('resize', function() {
myChart.resize();
});
</script>
</body>
</html>