Server Send Event(SSE)简单实现进度条功能

背景

前几天写了一个音频/视频格式转换工具,其中需要实现一个进度条功能。轮询方式存在资源浪费和延迟问题,而 WebSocket 则显得过于"重量级",于是就想到Server Send Event,也就是SSE来实现。

什么是SSE?

SSE(Server-Sent Events)是一种服务器消息推送技术,是HTML5标准协议中的一部分,类似WebSocket,不同在于WebSocket可以双向通信,SSE只能服务器向浏览器发送消息。具体可以查看 w3schools。 SSE 与 WebSocket 作用相似,都是建立浏览器与服务器之间的通信渠道,然后服务器向浏览器推送信息。

SSE 相较 WebSocket 的一些优点:

  1. 简单易用

    • SSE 是基于 HTTP 协议的一种轻量级通信方式,使用简单直观。客户端通过普通的 HTTP 请求(GET 请求)与服务器建立连接,并通过事件流(Event Stream)持续接收服务器推送的数据。
    • 相比之下,WebSocket 需要经过握手等复杂的过程来建立连接,使用起来相对复杂一些。
  2. 支持性好

    • SSE 是 HTML5 规范的一部分,现代浏览器普遍支持 SSE 技术,不需要额外的插件或库。
    • WebSocket 在一些特殊环境(如防火墙、代理服务器)下可能会受到限制,需要特殊配置或使用代理协议。
  3. 单向通信

    • SSE 是单向通信,只能由服务器向客户端推送数据,适用于服务器向客户端实时更新数据的场景,如实时消息、实时通知等。
    • WebSocket 是全双工通信,客户端和服务器可以双向发送和接收数据,适用于需要双向通信的应用场景,如在线游戏、实时聊天等。
  4. 自动重连

    • SSE 内置了自动重连机制。如果连接断开或中断,客户端会自动尝试重新建立连接,从而保持持续的事件流。
    • WebSocket 需要通过编程实现断线重连的逻辑,相对而言稍显复杂。
  5. 适合服务器推送场景

    • 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}的请求,根据emitterIdemitters中获取相应的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;'">
                &nbsp;
            </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: falsecontentType: false 配置 告诉 jQuery 不要处理发送的数据(因为数据已经是 FormData 格式)。

  • 如果文件上传请求成功 (then(emitterId => {...})),则会执行一个回调函数。在这个回调函数中,会以返回的emitterId创建一个 EventSource 对象,用于建立到服务器的事件源连接 (/progress/${emitterId})。成功建立连接后onmessage方法接收到服务器推送的进度数据,并更新页面上的进度条 (#bar) 和显示比例 (#ratio)。

效果展示

完整代码已上传到github:progress-bar-demo,需要源代码的伙伴可以去看看。

具体实现可参考easy-format-converter

相关推荐
小池先生27 分钟前
springboot启动不了 因一个spring-boot-starter-web底下的tomcat-embed-core依赖丢失
java·spring boot·后端
小蜗牛慢慢爬行2 小时前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate
wm10432 小时前
java web springboot
java·spring boot·后端
龙少95434 小时前
【深入理解@EnableCaching】
java·后端·spring
溟洵6 小时前
Linux下学【MySQL】表中插入和查询的进阶操作(配实操图和SQL语句通俗易懂)
linux·运维·数据库·后端·sql·mysql
SomeB1oody8 小时前
【Rust自学】6.1. 定义枚举
开发语言·后端·rust
SomeB1oody8 小时前
【Rust自学】5.3. struct的方法(Method)
开发语言·后端·rust
啦啦右一10 小时前
Spring Boot | (一)Spring开发环境构建
spring boot·后端·spring
森屿Serien10 小时前
Spring Boot常用注解
java·spring boot·后端
盛派网络小助手12 小时前
微信 SDK 更新 Sample,NCF 文档和模板更新,更多更新日志,欢迎解锁
开发语言·人工智能·后端·架构·c#