SSE(Server-Sent Events)是一种基于HTTP的单向通信协议,适用于服务端向客户端推送实时数据的场景。SpringBoot提供了对SSE的原生支持,以下为具体实现方法。
技术要点:
1、spring boot集群
2、sse命令下发
3、redis订阅发布(可用rocketmq或者其他消息中间件代替)
4、nginx反向代理
1、添加依赖
确保pom.xml
中包含SpringBoot Web依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!-- redis 缓存操作 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
2、创建SSE控制器
通过设备编号、密钥确保连接安全。
import cn.hutool.core.thread.ThreadUtil;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* @author xuancg
*/
@RestController
@RequestMapping("/sse")
@Validated
@Slf4j
public class SseController {
@Resource
private SseServiceImpl sseService;
/**
* 创建sse链接
*/
@GetMapping("/createConnect/{devNo}/{devKey}")
public SseEmitterUTF8 createConnect(@PathVariable("devNo") String devNo, @PathVariable("devKey") String devKey) {
if(StrUtil.isBlank(devNo) || StrUtil.isBlank(devKey)){
log.info("sse注册信息不全");
return null;
}
return sseService.createConnect(devNo, devKey);
}
/**
* 关闭链接
*/
@GetMapping("/closeConnect/{devNo}/{devKey}")
public Boolean closeConnect(@PathVariable("devNo") String devNo, @PathVariable("devKey") String devKey) {
ThreadUtil.execute(() -> {
sseService.closeConnect(devNo, devKey);
});
return true;
}
}
3、创建SSE业务服务
3.1、自定义 SseEmitter对象
其中uuid作为唯一标识,为后续连接下线使用
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.nio.charset.StandardCharsets;
/***
*
* @author xuancg
* @date 2025/7/24
*/
public class SseEmitterUTF8 extends SseEmitter {
public SseEmitterUTF8(Long timeout) {
super(timeout);
this.uuid = IdUtils.fastUUID();
}
private String uuid;
@Override
protected void extendResponse(ServerHttpResponse outputMessage) {
super.extendResponse(outputMessage);
HttpHeaders headers = outputMessage.getHeaders();
headers.setContentType(new MediaType(MediaType.TEXT_EVENT_STREAM, StandardCharsets.UTF_8));
}
public String getUuid() {
return uuid;
}
public void setUuid(String uuid) {
this.uuid = uuid;
}
}
3.2、创建具体业务处理
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.thread.ThreadUtil;
import cn.hutool.http.HttpStatus;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* @author xuancg
*/
@Slf4j
@Service
public class SseServiceImpl {
/**
* 容器,保存连接,用于输出返回 ;可使用其他方法实现,使用广播消息方式
*/
private static final Map<String, SseEmitterUTF8> SSE_CACHE = MapUtil.newConcurrentHashMap();
/**
* 重试次数
*/
private final Integer RESET_COUNT = 3;
/**
* 重试等待事件 单位 ms
*/
private final Integer RESET_TIME = 5000;
/**连接超时时间10分钟*/
private final long TIME_OUT = 10 * 60 * 1000L;
@Resource
private DeviceService deviceService;
@Resource
private RedisCache redisCache;
@Resource
private RedisParserFactory redisParserFactory;
/**
* 创建连接
*/
public SseEmitterUTF8 createConnect(String devNo, String devKey) {
// 是否需要给客户端推送ID
// 检查 设备账号密码是否匹配
Device device = deviceService.getByDevNoAndKey(devNo, devKey);
if(null == device){
log.info("sse注册设备信息不匹配" + devNo);
return null;
}
// 设置超时时间 单位毫秒,0表示不过期。默认30秒,超过时间未完成会抛出异常:AsyncRequestTimeoutException
SseEmitterUTF8 sseEmitter = new SseEmitterUTF8(TIME_OUT);
// 如果存在上一个,关闭连接
if(SSE_CACHE.containsKey(devNo)){
closeConnect(devNo);
} else {
// 集群下,广播通知关闭
ClientNotifyResp resp = new ClientNotifyResp(ClientOptType.CLOSE_CONNECT, sseEmitter.getUuid());
sendMessageByPublish(devNo, resp, true);
}
// 注册回调
// 长链接完成后回调接口(即关闭连接时调用)
sseEmitter.onCompletion(completionCallBack(devNo));
// 连接超时回调
sseEmitter.onTimeout(timeoutCallBack(devNo));
// 推送消息异常时,回调方法
sseEmitter.onError(errorCallBack(devNo));
SSE_CACHE.put(devNo, sseEmitter);
log.info("创建新的sse连接,当前用户:{} 累计用户:{}", devNo, SSE_CACHE.size());
// TODO 连接成功的后续操作
return sseEmitter;
}
/**
* 关闭连接
*
* @param devNo 客户端ID
*/
public void closeConnect(String devNo, String devKey) {
Device device = deviceService.getByDevNoAndKey(devNo, devKey);
if(null == device){
return ;
}
closeConnect(devNo);
}
private void closeConnect(String devNo){
SseEmitterUTF8 sseEmitter = SSE_CACHE.get(devNo);
if (sseEmitter != null) {
log.info("关闭sse连接,当前用户:{} uuid:{}", devNo, sseEmitter.getUuid());
sseEmitter.complete();
removeUser(devNo);
}
}
/**
* 1、如果当前sessionMap存在deviceId则直接发送消息
* 2、否则消息发布至redis,进行广播通知处理;
* @param resp
* @param broadcast 是否广播
*/
public void sendMessageByPublish(String devNo, ClientNotifyResp resp, boolean broadcast){
if(SSE_CACHE.containsKey(devNo)){
if(resp.getOptType() == ClientOptType.CLOSE_CONNECT){
// 防止自己清除自己
SseEmitterUTF8 sseEmitter = SSE_CACHE.get(devNo);
if(sseEmitter.getUuid().equals(String.valueOf(resp.getData()))){
return ;
}
log.info("sse关闭上一个连接");
closeConnect(devNo);
return ;
}
sendMsgToClientByDevNo(devNo, resp);
return ;
}
if(broadcast){
redisParserFactory.getParser(ContentType.CLIENT_BROWSER_BROADCAST_PARSER).send(devNo, resp);
}
}
/**
* 推送消息到客户端
* 此处做了推送失败后,重试推送机制,可根据自己业务进行修改
*
* @param devNo 客户端ID
* @param msg 推送信息,此处结合具体业务,定义自己的返回值即可
**/
private void sendMsgToClientByDevNo(String devNo, ClientNotifyResp msg) {
SseEmitterUTF8 emitter = SSE_CACHE.get(devNo);
if (emitter == null) {
return;
}
log.info("SSE推送消息:设备={},类型={}", devNo, msg.getOptType());
SseEmitterUTF8.SseEventBuilder sendData = SseEmitterUTF8.event().id(String.valueOf(HttpStatus.HTTP_OK))
.data(msg, MediaType.APPLICATION_JSON);
try {
emitter.send(sendData);
} catch (Exception e) {
// 推送消息失败,记录错误日志,进行重推
log.error("推送消息失败:{},尝试进行重推", msg);
// 推送消息失败后,每隔10s推送一次,推送5次
for (int i = 0; i < RESET_COUNT; i++) {
try {
// 防止等待过程中,连接断开
Thread.sleep(RESET_TIME);
emitter = SSE_CACHE.get(devNo);
if (emitter == null) {
log.error("{}的第{}次消息重推失败,未创建长链接", devNo, i + 1);
continue;
}
emitter.send(sendData);
} catch (Exception ex) {
log.error("{}的第{}次消息重推失败", devNo, i + 1, ex);
continue;
}
log.info("{}的第{}次消息重推成功,{}", devNo, i + 1, msg);
return;
}
}
}
/**
* 长链接完成后回调接口(即关闭连接时调用)
*
* @param devNo 客户端ID
**/
private Runnable completionCallBack(String devNo) {
return () -> {
log.info("结束连接:{}", devNo);
removeUser(devNo);
};
}
/**
* 连接超时时调用
*
* @param devNo 客户端ID
**/
private Runnable timeoutCallBack(String devNo) {
return () -> {
log.info("连接超时:{}", devNo);
removeUser(devNo);
};
}
/**
* 推送消息异常时,回调方法
*
* @param devNo 客户端ID
**/
private Consumer<Throwable> errorCallBack(String devNo) {
return throwable -> {
log.error("SseEmitterServiceImpl[errorCallBack]:连接异常,客户端ID:{}", devNo);
// 推送消息失败后,每隔10s推送一次,推送5次
for (int i = 0; i < RESET_COUNT; i++) {
try {
Thread.sleep(RESET_TIME);
SseEmitterUTF8 sseEmitter = SSE_CACHE.get(devNo);
if (sseEmitter == null) {
log.error("SseEmitterServiceImpl[errorCallBack]:第{}次消息重推失败,未获取到 {} 对应的长链接", i + 1, devNo);
continue;
}
sseEmitter.send("失败后重新推送");
} catch (Exception e) {
log.error("sse推送消息异常", e);
}
}
};
}
/**
* 移除用户连接
*
* @param devNo 客户端ID
**/
private void removeUser(String devNo) {
SSE_CACHE.remove(devNo);
// 登录对象移除
log.info("SseEmitterServiceImpl[removeUser]:移除用户:{}", devNo);
}
}
3.3、消息类型
/**
* 客户端操作类型记录
* @author xuancg
*/
public enum ClientOptType {
/** 保持联通性*/
PING("保持联通性"),
/** 创建支付通知 */
CREATE_PAY("创建支付通知"),
/**检测项完成通知*/
CHECK_ITEM_SUCCESS("检测项完成通知"),
/** 检测完成通知 */
CHECK_COMPLETE("检测完成通知"),
/**反馈接收通知 */
RECEIVE_RESP_ID("反馈接收通知"),
/**终端激活通知*/
ACTIVE_SUCCESS("终端激活"),
/**终端上传日志*/
UPLOAD_LOG("终端上传日志"),
/**服务端关闭已经存在的上一个连接*/
CLOSE_CONNECT("关闭连接"),
CONNECT_SUCCESS("登录成功"),
private String desc;
ClientOptType(String desc) {
this.desc = desc;
}
}
3.4、消息对象
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/***
* 返回客户端通知对象
* @author xuancg
*/
@Data
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ClientNotifyResp implements Serializable {
/** 返回状态码 code200=成功 500=异常*/
private int code;
private String msg;
private Object data;
/**操作类型*/
private ClientOptType optType;
public ClientNotifyResp(ClientOptType optType, Object data) {
this.data = data;
this.optType = optType;
this.code = HttpStatus.SUCCESS;
}
}
4、Redis订阅发布
如果服务端为单机模式,则不需要redis或者其他消息中间件
详见附件资源
5、客户端连接
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SSE 客户端测试</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-2xl font-bold mb-4">SSE 客户端测试</h1>
<div class="mb-4">
<label for="devNo" class="block text-gray-700 mb-2">客户端编号:</label>
<input type="text" id="devNo" value="11111"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
<label for="devKey" class="block text-gray-700 mb-2">客户端密钥:</label>
<input type="text" id="devKey" value="80f326f450f8ba0717bc6a071bc"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
<button id="connectBtn" class="mt-2 bg-blue-500 text-white px-4 py-2 rounded-md">
建立连接
</button>
<button id="disconnectBtn" class="mt-2 bg-red-500 text-white px-4 py-2 rounded-md ml-2" disabled>
断开连接
</button>
</div>
<div class="mb-4">
<label for="message" class="block text-gray-700 mb-2">发送消息:</label>
<input type="text" id="message" placeholder="输入要发送的消息"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
<button id="sendBtn" class="mt-2 bg-green-500 text-white px-4 py-2 rounded-md" disabled>
发送消息
</button>
</div>
<div>
<h2 class="text-xl font-semibold mb-2">接收消息:</h2>
<div id="messageContainer" class="border border-gray-300 rounded-md p-4 h-64 overflow-y-auto bg-gray-50">
<!-- 消息将显示在这里 -->
</div>
</div>
</div>
<script>
let eventSource;
const devNoInput = document.getElementById('devNo');
const devKeyInput = document.getElementById('devKey');
const messageInput = document.getElementById('message');
const messageContainer = document.getElementById('messageContainer');
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
const sendBtn = document.getElementById('sendBtn');
const host = "http://127.0.0.1:8081"
const devNo = devNoInput.value.trim();
const devKey = devKeyInput.value.trim();
// 建立连接
connectBtn.addEventListener('click', () => {
if (!devNo) {
alert('请输入客户端ID');
return;
}
// 关闭已存在的连接
if (eventSource) {
eventSource.close();
}
// 建立新连接
eventSource = new EventSource(host + `/app/sse/createConnect/${devNo}/${devKey}`);
// 连接成功
eventSource.onopen = () => {
addMessage('连接已建立');
connectBtn.disabled = true;
disconnectBtn.disabled = false;
sendBtn.disabled = false;
};
// 接收消息
eventSource.onmessage = (event) => {
addMessage(`收到消息: ${event.data}`);
};
// 处理特定类型的事件
eventSource.addEventListener('connect', (event) => {
addMessage(`系统消息: ${event.data}`);
});
eventSource.addEventListener('timer', (event) => {
addMessage(`定时消息: ${event.data}`);
});
// 连接错误
eventSource.onerror = (error) => {
addMessage(`连接错误: ${error}`);
eventSource.close();
connectBtn.disabled = false;
disconnectBtn.disabled = true;
sendBtn.disabled = true;
};
});
// 断开连接
disconnectBtn.addEventListener('click', () => {
if (eventSource) {
eventSource.close();
fetch(host + `/app/sse/closeConnect/${devNo}/${devKey}`)
.then(response => response.text())
.then(message => addMessage(message));
}
connectBtn.disabled = false;
disconnectBtn.disabled = true;
sendBtn.disabled = true;
});
// 发送消息
sendBtn.addEventListener('click', () => {
const devNo = devNoInput.value.trim();
const message = messageInput.value.trim();
if (!message) {
alert('请输入消息内容');
return;
}
fetch(`/sse/send/${devNo}`, {
method: 'POST',
headers: {
'Content-Type': 'text/plain'
},
body: message
})
.then(response => response.text())
.then(result => {
addMessage(`发送状态: ${result}`);
messageInput.value = '';
});
});
// 添加消息到页面
function addMessage(text) {
const timestamp = new Date().toLocaleTimeString();
const messageElement = document.createElement('div');
messageElement.className = 'mb-2 p-2 border-b border-gray-200';
messageElement.innerHTML = `<span class="text-gray-500">[${timestamp}]</span> ${text}`;
messageContainer.appendChild(messageElement);
messageContainer.scrollTop = messageContainer.scrollHeight;
}
</script>
</body>
</html>
6、Nginx代理配置
与一般http请求存在差异,需要给sse配置代理
location /sse/createConnect {
add_header X-XSS-Protection "1;mode=block";
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $http_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Scheme $scheme;
proxy_set_header Accept-Encoding "";
proxy_ssl_session_reuse on;
proxy_redirect off;
proxy_ignore_client_abort on;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 86400s;
# SSE 连接时的超时时间
proxy_buffering off;
proxy_cache off;
gzip off;
add_header Access-Control-Allow-Origin *;
add_header Cache-Control no-cache;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:8091;
}