前端后端的消息推送 7 种方式

消息推送 7 种方式

看了原文章后,自己写的笔记
参考

https://mp.weixin.qq.com/s/xQV81HiD8hkzx2wMc3ZT7A

需求

先说需求,实现一个站内信的web消息推送功能,就是这个小红点,有消息来了,就+1
在这里插入图片描述

消息推送的常用方式

在这里插入图片描述

什么是消息推送

推送的场景比较多,比如有人关注我的公众号,这时我就会收到一条推送消息,以此来吸引我点击打开应用。

消息推送(push)通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备APP进行的主动消息推送。

消息推送一般又分为web端消息推送移动端消息推送

在这里插入图片描述

上边的这种属于移动端消息推送

web端消息推送常见的诸如站内信、未读邮件数量、监控报警数量等,应用的也非常广泛。

在这里插入图片描述

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

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

在这里插入图片描述

消息推送无非是推(push)和拉(pull)两种形式

短轮询

轮询(polling)应该是实现消息推送方案中最简单的一种,也就是客户端一直请求服务端,相当于实时监听的效果,这里我们暂且将轮询分为短轮询长轮询

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

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

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

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

长轮询

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

可以了解下 Nacos的长轮询实现原理:https://mp.weixin.qq.com/s/94ftESkDoZI9gAGflLiGwg

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

在这里插入图片描述

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

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

相当于,前端页面间隔一段时间发请求,数据没有更新就返回304,一旦有数据变化,就会返回200,直接通知客户端;

package com.ung.realtime.controller.polling;

import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.async.DeferredResult;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;

import static com.ung.realtime.controller.polling.AsyncConfig.TIME_OUT;

/**
 * @author: wenyi
 * @create: 2022/10/9
 * @Description: 长轮询
 */
@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) {
        if (watchRequests.containsKey(id)) {
            Collection<DeferredResult<String>> deferredResults = watchRequests.get(id);
            for (DeferredResult<String> deferredResult : deferredResults) {
                DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
                deferredResult.setResult("我更新了" + LocalDateTime.now().format(formatter));
            }
        }
        return "success";
    }
}

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

package com.ung.realtime.controller.polling;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;


/**
 * 全局捕获异步请求超时
 */
@ControllerAdvice
public class AsyncRequestTimeoutHandler {

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

配置异步线程池

package com.ung.realtime.controller.polling;


import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 设置异步线程池
 */
@Component
public class AsyncConfig implements WebMvcConfigurer {

    public static final long TIME_OUT = 10000L;

    @Bean
    public ThreadPoolTaskExecutor workerTaskExecutor(){
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(25);
        executor.setQueueCapacity(100);
        return executor;
    }

    @Override
    public void configureAsyncSupport(AsyncSupportConfigurer configurer){
        configurer.setTaskExecutor(workerTaskExecutor());
        configurer.setDefaultTimeout(TIME_OUT);
    }
}

前端页面

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE html
    PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-CN">

<head>
    <title>未读消息</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js" type="text/javascript"></script>
<!--    <script src="http://libs.baidu.com/jquery/2.0.1/jquery.min.js" type="text/javascript"></script>-->
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.1/jquery.min.js" type="text/javascript"></script>
    <script src="/vue.min.js" type="text/javascript"></script>
    <script src="/js/index.js"></script>
    <link rel="stylesheet" href="/style.css" type="text/css" />
    <link rel="stylesheet" href="/push.css" type="text/css" />

    <script>

        function sendMessage() {
            $.ajax({
                url: '/polling/publish/10086',
                type: 'GET',
                success: function (data) {
                    console.log(data)
                },
                error: function (err) {
                    console.log(err)
                },
                done: function () {

                }
            })
        }

        function startLongPolling() {
            $.ajax({
                url: '/polling/watch/10086',
                type: 'GET',
                success: function (response, status, xhr) {
                    console.log(xhr.status);   //状态码 304 再次发起长轮询
                    if (xhr.status == 304) {
                        startLongPolling();
                    }
                    if (xhr.status == 200) {
                        $("#arrivedDiv").append("<br/>" + response);
                        var count = $("#count").text();
                        count = Number(count) + 1;
                        $("#count").text(count);
                        // 调用查询API
                        startLongPolling();
                    }
                }
            })
        }

    </script>
</head>


<body>
    <div>
        <ul>
            <li class="active"><i class="fa fa-home fa-lg"></i> 未读消息 <span id="count" class="unread">0</span></li>

        </ul>
        <div style="margin-left: 800px;">
            <button class="button" onclick="sendMessage()"> 变更数据</button>

