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封装提供的一种异步请求机制,直意就是延迟结果

image.png

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脚本,来达到实时更新页面的效果。

image.png

这种方式实现简单,前端只要一个<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>");
        }
    }
}

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


image.png

1.6 SSE

1.6.1 简介

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

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

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

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


image.png

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

前端只需进行一次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.7 MQTT

点击此处了解 MQTT 详解

1.8 WebSocket

点击了解 SpringBoot集成WebSocket讲解

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,122评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,070评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,491评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,636评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,676评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,541评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,292评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,211评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,655评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,846评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,965评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,684评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,295评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,894评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,012评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,126评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,914评论 2 355

推荐阅读更多精彩内容