背景
前几天写了一个音频/视频格式转换工具,其中需要实现一个进度条功能。轮询方式存在资源浪费和延迟问题,而 WebSocket 则显得过于"重量级",于是就想到Server Send Event,也就是SSE来实现。
什么是SSE?
SSE(Server-Sent Events)是一种服务器消息推送技术,是HTML5标准协议中的一部分,类似WebSocket,不同在于WebSocket可以双向通信,SSE只能服务器向浏览器发送消息。具体可以查看 w3schools。 SSE 与 WebSocket 作用相似,都是建立浏览器与服务器之间的通信渠道,然后服务器向浏览器推送信息。
SSE 相较 WebSocket 的一些优点:
-
简单易用:
- SSE 是基于 HTTP 协议的一种轻量级通信方式,使用简单直观。客户端通过普通的 HTTP 请求(GET 请求)与服务器建立连接,并通过事件流(Event Stream)持续接收服务器推送的数据。
- 相比之下,WebSocket 需要经过握手等复杂的过程来建立连接,使用起来相对复杂一些。
-
支持性好:
- SSE 是 HTML5 规范的一部分,现代浏览器普遍支持 SSE 技术,不需要额外的插件或库。
- WebSocket 在一些特殊环境(如防火墙、代理服务器)下可能会受到限制,需要特殊配置或使用代理协议。
-
单向通信:
- SSE 是单向通信,只能由服务器向客户端推送数据,适用于服务器向客户端实时更新数据的场景,如实时消息、实时通知等。
- WebSocket 是全双工通信,客户端和服务器可以双向发送和接收数据,适用于需要双向通信的应用场景,如在线游戏、实时聊天等。
-
自动重连:
- SSE 内置了自动重连机制。如果连接断开或中断,客户端会自动尝试重新建立连接,从而保持持续的事件流。
- WebSocket 需要通过编程实现断线重连的逻辑,相对而言稍显复杂。
-
适合服务器推送场景:
- SSE 主要用于服务器推送数据给客户端的场景,例如实时更新的股票价格、新闻推送、实时监控数据等。
- WebSocket 则更适合需要双向通信和实时交互的场景,例如在线游戏、在线聊天等。
尽管 SSE 有上述优点,但它也有一些限制:
- SSE 是基于 HTTP 的长轮询机制,因此无法实现客户端到服务器的双向通信。
- SSE 不支持像 WebSocket 那样的自定义协议和更复杂的消息传输方式。
- SSE 的消息传输使用的是文本格式,不能传输二进制数据。
总体来说,WebSocket 更强大和灵活。因为它是全双工通道,可以双向通信;SSE 是单向通道,只能服务器向浏览器发送,因为流信息本质上就是下载。如果浏览器向服务器发送信息,就变成了另一次 HTTP 请求。SSE和 WebSocket 都是用于实现服务器与客户端之间实时通信的技术,但它们在设计和应用场景上有一些区别,具有不同的优点和适用性。
实现
1、服务端实现
创建spring boot项目,引入以下依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
创建FileUploadController
类,代码如下
java
@Controller
public class FileUploadController {
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
@GetMapping("/test")
public String progressBarTest(Model model) {
model.addAttribute("ratio", "0");
return "test";
}
@PostMapping("/upload")
@ResponseBody
public String filesUpload(@RequestParam String file) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
emitters.put(file, emitter);
CompletableFuture.supplyAsync(() -> {
try {
//模拟文件处理进度
int progress = 0;
while (progress < 100) {
Thread.sleep(1000);
progress += 20;
emitter.send(SseEmitter.event().data(progress)); // 发送进度更新
System.out.println("已处理" + progress + "%");
}
} catch (Exception e) {
System.err.println("向 SSE 发送消息时出现错误: " + e);
emitter.completeWithError(e); // 发送错误消息并完成 SSE
}
return file;
}).thenAccept(output -> completeEmitter(emitter, "文件处理完成", file));
return file;
}
@GetMapping(value = "/progress/{emitterId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@ResponseBody
public SseEmitter getProgress(@PathVariable String emitterId) {
if (emitterId == null) return null;
return getProgressEmitter(emitterId);
}
private SseEmitter getProgressEmitter(String emitterId) {
SseEmitter emitter = emitters.get(emitterId);
if (emitter == null) {
emitter = createErrorMessageEmitter(emitterId);
}
return emitter;
}
private SseEmitter createErrorMessageEmitter(String emitterId) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
completeEmitter(emitter, "无效的进度查询", emitterId);
return emitter;
}
private void completeEmitter(SseEmitter emitter, String message, String emitterId) {
try {
emitter.send(SseEmitter.event().data(message));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
} finally {
emitters.remove(emitterId);
}
}
}
代码解释
-
这里使用了一个
ConcurrentHashMap
来存储文件上传的SseEmitter
对象,String
类型的key是文件名,SseEmitter
是用于服务器发送事件(SSE)的对象。 -
使用
filesUpload
方法中,通过@RequestParam
获取上传的文件名file
,然后使用SseEmitter
模拟文件处理进度,通过异步任务CompletableFuture.supplyAsync
处理文件,并实时发送处理进度到客户端。最后返回file
作为后续请求的emitterId
, -
使用
getProgress
方法:处理/progress/{emitterId}
的请求,根据emitterId
从emitters
中获取相应的SseEmitter
对象,用于实时获取文件处理进度。
2、前端页面实现
本例前端页面使用到jquery
html
<head>
<meta charset="UTF-8">
<title>Test</title>
<!--引入jquery-->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
完成进度条的显示
html
<div>
<!-- 进度条显示 -->
<div class="progress" th:if="${ratio != null}">
<p>Progress: <span id="ratio" th:text="${ratio}"></span></p>
<div class="progressBar">
<div id="bar" th:style="'width: ' + ${ratio} + '%; background-color: lightblue;'">
</div>
</div>
</div>
<button id="submitButton">Submit</button>
</div>
最后是进度的接收
javascript
<script th:inline="javascript">
$(document).ready(function () {
$('#submitButton').click(function () {
let fileName = "testFile";
let formData = new FormData();
formData.append('file', fileName);
$.ajax({
url: '/upload',
type: 'POST',
processData: false,
contentType: false,
data: formData
}).then(emitterId => {
let eventSource = new EventSource('/progress/' + emitterId);
eventSource.onopen = function () {
console.log('EventSource connected');
};
eventSource.onmessage = function (event) {
let ratio = event.data;
if (ratio % 1 === 0) {
$('#ratio').text(ratio + '%');
} else {
$('#ratio').text(ratio);
eventSource.close();
console.log('EventSource closed');
}
$('#bar').css('width', ratio + '%');
};
eventSource.onerror = function () {
console.log('EventSource error');
eventSource.close();
};
});
});
});
</script>
代码解释
-
在点击按钮后,首先使用
ajax
方法发送 POST 请求到/upload
接口,传递fileName
作为请求数据。其中的processData: false
和contentType: false
配置 告诉 jQuery 不要处理发送的数据(因为数据已经是FormData
格式)。 -
如果文件上传请求成功 (
then(emitterId => {...})
),则会执行一个回调函数。在这个回调函数中,会以返回的emitterId
创建一个EventSource
对象,用于建立到服务器的事件源连接 (/progress/${emitterId}
)。成功建立连接后onmessage
方法接收到服务器推送的进度数据,并更新页面上的进度条 (#bar
) 和显示比例 (#ratio
)。
效果展示
完整代码已上传到github:progress-bar-demo,需要源代码的伙伴可以去看看。
具体实现可参考easy-format-converter