            <button class="button" id="mySendBtn" onclick="startLongPolling()"> 开始长轮询</button>
            <div style="font-size: 20px;color: darkcyan"> 接收到的消息</div>
            <hr />
            <div id="arrivedDiv" style="height:200px; width:300px; overflow:scroll; background:#EEEEEE;">
                <br />
            </div>
        </div>
    </div>
</body>

</html>

http://localhost:8001/polling.html

点击开始长轮询,前端页面一直会发送请求

在这里插入图片描述

直到有数据变更,点击变更数据,监听key10086的DeferredResult 发生变化,就会返回消息,红色数字+1

在这里插入图片描述

iframe流

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

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

在这里插入图片描述

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

src路径就是请求服务器的路径接口;

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

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

package com.ung.realtime.controller.iframe;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author: wenyi
 * @create: 2022/10/10
 * @Description: 使用 iframe 流的方式
 */
@CrossOrigin("*")//跨域注解,*表示所有域名都可以访问
@Controller
@RequestMapping("/iframe")
public class IframeController {
    private AtomicInteger count = new AtomicInteger();

    /**
     * iframe 页面
     */
    @RequestMapping("/index")
    public String sse() {
        return "iframe";
    }

    @GetMapping(path = "message")
    public void message(HttpServletResponse response) throws IOException {
        while (true) {
            count.incrementAndGet();
            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>");
        }
    }
}

使用效果:

不推荐,因为它在浏览器上会显示请求未加载完,图标会不停旋转

在这里插入图片描述

SSE

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

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

在这里插入图片描述

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

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

在这里插入图片描述

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

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

SSE 与 WebSocket 该如何选择?

SSE好像一直不被大家所熟知,一部分原因是出现了WebSockets,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。

但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE不管是从实现的难易和成本上都更加有优势。此外,SSE 具有WebSockets在设计上缺乏的多种功能,例如:自动重新连接事件ID发送任意事件的能力。

前端只需进行一次HTTP请求,带上唯一ID,打开事件流,监听服务端推送的事件就可以了

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE html
    PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-CN">

<head>
    <title>未读消息</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js" type="text/javascript"></script>
<!--    <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js" type="text/javascript"></script>-->
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.1/jquery.min.js" type="text/javascript"></script>
    <script src="/vue.min.js" type="text/javascript"></script>
    <script src="/js/index.js"></script>
    <link rel="stylesheet" href="/style.css" type="text/css" />
    <link rel="stylesheet" href="/push.css" type="text/css" />

    <script>
        let source = null;

        // 用时间戳模拟登录用户
        const userId = new Date().getTime();

        if (window.EventSource) {

            // 建立连接
            source = new EventSource('http://localhost:8001/sse/sub/7777');
            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);
            });


            /**
             * 如果发生通信错误(比如连接中断),就会触发error事件
             * 或者:
             * 另一种写法:source.onerror = function (event) {}
             */
            source.addEventListener('error', function (e) {
                if (e.readyState === EventSource.CLOSED) {
                    setMessageInnerHTML("连接关闭");
                } else {
                    console.log(e);
                }
            }, false);

        } else {
            setMessageInnerHTML("你的浏览器不支持SSE");
        }

        // 监听窗口关闭事件,主动去关闭sse连接,如果服务端设置永不过期,浏览器关闭后手动清理服务端数据
        window.onbeforeunload = function () {
            closeSse();
        };

        // 关闭Sse连接
        function closeSse() {
            source.close();
            const httpRequest = new XMLHttpRequest();
            httpRequest.open('GET', 'http://localhost:8001/sse/close/' + userId, true);
            httpRequest.send();
            console.log("close");
        }

        // 将消息显示在网页上
        function setMessageInnerHTML(innerHTML) {
            $("#arrivedDiv").append("<br/>"+innerHTML);
            var count = $("#count").text();
            count = Number(count) + 1;
            $("#count").text(count);
        }


        function sendMessage() {
            var content = $("#message").val();
            $.ajax({
                url: '/sse/push',
                type: 'GET',
                data: { "id": "7777", "content": content },
                success: function (data) {

                    console.log(data)
                },
                error: function (err) {

                },
                done: function () {

                }
            })
        }


    </script>
</head>


<body>
    <div>
        <ul>
            <li class="active"><i class="fa fa-home fa-lg"></i> 未读消息 <span id="count" class="unread">0</span></li>

        </ul>
        <div style="margin-left: 800px;">
            <input style="height: 25px; width: 180px;" maxlength="60" value="" id="message" />
            <button class="button" id="mySendBtn" onclick="sendMessage()"> 点击发送</button>
            <div style="font-size: 20px;color: darkcyan"> 接收到的mqtt消息</div>
            <hr />
            <div id="arrivedDiv" style="height:200px; width:300px; overflow:scroll; background:#EEEEEE;">
                <br />
            </div>
        </div>
    </div>
