背景介绍:
最近在ai面试项目时需要用到消息实时推送技术,了解到有两种实时通信技术供我选择:SSE和WebSocket。详细了解后得知SSE是基于http协议,无需导入其他依赖,特点是服务端主动给客户端推送消息(单向),适合浏览器端只做数据接收。而websocket特点是客户端和服务端实现双工通信(双向),多用于即时通信。基于项目特点,我选择了Sse。而且springboot还整合了sse类名SseEmitter,使用简单方便,服务端推送消息我们采用SSE方式进行推送。
关于SSE:
1. 概念介绍
sse(Server Sent Event),直译为服务器发送事件,顾名思义,也就是客户端可以获取到服务器发送的事件
我们常见的 http 交互方式是客户端发起请求,服务端响应,然后一次请求完毕;但是在 sse 的场景下,客户端发起请求,连接一直保持,服务端有数据就可以返回数据给客户端,这个返回可以是多次间隔的方式
2. 特点分析
SSE 最大的特点,可以简单规划为两个
长连接
服务端可以向客户端推送信息
了解 websocket 的小伙伴,可能也知道它也是长连接,可以推送信息,但是它们有一个明显的区别
sse 是单通道,只能服务端向客户端发消息;而 webscoket 是双通道
那么为什么有了 webscoket 还要搞出一个 sse 呢?既然存在,必然有着它的优越之处
3. 应用场景
从 sse 的特点出发,我们可以大致的判断出它的应用场景,需要轮询获取服务端最新数据的 case 下,多半是可以用它的
比如显示当前网站在线的实时人数,法币汇率显示当前实时汇率,电商大促的实时成交额等等…
我们这里是硬件回调服务端接口插入报警数据的同时需要推送给前端进行提示。
以下是记录:
1、事件消息体:
@Getter
@Setter
public class InterviewStatusEvent {
private String seeId;
/**
* 消息体
*/
private InterviewStatusDTO interviewStatusDTO;
public InterviewStatusEvent(String seeId,InterviewStatusDTO interviewStatusDTO) {
this.seeId=seeId;
this.interviewStatusDTO=interviewStatusDTO;
}
}
/**
* 面试状态DTO
*/
@Data
public class InterviewStatusDTO implements Serializable {
/**
* 是否面试结束
*/
private Boolean isInterviewOver;
/**
* 是否回答结束
*/
private Boolean isAnswerOver;
/**
* 是否发送gpt
*/
private Boolean isSendGpt;
}
2、事件监听回调类:
/**
* 面试状态监听
*/
@Slf4j
@Component
public class InterviewStatusListener {
/**
* 当前连接数,后续放redis里面
*/
private static AtomicInteger count = new AtomicInteger(0);
/**
* 使用map对象,便于根据userId来获取对应的SseEmitter,后续放redis里面
*/
private static Map sseEmitters = new ConcurrentHashMap<>();
public SseEmitter addSseEmitter(String id) {
log.info("InterviewStatusListener.addSseEmitter-->id:{}", id);
SseEmitter sseEmitter = null;
try {
// 设置超时时间,0表示不过期。默认30秒,超过时间未完成会抛出异常:AsyncRequestTimeoutException
sseEmitter = new SseEmitter(0l);
sseEmitters.put(id, sseEmitter);
sseEmitter.onError(errorCallBack(id));
sseEmitter.onTimeout(timeoutCallBack(id));
sseEmitter.onCompletion(completionCallback(id));
count.getAndIncrement();
} catch (Exception e) {
log.error("InterviewStatusListener.addSseEmitter-->", e);
}
log.info("InterviewStatusListener.addSseEmitter-->sseEmitter:{}", sseEmitter);
return sseEmitter;
}
/**
* 发送时间
*
* @param interviewStatusEvent
*/
public void sendEvent(InterviewStatusEvent interviewStatusEvent) {
String id = interviewStatusEvent.getSeeId();
SseEmitter sseEmitter = sseEmitters.get(id);
if (ObjectUtils.isEmpty(sseEmitter)) {
log.warn("InterviewStatusListener.deployEventHandler-->sseEmitter is empty!");
return;
}
try {
sseEmitter.send(interviewStatusEvent.getInterviewStatusDTO());
} catch (Exception e) {
log.error("InterviewStatusListener.deployEventHandler-->", e);
}
}
/**
* 监听结束
*
* @param id
*/
public void completeEvent(String id) {
SseEmitter sseEmitter = sseEmitters.get(id);
if (ObjectUtils.isEmpty(sseEmitter)) {
log.warn("InterviewStatusListener.completeEvent-->sseEmitter is empty!");
return;
}
try {
sseEmitter.complete();
} catch (Exception e) {
sseEmitter.completeWithError(e);
log.error("InterviewStatusListener.completeEvent-->", e);
}
}
/**
* 移除监听
*
* @param id
*/
private void removeListener(String id) {
sseEmitters.remove(id);
// 数量-1
count.getAndDecrement();
log.info("InterviewStatusListener.removeListener-->id:{}", id);
}
/**
* 连接超时回调
*
* @param id
* @return
*/
private Runnable timeoutCallBack(String id) {
return () -> {
log.info("InterviewStatusListener.timeoutCallBack-->id:{}", id);
removeListener(id);
};
}
/**
* 完成回调
*
* @param id
*/
private Runnable completionCallback(String id) {
return () -> {
log.info("InterviewStatusListener.completionCallback-->id:{}", id);
removeListener(id);
};
}
/**
* 连接异常回调
*
* @param id
* @return
*/
private Consumer errorCallBack(String id) {
return throwable -> {
log.info("InterviewStatusListener.errorCallBack-->id:{}", id);
removeListener(id);
};
}
/**
* 获取当前连接信息
*/
public static List getIds() {
return new ArrayList<>(sseEmitters.keySet());
}
/**
* 获取当前连接数量
*/
public static Integer getUserCount() {
return count.intValue();
}
}
3、controller提供消息订阅接口(即sse通信连接)
/**
* 订阅sse消息监视面试状态
*
* @param id
* @return
*/
@ApiOperation(value = "订阅sse消息监视面试状态")
@GetMapping(path = "/subscribe-status", produces = {MediaType.TEXT_EVENT_STREAM_VALUE})
public SseEmitter subscribeInterviewStatus(@RequestParam("id") String id) {
return aiInterviewFacade.subscribeInterviewStatus(id);
}
4、具体逻辑处理
@Override
public SseEmitter subscribeInterviewStatus(String id) {
SseEmitter emitter = interviewStatusListener.addSseEmitter(id);
return emitter;
}
5、发送信息:
private void sendInterviewStatus(Boolean isInterviewOver) {
InterviewStatusDTO interviewStatusDTO = aiInterviewAssembler.buildInterviewStatusDTO(isInterviewOver);
//发送客户端面试结束
interviewStatusListener.sendEvent(new InterviewStatusEvent(TEST_SSE_ID, interviewStatusDTO));
}
6、提供接口请求调用发送消息,效果
四、一些问题
前端sse连接不上:可能是跨域了,ie浏览器不支持
可以初始化设置sse超时时间 SseEmitter sseEmitter = new SseEmitter(3600_000L); 默认为2分钟,
每两分钟后端会报超时org.springframework.web.context.request.async.AsyncRequestTimeoutException: null,但会立即重新连接上来
前端每次source.onmessage后会走source.onerror然后重新source.onopen