SpringBoot之实现Web消息实时消息推送

1 实时消息推送

1.1 消息推送

推送的场景比较多,比如有人关注公众号,这时就会收到一条推送消息,以此来吸引点击打开应用。
消息推送(push)通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备APP进行的主动消息推送。
消息推送一般又分为web端消息推送移动端消息推送

在具体实现之前,咱们再来分析一下前边的需求,其实功能很简单,只要触发某个事件(主动分享了资源或者后台主动推送消息),web页面的通知小红点就会实时的+1就可以了。

通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。

1.2 准备sql

CREATE TABLE `message_record` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`template_id` bigint unsigned NOT NULL COMMENT '消息模板ID',
`type` int NOT NULL DEFAULT '1' COMMENT'推送渠道1短信2邮件3微信 4APP',
`receiver` varchar(128) NOT NULL DEFAULT '' COMMENT'消息接收者(手机号,邮箱号,微信openid等) ',
`device_info`varchar(128) NOT NULL DEFAULT '' COMMENT 'APP推送终端设备信息',
`content` varchar(1024) NOT NULL COMMENT'''消息推送内容',
`deleted`tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:1删除;0未删除',
`create_by` bigint unsigned NOT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` bigint unsigned NOT NULL COMMENT'修改人',
`update_time` datetime NOT NULL COMMENT'修改时间',
PRIMARY KEY (`id`),
KEY `idx_template_id`(`template_id`),
KEY `idx_receiver`(`receiver`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='消息推送包录表'

1.3 短轮询

轮询(polling)应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询长轮询

短轮询很好理解,指定的时间间隔,由浏览器向服务器发出HTTP请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。

一个简单的JS定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。

setInterval(() => {
  // 方法请求
  messageCount().then((res) => {
      if (res.code === 200) {
          this.messageCount = res.data
      }
  })
}, 1000);

效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。

1.4 长轮询

1.4.1 简介

长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如Nacos和apollo配置中心,消息队列kafka、RocketMQ中都有用到长轮询。

这次使用apollo配置中心实现长轮询的方式,应用了一个类DeferredResult,它是在servelet3.0后经过Spring封装提供的一种异步请求机制,直意就是延迟结果
图片

DeferredResult 可以允许容器线程快速释放占用的资源,不阻塞请求线程,以此接受更多的请求提升系统的吞吐量,然后启动异步工作线程处理真正的业务逻辑,处理完成调用DeferredResult.setResult(200)提交响应结果。

1.4.2 代码示例

下边我们用长轮询来实现消息推送。
因为一个ID可能会被多个长轮询请求监听,所以采用了guava包提供的Multimap结构存放长轮询,一个key可以对应多个value。一旦监听到key发生变化,对应的所有长轮询都会响应。前端得到非请求超时的状态码,知晓数据变更,主动查询未读消息数接口,更新页面数据。

@Controller
@RequestMapping("/polling")
public class PollingController {

    // 存放监听某个Id的长轮询集合
    // 线程同步结构
    public static Multimap<String, DeferredResult<String>> watchRequests = Multimaps.synchronizedMultimap(HashMultimap.create());

    /**
     * 设置监听
     */
    @GetMapping(path = "watch/{id}")
    @ResponseBody
    public DeferredResult<String> watch(@PathVariable String id) {
        // 延迟对象设置超时时间
        DeferredResult<String> deferredResult = new DeferredResult<>(TIME_OUT);
        // 异步请求完成时移除 key,防止内存溢出
        deferredResult.onCompletion(() -> {
            watchRequests.remove(id, deferredResult);
        });
        // 注册长轮询请求
        watchRequests.put(id, deferredResult);
        return deferredResult;
    }

    /**
     * 变更数据
     */
    @GetMapping(path = "publish/{id}")
    @ResponseBody
    public String publish(@PathVariable String id) {
        // 数据变更 取出监听ID的所有长轮询请求,并一一响应处理
        if (watchRequests.containsKey(id)) {
            Collection<DeferredResult<String>> deferredResults = watchRequests.get(id);
            for (DeferredResult<String> deferredResult : deferredResults) {
                deferredResult.setResult("我更新了" + new Date());
            }
        }
        return "success";
    }
}

当请求超过设置的超时时间,会抛出AsyncRequestTimeoutException异常,这里直接用@ControllerAdvice全局捕获统一返回即可,前端获取约定好的状态码后再次发起长轮询请求,如此往复调用。

@ControllerAdvice
public class AsyncRequestTimeoutHandler {

    @ResponseStatus(HttpStatus.NOT_MODIFIED)
    @ResponseBody
    @ExceptionHandler(AsyncRequestTimeoutException.class)
    public String asyncRequestTimeoutHandler(AsyncRequestTimeoutException e) {
        System.out.println("异步请求超时");
        return "304";
    }
}

测试一下,首先页面发起长轮询请求/polling/watch/10086监听消息更变,请求被挂起,不变更数据直至超时,再次发起了长轮询请求;紧接着手动变更数据/polling/publish/10086,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。

长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。

1.5 iframe流

iframe流就是在页面中插入一个隐藏的<iframe>标签,通过在src中请求消息数量API接口,由此在服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。

传输的数据通常是HTML、或是内嵌的javascript脚本,来达到实时更新页面的效果。
图片

这种方式实现简单,前端只要一个<iframe>标签搞定了

<iframe src="/iframe/message" style="display:none"></iframe>

服务端直接组装html、js脚本数据向response写入就行了

@Controller
@RequestMapping("/iframe")
public class IframeController {
    @GetMapping(path = "message")
    public void message(HttpServletResponse response) throws IOException, InterruptedException {
        while (true) {
            response.setHeader("Pragma", "no-cache");
            response.setDateHeader("Expires", 0);
            response.setHeader("Cache-Control", "no-cache,no-store");
            response.setStatus(HttpServletResponse.SC_OK);
            response.getWriter().print(" <script type=\"text/javascript\">\n" +
                    "parent.document.getElementById('clock').innerHTML = \"" + count.get() + "\";" +
                    "parent.document.getElementById('count').innerHTML = \"" + count.get() + "\";" +
                    "</script>");
        }
    }
}

但个人不推荐,因为它在浏览器上会显示请求未加载完,图标会不停旋转,简直是强迫症杀手。
图片

1.6 SSE

1.6.1 简介

很多人可能不知道,服务端向客户端推送消息,其实除了可以用 WebSocket 这种耳熟能详的机制外,还有一种服务器发送事件(Server-sent events),简称SSE

SSE它是基于HTTP协议的,我们知道一般意义上的HTTP协议是无法做到服务端主动向客户端推送消息的,但SSE是个例外,它变换了一种思路。

SSE在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream类型的数据流信息,在有数据变更时从服务器流式传输到客户端。

整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。
图片

1.6.2 与 WebSocket 区别

SSEWebSocket作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:

  • SSE 是基于HTTP协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket需单独服务器来处理协议。
  • SSE 单向通信,只能由服务端向客户端单向通信;webSocket全双工通信,即通信的双方可以同时发送和接受信息。
  • SSE 实现简单开发成本低,无需引入其他组件;WebSocket传输数据需做二次解析,开发门槛高一些。
  • SSE 默认支持断线重连;WebSocket 则需要自己实现。
  • SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket 默认支持传送二进制数据。

SSE好像一直不被大家所熟知,一部分原因是出现了WebSockets,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。
但是,在某些情况下,不需要从客户端发送数据。而只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE不管是从实现的难易和成本上都更加有优势。此外,SSE 具有WebSockets在设计上缺乏的多种功能,例如:自动重新连接、事件ID和发送任意事件的能力。

1.6.3 与轮询、WebSocket 对比

特性 SSE (Server-Sent Events) 轮询 (Polling) WebSocket
通讯方向 单向:服务器向客户端推送数据 单向:客户端定时请求服务器获取数据 双向:客户端与服务器双向通讯
连接类型 长连接:使用 HTTP/1.1 或 HTTP/2 长连接 短连接:每次请求都是独立的 HTTP 请求 长连接:通过 TCP 建立的持久连接
传输协议 HTTP/1.1 或 HTTP/2 HTTP TCP(通过 WebSocket 协议升级)
浏览器支持 广泛支持(HTML5 原生支持) 全面支持,所有浏览器均支持 广泛支持(需通过 WebSocket API 实现)
消息发送频率 服务器可随时推送数据 客户端主动定时请求,频率取决于客户端设置 实时、即时双向发送
服务器开销 较低:只需保持一个长连接 高:每次请求都需要重新建立 HTTP 连接 较高:需保持 TCP 连接,管理心跳包
客户端开销 低:只需处理服务器推送的数据 高:每次请求都会产生额外的资源消耗 中等:需保持与服务器的连接
传输数据格式 纯文本、JSON 等 纯文本、JSON 等 任意格式(二进制、文本等)
连接恢复机制 自动恢复:连接中断时,客户端会自动重新连接 无自动恢复,需客户端重新发起请求 需开发者自行实现重连机制
适用场景 适合实时数据推送、通知、监控等单向通讯场景 适用于低频率、对实时性要求不高的场景 适合即时通讯、协作应用等双向通讯场景
复杂度 简单:实现轻量且代码量少 简单:代码量少,但性能较差 复杂:需处理更多逻辑,如握手、心跳等
实时性 高:服务器可立即推送消息 低:依赖客户端的请求频率 高:双向即时通讯
防火墙/代理兼容性 高:使用标准 HTTP 协议,通常无额外配置需求 高:使用标准 HTTP 协议 可能较低:需配置防火墙或代理来允许 TCP 连接
带宽消耗 较低:保持长连接,但只在有数据时传输 较高:每次轮询都会消耗资源和带宽 较低:保持长连接,数据按需传输

1.6.4 代码示例

SseEmitter 和 Flux 具体对比

特性 SseEmitter 实现 Flux 实现
连接管理 手动管理,每个用户对应一个连接 由框架自动管理,无需手动维护
消息粒度 精确到用户,支持个性化推送 广播式推送,所有用户接收相同的流数据
错误处理 手动处理超时、错误等情况 框架支持自动恢复/错误处理
扩展性 用户数增加时,管理成本和内存占用变高 基于 Reactor,具备良好的并发支持
复杂度 较高,需要额外逻辑处理 较低,关注数据流的生成即可
适用场景 单用户定制化推送,例如个人通知 广播消息,例如系统状态或统计数据的更新

1.6.4.1 基于 SseEmitter

SseEmitter 是 下面介绍的 ResponseBodyEmitter 的一个子类,它同样能够实现动态内容生成,不过主要将它用在服务器向客户端推送实时数据,如实时消息推送、状态更新等场景。
前端只需进行一次HTTP请求,带上唯一ID,打开事件流,监听服务端推送的事件就可以了

<script>
    let source = null;
    let userId = 7777
    if (window.EventSource) {
        // 建立连接
        source = new EventSource('http://localhost:7777/sse/sub/'+userId);
        setMessageInnerHTML("连接用户=" + userId);
        /**
         * 连接一旦建立,就会触发open事件
         * 另一种写法:source.onopen = function (event) {}
         */
        source.addEventListener('open', function (e) {
            setMessageInnerHTML("建立连接。。。");
        }, false);
        /**
         * 客户端收到服务器发来的数据
         * 另一种写法:source.onmessage = function (event) {}
         */
        source.addEventListener('message', function (e) {
            setMessageInnerHTML(e.data);
        });
    } else {
        setMessageInnerHTML("你的浏览器不支持SSE");
    }
</script>

服务端的实现更简单,创建一个SseEmitter对象放入sseEmitterMap进行管理

@RestController
@RequestMapping("/sse")
public class SseController {
	private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();

/**
 * 创建连接
 */
 @GetMapping("/connect/{userId}")
public SseEmitter connect(@PathVariable String userId) {
    try {
        // 设置超时时间,0表示不过期。默认30秒
        SseEmitter sseEmitter = new SseEmitter(0L);
        sseEmitter.onCompletion(() -> {
            System.out.println("连接完成:" + userId);
            sseEmitterMap.remove(userId);
        });
        sseEmitter.onTimeout(() -> {
            System.out.println("连接超时:" + userId);
            sseEmitterMap.remove(userId);
        });
        sseEmitter.onError((e) -> {
            System.out.println("连接出错:" + userId);
            sseEmitterMap.remove(userId);
        });
        sseEmitterMap.put(userId, sseEmitter);
        return sseEmitter;
       
    } catch (Exception e) {
        log.info("创建新的sse连接异常,当前用户:{}", userId);
    }
    return null;
}

/**
 * 给指定用户发送消息
 */
 @PostMapping("/send/{userId}")
public ResponseEntity<String> sendMessage(@PathVariable String userId, @RequestBody String message) {
        SseEmitter sseEmitter = sseEmitterMap.get(userId);
		if (sseEmitter == null) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("用户未连接");
        }
        try {
            sseEmitter.send(SseEmitter.event().data(message));
            return ResponseEntity.ok("消息发送成功");
        } catch (IOException e) {
            sseEmitterMap.remove(userId); // 发送失败,移除用户
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("消息发送失败");
        }
}
/**
     * 关闭连接
     */
    @DeleteMapping("/disconnect/{userId}")
    public ResponseEntity<String> disconnect(@PathVariable String userId) {
        SseEmitter sseEmitter = sseEmitterMap.get(userId);
        if (sseEmitter != null) {
            sseEmitter.complete();
            sseEmitterMap.remove(userId);
            return ResponseEntity.ok("连接已断开");
        }
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body("用户未连接");
    }
}

我们模拟服务端推送消息,看下客户端收到了消息,和我们预期的效果一致。
注意SSE不支持IE浏览器,对其他主流浏览器兼容性做的还不错

1.6.4.2 基于 Flux 的实现

@GetMapping(value = "/user-activity-sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Integer> streamUserActivitySSE() {
    // 创建一个持续时间为指定秒数的 Flux 流
    return Flux.interval(Duration.ofSeconds(5))
            .flatMap(sequence -> Mono
                    .fromCallable(onlineUserService::getUserActivityNum) // 返回在线用户人数
                    .onErrorReturn(0)
            );
}

1.6.5 ResponseBodyEmitter

1.6.5.1 简介

ResponseBodyEmitter 并非新技术,早在 Spring Framework 4.2 版本就已被引入。它是 Spring 框架提供的轻量级流式传输解决方案,它能够显著提升高并发和实时性场景下的用户体验。通过 ResponseBodyEmitter,我们可以轻松实现服务器向客户端的实时数据推送。无论是进度条的实时更新、实时聊天、股票价格的实时监控还是系统日志的流式输出,ResponseBodyEmitter 都能帮助我们构建更加动态和互动的应用程序。

ResponseBodyEmitter 的作用

  • 相较于 SSE 技术,ResponseBodyEmitter 更加简单易用。它主要用于处理异步的 HTTP 响应,其核心优势在于 允许逐步将数据发送到客户端,而非一次性发送所有内容。这一特性使得它在需要长时间处理或进行流式传输的场景中表现出色。需要注意的是,ResponseBodyEmitter 本质上是一个接口。
  • 使用场景
    • 长轮询:服务器在有数据时会立即响应客户端请求,若暂无数据,则保持连接开放,等待数据到来。
    • 服务器推送事件 (SSE):服务器能够持续不断地向客户端推送各类事件,实现实时交互。
    • 流式传输:可逐步发送大量数据,像文件下载或者实时数据流传输等场景都适用。
    • 异步处理:在处理耗时任务时,能逐步返回处理结果,避免客户端长时间等待,提升用户体验。
  • 业务场景举例
    在实际业务中,ResponseBodyEmitter 有着广泛的应用,比如进度条的实时更新、实时聊天功能、股票价格的实时更新、系统日志的流式输出以及 AI 的流式响应等。

与 Streaming 和 SSE 的对比

  • Streaming:直接通过 OutputStream 向客户端写入数据,灵活性较高,但需要手动处理流的关闭,增加了开发的复杂度。
  • Server-Sent Events (SSE):基于 text/event-stream 协议,适用于服务端事件推送场景,但要求客户端支持 SSE 协议。
  • ResponseBodyEmitter:通用性更强,适用于任何支持 HTTP 的客户端,并且易于与 Spring 框架集成,是一种更为便捷的流式传输解决方案。

注意事项:

  • 客户端支持:虽然大多数浏览器和 HTTP 客户端库都支持分块传输,但某些老旧的客户端可能存在兼容性问题。
  • 超时设置:为避免长连接长时间占用资源,可以为 ResponseBodyEmitter 设置超时时间,示例代码如下:
    emitter.onTimeout(() -> emitter.complete());
  • 线程安全:ResponseBodyEmitter 的 send() 方法是线程安全的,但在使用时需要注意控制任务线程的生命周期,避免出现资源泄漏。
  • 连接关闭:务必确保在任务结束时调用 complete() 或 completeWithError() 方法,否则可能导致连接无法正常关闭,造成资源浪费。

1.6.5.2 操作示例

接下来,我们通过一个简单的实时日志流功能,来深入了解 ResponseBodyEmitter 的使用。假设我们有一个应用程序,需要实时查看服务器的日志,以便快速定位和解决问题。

首先,我们在 Spring Boot 应用中创建一个控制器,借助 ResponseBodyEmitter 实现实时日志流。

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;

@RestController
@RequestMapping("/api/log")
publicclass LogController {
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseBodyEmitter streamLogs() {
        ResponseBodyEmitter emitter = new ResponseBodyEmitter();

        // 开启异步线程处理数据并发送
        new Thread(() -> {
            try {
                while (true) {
                    String logEntry = getLatestLogEntry();
                    if (logEntry != null) {
                        emitter.send(logEntry);
                    }
                    // 每秒检查一次日志更新
                    Thread.sleep(1000); 
                }
            } catch (Exception e) {
                // 出现异常时结束响应并传递错误信息
                emitter.completeWithError(e); 
            }
        }).start();

        return emitter;
    }

    private String getLatestLogEntry() {
        // 模拟从日志文件中获取最新日志条目
        return"2025-02-12 12:00:00 - INFO: User logged in successfully.";
    }
}

当我们启动这个应用程序,并访问 /api/log/stream 路径时,就能看到一个实时更新的日志流。服务器会每秒向客户端推送一条新的日志条目,客户端会将其显示在页面上,效果如下:

1.6.5.3 ResponseBodyEmitter 核心方法

核心方法:

  • send(Object data):向客户端发送数据,该方法可以多次调用,实现数据的逐步发送。
  • complete():用于结束响应流,表示数据已经全部发送完毕。
  • onTimeout(Runnable callback):设置超时回调函数,当连接超时时,会执行该回调。
  • onCompletion(Runnable callback):设置完成回调函数,当数据发送完成后,会执行该回调。

1.6.5.4 ResponseBodyEmitter 工作原理

工作原理:

  • 异步数据生成与推送
    在传统的 HTTP 请求 - 响应模式中,服务器通常需要等待整个响应数据生成完成后,才会将其一次性发送给客户端。而 ResponseBodyEmitter 打破了这种模式,它允许服务端在任务执行过程中异步地生成响应数据。
    当有部分数据准备好时,就可以立即调用 send() 方法将这些数据推送给客户端,而无需等待整个任务完成。这就好比一场接力赛,每完成一段赛程(生成一部分数据),就马上将接力棒(数据)传递给客户端,大大提高了数据传输的实时性。
  • 分块传输机制
    ResponseBodyEmitter 采用了 HTTP 的分块编码(Chunked Encoding)方式来传输数据。在传统的 HTTP 响应中,通常需要在响应头中明确指定 Content-Length,表示整个响应数据的长度。但在分块传输中,服务器不会提前设置 Content-Length,而是将数据分成多个独立的块,每个块都有自己的长度标识。
    客户端在接收到数据块后,可以立即对其进行处理,而不必等待整个响应数据接收完毕。这种方式使得数据可以边生成边传输,减少了客户端的等待时间,提高了用户体验。
  • 连接生命周期管理
    为了确保资源的合理使用,ResponseBodyEmitter 提供了对连接生命周期的有效管理。当所有数据都发送完毕后,需要调用 complete() 方法来明确告知客户端响应结束,关闭连接。如果在数据传输过程中出现异常,可以调用 completeWithError() 方法,结束响应并向客户端传递错误信息。
    这样可以避免连接长时间保持开放,造成资源浪费。

1.6.6 StreamingResponseBody

StreamingResponseBody 与其他响应处理方式略有不同,主要用于处理大数据量持续数据流的传输,支持将数据直接写入OutputStream
例如,当我们需要下载一个超大文件时,使用 StreamingResponseBody 可以避免将文件数据一次性加载到内存中,而是持续不断的把文件流发送给客户端,从而解决下载大文件时常见的内存溢出问题。
接口实现直接返回 StreamingResponseBody 对象,将数据写入输出流并刷新,调用一次flush就会向客户端写入一次数据。

@GetMapping("/streamingResponse")
public ResponseEntity<StreamingResponseBody> handleRbe() {
    StreamingResponseBody stream = out -> {
        String message = "streamingResponse";
        for (int i = 0; i < 1000; i++) {
            try {
                out.write(((message + i) + "\r\n").getBytes());
                out.write("\r\n".getBytes());
                //调用一次flush就会像前端写入一次数据
                out.flush();
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    };
    return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(stream);
}

demo这里输出的是简单的文本流,如果是下载文件那么转换成文件流效果是一样的

1.7 MQTT

点击此处了解 MQTT 详解

1.8 WebSocket

点击了解 SpringBoot集成WebSocket讲解

posted @ 2023-10-10 09:17  上善若泪  阅读(2349)  评论(0)    收藏  举报