</body>

</html>

后台创建一个SseEmitter对象放入sseEmitterMap进行管理

package com.ung.realtime.controller.sse;

import cn.hutool.core.map.MapUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;

/**
 * @author: wenyi
 * @create: 2022/10/10
 * @Description:
 */
@Slf4j
@Component
public class SseEmitterUtils {
    /**
     * 当前连接数
     */
    private static AtomicInteger count = new AtomicInteger(0);

    /**
     * 使用map对象,便于根据userId来获取对应的SseEmitter,或者放redis里面
     */
    private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();

    /**
     * 创建用户连接并返回 SseEmitter
     *
     * @param userId 用户ID
     * @return SseEmitter
     */
    public static SseEmitter connect(String userId) {

        if (sseEmitterMap.containsKey(userId)) {
            return sseEmitterMap.get(userId);
        }
        try {
            /**
             * 设置超时时间,0表示不过期。默认30秒
             */
            SseEmitter sseEmitter = new SseEmitter(0L);
            /**
             * 注册回调
             */
            sseEmitter.onCompletion(completionCallBack(userId));
            sseEmitter.onError(errorCallBack(userId));
            sseEmitter.onTimeout(timeoutCallBack(userId));
            sseEmitterMap.put(userId, sseEmitter);
            /**
             * 数量+1
             */
            count.getAndIncrement();

            return sseEmitter;
        } catch (Exception e) {
            log.info("创建新的sse连接异常,当前用户:{}", userId);
        }
        return null;
    }

    /**
     * 给指定用户发送消息
     */
    public static void sendMessage(String userId, String message) {

        if (sseEmitterMap.containsKey(userId)) {
            try {
                sseEmitterMap.get(userId).send(message);
            } catch (IOException e) {
                log.error("用户[{}]推送异常:{}", userId, e.getMessage());
                removeUser(userId);
            }
        }
    }

    /**
     * 向同组人发布消息   (要求userId+groupId)
     */
    public static void groupSendMessage(String groupId, String message) {

        if (MapUtil.isNotEmpty(sseEmitterMap)) {
            sseEmitterMap.forEach((k, v) -> {
                try {
                    if (k.startsWith(groupId)) {
                        v.send(message, MediaType.APPLICATION_JSON);
                    }
                } catch (IOException e) {
                    log.error("用户[{}]推送异常:{}", k, e.getMessage());
                    removeUser(k);
                }
            });
        }
    }

    /**
     * @date: 2022/7/12 14:51
     * @auther: 公众号:程序员小富
     */
    public static void batchSendMessage(String message) {
        sseEmitterMap.forEach((k, v) -> {
            try {
                v.send(message, MediaType.APPLICATION_JSON);
            } catch (IOException e) {
                log.error("用户[{}]推送异常:{}", k, e.getMessage());
                removeUser(k);
            }
        });
    }

    /**
     * 群发消息
     */
    public static void batchSendMessage(String message, Set<String> ids) {
        ids.forEach(userId -> sendMessage(userId, message));
    }

    /**
     * 移除用户连接
     */
    public static void removeUser(String userId) {
        sseEmitterMap.remove(userId);
        // 数量-1
        count.getAndDecrement();
        log.info("移除用户:{}", userId);
    }

    /**
     * 获取当前连接信息
     */
    public static List<String> getIds() {
        return new ArrayList<>(sseEmitterMap.keySet());
    }

    /**
     * 获取当前连接数量
     */
    public static int getUserCount() {
        return count.intValue();
    }

    private static Runnable completionCallBack(String userId) {
        return () -> {
            log.info("结束连接:{}", userId);
            removeUser(userId);
        };
    }

    private static Runnable timeoutCallBack(String userId) {
        return () -> {
            log.info("连接超时:{}", userId);
            removeUser(userId);
        };
    }

    private static Consumer<Throwable> errorCallBack(String userId) {
        return throwable -> {
            log.info("连接异常:{}", userId);
            removeUser(userId);
        };
    }
}

请求接口

package com.ung.realtime.controller.sse;

import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@CrossOrigin("*")
@Controller
@RequestMapping("/sse")
public class SSEController {

    /**
     * sse 页面
     */
    @RequestMapping("/index")
    public String sse() {
        return "sse";
    }

