# nginx反向代理配置
# sse连接配置
location /admin-api/sse {
proxy_pass http://host.docker.internal:48081/admin-api/sse;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection ''; # 确保 Nginx 不会断开 SSE 连接
chunked_transfer_encoding on; # 确保传输支持 chunked encoding
}
# 如果使用nginx可以这样,但是如果是阿里云的alb等等中间件就不可以了
# 解决方案
思路:将SSE连接时间调整为网关超时时间的最大值,断开后自动重连即可
实际就是之前是永不超时,现在是超时自动重连
# 如果下面方案不适用,请参考如下连接,http2降级http1.1
https://blog.csdn.net/AinUser/article/details/154446622
# 上代码(支持分布式)
# 前端代码
import request from '@/config/axios'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { getAccessToken } from '@/utils/auth'
import { config } from '@/config/axios/config'
export const SSEApi = {
// 创建SSE连接(支持超时和错误自动重连)
createSseConnection: async (atId: number, userId: number, onMessage, onError?, onOpen?) => {
const token = getAccessToken()
const domain = `${config.base_url}`
// 重连配置
const maxRetries = 3 // 最大重连次数
const initialRetryDelay = 1000 // 初始重连延迟 (ms)
const maxRetryDelay = 30000 // 最大重连延迟 (ms)
const connectionTimeout = 60 * 1000 // 连接超时时间 (ms)
let retryCount = 0
let retryDelay = initialRetryDelay
let isClosed = false
const connect = () => {
return new Promise((resolve, reject) => {
if (isClosed) {
reject(new Error('连接已关闭'))
return
}
const ctrl = new AbortController()
let connectionEstablished = false
let hasError = false
// 设置连接超时
const timeoutId = setTimeout(() => {
if (!connectionEstablished) {
hasError = true
ctrl.abort()
handleReconnect(userId)
}
}, connectionTimeout)
fetchEventSource(domain + '/sse/connect/' + atId + '/' + userId, {
method: 'get',
openWhenHidden: true,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
onopen: (response) => {
clearTimeout(timeoutId)
connectionEstablished = true
retryCount = 0 // 重置重试计数
retryDelay = initialRetryDelay // 重置重试延迟
if (onOpen) {
onOpen(response)
}
// 检查响应状态
if (response.status >= 400) {
hasError = true
handleReconnect(userId)
}
},
onmessage: (event) => {
if (!hasError) {
onMessage(event)
}
},
onerror: (error) => {
clearTimeout(timeoutId)
hasError = true
if (onError) {
onError(error)
}
handleReconnect(userId)
},
signal: ctrl.signal
}).catch((err) => {
clearTimeout(timeoutId)
if (!connectionEstablished) {
handleReconnect(userId)
}
})
})
}
// 处理重连逻辑
const handleReconnect = (userId: number) => {
if (isClosed || retryCount >= maxRetries) {
console.error(`SSE连接失败,已达到最大重试次数 ${maxRetries}`)
return
}
retryCount++
console.log(`${userId} - SSE连接断开,${retryDelay / 1000}秒后第 ${retryCount} 次重连...`)
setTimeout(() => {
if (!isClosed) {
// 指数退避策略,但不超过最大延迟
retryDelay = Math.min(retryDelay * 2, maxRetryDelay)
connect()
}
}, retryDelay)
}
// 开始连接
connect()
// 返回关闭函数
return {
close: () => {
isClosed = true
}
}
},
// 向指定用户发送消息
sendSseMessage : (fromUserId: number, toUserId: number, message: string) => {
return request.post({ url: '/sse/send/' + fromUserId + '/' + toUserId, data: message })
},
// 关闭SSE连接
closeSseConnection : (userId: number) => {
return request.get({ url: '/sse/close/' + userId })
}
}
# 后端代码(支持分布式)
@CrossOrigin
@RestController
@RequestMapping("/sse")
public class SSEController {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 添加Redis消息监听容器
@Resource
private RedisMessageListenerContainer redisMessageListenerContainer;
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
// 添加Redis消息频道常量
private static final String CHANNEL_PREFIX = "sse:channel:";
private static String USER_CONNECTION_KEY = "sse:connections";
// Redis消息监听方法
@PostConstruct
public void initRedisListener() {
// 创建消息监听器适配器
MessageListenerAdapter listenerAdapter = new MessageListenerAdapter(new MessageListener() {
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel());
String userIds = channel.substring(CHANNEL_PREFIX.length());
String fromUserId = userIds.split("-")[0];
String toUserId = userIds.split("-")[1];
String messageContent = new String(message.getBody());
// 这里需要实现本地SSE连接的管理和消息发送
System.out.println("用户[" + toUserId + "]收到Redis消息: " + messageContent);
// 关键:从本地缓存获取 emitter 并发送消息
SseEmitter emitter = emitters.get(toUserId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event()
.id(String.valueOf(System.currentTimeMillis()))
.data(fromUserId + ":" + messageContent) // 前端需要fromUserId开关麦
.reconnectTime(5000L));
System.out.println("向用户[" + toUserId + "]本地发送消息成功");
} catch (IOException e) {
System.out.println("向用户[" + toUserId + "]发送消息失败: " + e.getMessage());
// 处理发送异常(例如连接已断开)
emitters.remove(toUserId);
stringRedisTemplate.opsForSet().remove(USER_CONNECTION_KEY, toUserId);
}
} else {
System.out.println("用户[" + toUserId + "]本地连接不存在,无法发送消息");
}
}
});
// 订阅所有用户的消息频道
redisMessageListenerContainer.addMessageListener(listenerAdapter,
PatternTopic.of(CHANNEL_PREFIX + "*"));
}
/**
* 创建SSE连接
*/
@PermitAll // 解决 SSE 最终响应的时候,会被 Access Denied 拦截的问题
@GetMapping(value = "/connect/{atId}/{userId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter createConnection(@PathVariable String atId, @PathVariable String userId) {
// 设置12小时后超时(单位:毫秒) 0L表示永不超时
long timeout = 0L;
SseEmitter emitter = new SseEmitter(timeout);
if (!USER_CONNECTION_KEY.contains("-")) {
USER_CONNECTION_KEY += "-" + atId;
}
// 注册回调函数
emitter.onCompletion(() -> {
System.out.println("[" + userId + "]连接完成");
// 从Redis和本地缓存移除连接信息
stringRedisTemplate.opsForSet().remove(USER_CONNECTION_KEY, userId);
emitters.remove(userId);
});
emitter.onTimeout(() -> {
System.out.println("[" + userId + "]连接超时");
stringRedisTemplate.opsForSet().remove(USER_CONNECTION_KEY, userId);
emitters.remove(userId);
});
emitter.onError(throwable -> {
System.out.println("[" + userId + "]连接异常: " + throwable.getMessage());
stringRedisTemplate.opsForSet().remove(USER_CONNECTION_KEY, userId);
emitters.remove(userId);
});
// 将用户连接信息存储到Redis和本地缓存
stringRedisTemplate.opsForSet().add(USER_CONNECTION_KEY, userId);
emitters.put(userId, emitter); // 添加此行,将emitter存入本地缓存
System.out.println("用户[" + userId + "]SSE连接创建成功");
return emitter;
}
/**
* 向指定用户发送消息
*/
@PermitAll // 解决 SSE 最终响应的时候,会被 Access Denied 拦截的问题
@PostMapping("/send/{fromUserId}/{toUserId}")
public boolean sendMessage(@PathVariable String fromUserId, @PathVariable String toUserId, @RequestBody String message) {
// 检查用户是否在线
Boolean isOnline = stringRedisTemplate.opsForSet().isMember(USER_CONNECTION_KEY, toUserId);
if (Boolean.FALSE.equals(isOnline)) {
System.out.println("用户[" + toUserId + "]未建立连接");
return false;
}
try {
// 通过Redis发布消息,所有节点都会收到
String channel = CHANNEL_PREFIX + fromUserId + "-" + toUserId;
stringRedisTemplate.convertAndSend(channel, message);
System.out.println("向用户[" + toUserId + "]发送消息成功: " + message);
return true;
} catch (Exception e) {
System.out.println("向用户[" + toUserId + "]发送消息失败: " + e.getMessage());
return false;
}
}
/**
* 主动关闭连接
*/
@PermitAll // 解决 SSE 最终响应的时候,会被 Access Denied 拦截的问题
@GetMapping("/close/{userId}")
public void closeConnection(@PathVariable String userId) {
SseEmitter emitter = emitters.get(userId);
if (emitter != null) {
emitter.complete();
emitters.remove(userId);
stringRedisTemplate.opsForSet().remove(USER_CONNECTION_KEY, userId);
System.out.println("用户[" + userId + "]连接已关闭");
}
}
}