web编程之SSE初探
不一定非要websocket。
一、什么是SSE
SSE(Server Sent Events),是一种服务器向浏览器发送消息的技术,基于http协议。WebSocket大家耳熟能详,是WEB中一种客户端和服务器采用长连接,可长时间保持通信通道的技术,不过它是双向的,服务器可以向客户端推送消息,客户端也同样可以向服务器发送消息。而SSE则是单向的,只能是服务器向客户端发送消息。

二、SSE的作用
由上可知,SSE是单向的,而WebSocket是双向的,因此很自然地推测,SSE比WebSocket要轻量,编码没有那么复杂。如果在只需要从服务器向客户端单向推送消息的场景,比如实时通知、股票行情、日志流等,就可以使用sse。
三、试用SSE
我在下面的示例中,采用Spring Boot作为应用sse的后端。前端就是简单和原始的html + js。前端基于nginx承载和运行;因为前后端分离,为了避免跨域,nginx还对后端地址进行了转发。同时,为了支持长连接,nginx也要进行一些特别的设置。
1、后端
在本示例中,后端代码就是一个控制器里面的action,看上去跟一个普普通通的,基于http的RESTFul API没啥两样:
package com.bullshit.server.modules.sse;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicBoolean;
@RestController
@RequestMapping("sse")
public class SseController {
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter getEvents() {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); // 设置超时为无限长。这里存疑!!!
final AtomicBoolean completed = new AtomicBoolean(false); // 使用标志位来确保只完成一次
// 连接完成时触发
emitter.onCompletion(() -> {//连接关闭的时候触发本事件
System.out.println("SSE connection completed.");
completed.set(true); // 设置为已完成
});
// 连接超时时触发
emitter.onTimeout(() -> {
System.out.println("SSE connection timed out.");
if (!completed.get()) {
emitter.complete(); // 超时后完成连接
}
});
// 错误发生时触发
emitter.onError((e) -> {
System.out.println("SSE error: " + e.getMessage());
if (!completed.get()) {
emitter.completeWithError(e); // 出错时完成连接
}
});
// 启动一个新线程进行数据发送
new Thread(() -> {
try {
while (!completed.get()) { // 使用标志位来控制是否继续发送数据
emitter.send(SseEmitter.event()
.id(String.valueOf(System.currentTimeMillis()))
.name("time")
.data(LocalDateTime.now().toString()));
//心跳。避免客户端处理发送数据时间长等因素导致连接超时。
emitter.send(SseEmitter.event()
.id(String.valueOf(System.currentTimeMillis()))
.name("heartbeat")
.data("keep-alive"));
Thread.sleep(1000); // 每秒发送一次数据
}
} catch (IOException | InterruptedException e) {
System.out.println("Error in sending data: " + e.getMessage());
} finally {
if (!completed.get()) {
emitter.complete(); // 确保在异常后也能完成连接
}
}
}).start();
return emitter;
}
}
注意,发送事件中,
emitter.send(SseEmitter.event()
.id(String.valueOf(System.currentTimeMillis()))
.name("heartbeat")
.data("keep-alive"));
.name()这里面的值是消息类型,可以自定义,但前端必须跟它呼应。比如后端使用了name(“time”),那么前端就要监听“time”这种消息:
eventSource.addEventListener('time', function (event) {
info(event);
});
2、前端
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE Example</title>
</head>
<body>
<h1>Server-Sent Events Demo</h1>
<div id="events">
<!-- 消息将显示在这里 -->
</div>
<script>
(function connect() {
const eventSource = new EventSource('/api/sse/events');
const eventsDiv = document.getElementById('events');
eventSource.onopen = function () {
console.log("Connection established.");
};
eventSource.addEventListener('time', function (event) {
info(event);
});
eventSource.addEventListener('heartbeat', function(event) {
console.log("Heartbeat received:", event.data);
});
eventSource.onerror = function () {
console.error("Connection lost, retrying in 5 seconds...");
error();
eventSource.close(); // 关闭现有连接
setTimeout(connect, 5000); // 5 秒后尝试重新连接
};
function info(event){
const message = document.createElement('p');
message.textContent = `Time: ${event.data}`;
eventsDiv.appendChild(message);
}
function error(){
const errorMessage = document.createElement('p');
errorMessage.style.color = "red";
errorMessage.textContent = "Error occurred while connecting to the SSE endpoint.";
eventsDiv.appendChild(errorMessage);
}
})();
</script>
</body>
</html>
3、nginx
配置文件nginx.conf
worker_processes 1;
error_log logs/error.log debug;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
server {
listen 18080;
server_name localhost;
# 启用长连接
proxy_buffering off;
proxy_cache off;
proxy_connect_timeout 3600s; # 连接超时
proxy_send_timeout 3600s; # 数据发送超时
proxy_read_timeout 3600s; # 数据读取超时
chunked_transfer_encoding on; # 确保启用传输分块
keepalive_timeout 65s;
keepalive_requests 100; # 单个连接允许的最大请求数量
#gzip config
gzip on;
gzip_min_length 1k;
gzip_comp_level 9;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
gzip_static on;
location / {
root E:/sse/demo-ui;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api/ {#后端地址
proxy_pass http://192.168.10.8:8090/;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
gzip off; # 禁用 GZIP
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
4、运行结果

sse运行效果
5、超时问题
不知为什么,运行过程中,总会引发超时问题。看上去,这跟后端的sse对象的属性设置有关:
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); // 设置超时为无限长
刚开始,我是设了30秒,结果一直都是,到30秒必超时。前后端和nginx设置都搞了一遍,还是超时。按照chatGPT的说话,SseEmitter 的超时设置指的是 整个连接的存活时间,而不是基于数据发送的活动性。这意味着,即使后端持续发送数据,只要超时时间达到,SseEmitter 仍会触发超时。
而通义千问则刚好相反,它坚持道,SseEmitter 的超时设置并不是指整个连接的存活时间,而是指空闲时间(即在没有接收到任何数据或心跳消息的情况下,连接保持打开的最大时间)。换句话说,SseEmitter 的超时机制是基于活动性的,而不是基于连接的总存活时间。
神仙打架了。按常理来理解,我同意通义千问的说法。但是这很难解释,我明明后端一直发送,前端一直接收,心跳也能准时到达,根本没有空闲的时候,但30秒一到,马上超时。所以只好将时间设为无限长,只有前端主动断掉,或者报错才结束。
这个问题先搁置。
四、小结
从编码看,SSE的确比websocket要简单。如果只是单向推消息,我会考虑用SSE。
浙公网安备 33010602011771号