    /**
     * sse 订阅消息
     */
    @GetMapping(path = "sub/{id}", produces = {MediaType.TEXT_EVENT_STREAM_VALUE})
    @ResponseBody
    public SseEmitter sub(@PathVariable String id) {

        return SseEmitterUtils.connect(id);
    }

    /**
     * sse 发布消息
     */
    @GetMapping(path = "push")
    @ResponseBody
    public void push(String id, String content) {
        SseEmitterUtils.sendMessage(id, content);
    }

    /**
     * 关闭连接
     */
    @ResponseBody
    @GetMapping(path = "breakConnect")
    public void breakConnect(String id, HttpServletRequest request, HttpServletResponse response) {
        request.startAsync();
        SseEmitterUtils.removeUser(id);
    }
}

在这里插入图片描述

SSE不支持IE浏览器,对其他主流浏览器兼容性做的还不错。

MQTT

什么是 MQTT协议?

MQTT 全称(Message Queue Telemetry Transport):一种基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息,是物联网(Internet of Thing)中的一个标准传输协议。

该协议将消息的发布者(publisher)与订阅者(subscriber)进行分离,因此可以在不可靠的网络环境中,为远程连接的设备提供可靠的消息服务,使用方式与传统的MQ有点类似。

在这里插入图片描述

TCP协议位于传输层,MQTT 协议位于应用层,MQTT 协议构建于TCP/IP协议上,也就是说只要支持TCP/IP协议栈的地方,都可以使用MQTT协议。

为什么要用 MQTT协议?

MQTT协议为什么在物联网(IOT)中如此受偏爱?而不是其它协议,比如我们更为熟悉的 HTTP协议呢?

  • 首先HTTP协议它是一种同步协议,客户端请求后需要等待服务器的响应。而在物联网(IOT)环境中,设备会很受制于环境的影响,比如带宽低、网络延迟高、网络通信不稳定等,显然异步消息协议更为适合IOT应用程序。
  • HTTP是单向的,如果要获取消息客户端必须发起连接,而在物联网(IOT)应用程序中,设备或传感器往往都是客户端,这意味着它们无法被动地接收来自网络的命令。
  • 通常需要将一条命令或者消息,发送到网络上的所有设备上。HTTP要实现这样的功能不但很困难,而且成本极高。

具体的MQTT协议介绍和实践,参考两篇文章,里边写的也都很详细了

MQTT协议的介绍

我也没想到 springboot + rabbitmq 做智能家居,会这么简单

MQTT实现消息推送

未读消息(小红点),前端 与 RabbitMQ 实时消息推送实践,贼简单~

Websocket

websocket应该是大家都比较熟悉的一种实现消息推送的方式,上边我们在讲SSE的时候也和websocket进行过比较。

WebSocket是一种在TCP连接上进行全双工通信的协议,建立客户端和服务器之间的通信渠道。浏览器和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

在这里插入图片描述

springboot整合websocket,先引入websocket相关的工具包,和SSE相比额外的开发成本。

<!-- 引入websocket -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

服务端使用@ServerEndpoint注解标注当前类为一个websocket服务器,客户端可以通过ws://localhost:8001/webSocket/10086来连接到WebSocket服务器端。

package com.ung.realtime.controller.socket;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArraySet;

@Component
@Slf4j
// 接口路径 ws://localhost:8001/webSocket/userId;
@ServerEndpoint("/websocket/{userId}")
public class WebSocketServer {

    //与某个客户端的连接会话,需要通过它来给客户端发送数据
    private Session session;

    private static final CopyOnWriteArraySet<WebSocketServer> webSockets = new CopyOnWriteArraySet<>();

    // 用来存在线连接数
    private static final Map<String, Session> sessionPool = new HashMap<String, Session>();

    /**
     * 链接成功调用的方法
     */
    @OnOpen
    public void onOpen(Session session, @PathParam(value = "userId") String userId) {
        try {
            this.session = session;
            webSockets.add(this);
            sessionPool.put(userId, session);
            log.info("【websocket服务端】有新的连接,总数为:" + webSockets.size());
        } catch (Exception e) {
        }
    }

    /**
     * 链接关闭调用的方法
     */
    @OnClose
    public void onClose() {
        try {
            webSockets.remove(this);
            log.info("【websocket服务端】连接断开,总数为:" + webSockets.size());
        } catch (Exception e) {
        }
    }

    /**
     * 收到客户端消息后调用的方法
     */
    @OnMessage
    public void onMessage(String message) {
        log.info("【websocket服务端】收到客户端消息:" + message);
    }

