服务器主动推送之SSE (Server-Sent Events)探讨

465 阅读6分钟
日期更新说明
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 EventsWebSocket
通信模式单向通道(HTTP长连接)双向实时通道独立协议
开发实现简单,浏览器原生支持,服务端发送Content-Type: text/event-stream响应头复杂,需在客户端/服务端分别实现握手、帧解析、心跳维持等逻辑
文本格式文本文本和二进制

其实简单的总结一下,如果你刚好需要服务主动推送数据的,如果追求强交互、强实时、协议定制化建议使用WebSocke,如果当初需要服务推送数据的话使用Server-Sent Events是个优雅的选择。

实践(How)

实时更新消息更新案例

下面将演示服务端每个一秒中发送带时间的消息给浏览器(客户端),我们分别会使用 JavaRust分别演示,先看效果:

问:为啥要rust演示 SSE呢?

答:作者在学 元神语言:rust

  1. 浏览器编码:
<!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>