    /**
     * 发送错误时的处理
     */
    @OnError
    public void onError(Session session, Throwable error) {
        error.printStackTrace();
    }

    /**
     * 此为广播消息
     */
    public void sendAllMessage(String message) {
        log.info("【websocket服务端】广播消息:" + message);
        for (WebSocketServer webSocket : webSockets) {
            try {
                if (webSocket.session.isOpen()) {
                    webSocket.session.getAsyncRemote().sendText(message);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 此为单点消息
     */
    public void sendOneMessage(String userId, String message) {
        Session session = sessionPool.get(userId);
        if (session != null && session.isOpen()) {
            try {
                log.info("【websocket服务端】 单点消息:" + message);
                session.getAsyncRemote().sendText(message);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 此为单点消息(多人)
     */
    public void sendMoreMessage(String[] userIds, String message) {
        for (String userId : userIds) {
            Session session = sessionPool.get(userId);
            if (session != null && session.isOpen()) {
                try {
                    log.info("【websocket服务端】 单点消息:" + message);
                    session.getAsyncRemote().sendText(message);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

配置类

package com.ung.realtime.controller.socket;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfig {
    /**
     * 这个bean会自动注册使用了@ServerEndpoint注解声明的Websocket endpoint
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

controller接口

package com.ung.realtime.controller.socket;

import cn.hutool.json.JSONObject;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.annotation.Resource;

@CrossOrigin("*")
@Controller
@RequestMapping("/socket")
public class SocketOpsController {

    @Resource
    private WebSocketServer webSocket;

    @RequestMapping("/index")
    public String sse() {
        return "socket";
    }

    /**
     * 公众号:程序员小富
     * 变更数据
     */
    @GetMapping(path = "publish")
    @ResponseBody
    public String publish(String message, String userId) {
        //创建业务消息信息
        JSONObject obj = new JSONObject();
        webSocket.sendOneMessage(userId, message);
        return "success";
    }
}

前端初始化打开WebSocket连接,并监听连接状态,接收服务端数据或向服务端发送数据。

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE html
        PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-CN">

<head>
    <title>未读消息</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js" type="text/javascript"></script>
<!--    <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js" type="text/javascript"></script>-->
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.1/jquery.min.js" type="text/javascript"></script>
    <script src="/vue.min.js" type="text/javascript"></script>
    <script src="/js/index.js"></script>
    <link rel="stylesheet" href="/style.css" type="text/css"/>
    <link rel="stylesheet" href="/push.css" type="text/css"/>

    <script>
        var ws = new WebSocket('ws://127.0.0.1:8001/websocket/10086');
        // 获取连接状态
        console.log('ws连接状态:' + ws.readyState);
        //监听是否连接成功
        ws.onopen = function () {
            console.log('ws连接状态:' + ws.readyState);
            //连接成功则发送一个数据
            ws.send('test1');
        }
        // 接听服务器发回的信息并处理展示
        ws.onmessage = function (data) {
           $("#arrivedDiv").append("<br/>"+data.data);
            var count = $("#count").text();
            count = Number(count) + 1;
            $("#count").text(count);
            //完成通信后关闭WebSocket连接
            //ws.close();
        }
        // 监听连接关闭事件
        ws.onclose = function () {
            // 监听整个过程中websocket的状态
            console.log('ws连接状态:' + ws.readyState);
        }
        // 监听并处理error事件
        ws.onerror = function (error) {
            console.log(error);
        }

        function sendMessage() {
            var content = $("#message").val();
            $.ajax({
                url: '/socket/publish?userId=10086&message='+content,
                type: 'GET',
                success: function (data) {
                    console.log(data)
                }
            })
        }


    </script>
</head>


<body>
<div>
    <ul>
        <li class="active"><i class="fa fa-home fa-lg"></i> 未读消息 <span id="count" class="unread">0</span></li>

    </ul>
    <div style="margin-left: 800px;">
        <input style="height: 25px; width: 180px;" maxlength="60" value="" id="message"/>
        <button class="button" id="mySendBtn" onclick="sendMessage()"> 点击发送</button>
        <div style="font-size: 20px;color: darkcyan"> 接收到的websocket消息</div>
        <hr/>
        <div id="arrivedDiv" style="height:200px; width:300px; overflow:scroll; background:#EEEEEE;">
            <br/>
        </div>
    </div>
</div>
</body>

</html>

页面初始化建立websocket连接,之后就可以进行双向通信

在这里插入图片描述

自定义推送

使用第三推送平台,goEasy、极光推送等三方服务商。

  • 11
    点赞
  • 85
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值