基于SSE的全平台可用WebPush方案

613 阅读4分钟

因为众所周知的原因,chrome的浏览器推送始终不能用。本文推荐一种使用SSE技术替代厂商通道实现全平台浏览器推送的方案。

原理

浏览器推送

浏览器推送.png

SSE推送

SSE推送.png

增加成本

  1. redis,服务端需要单例或集群部署的redis实例(可与其他服务共用);
  2. sse-broker,一个或多个sse-broker实例,每个实例占用10M内存,每个连接可按2k内存计算;

步骤

安装运行sse-broker

  1. 下载sse-broker:Releases · sssxyd/go-sse-broker
  2. 安装,windows下解压,linux下 rpm -Uvh xxx.rpm
  3. 编辑config.toml windows下在解压所在目录,linux下在 /etc/sse-broker/config.toml
  4. 运行服务,windows下双击sse-broker.exe, linux下 systemctl start sse-broker

config.toml需要编辑以下配置项

[server]
port = 8080

[jwt]
secret = "please_modify"

[redis]
addrs = ["please_modify_1:6379", "please_modify_2:6379"]
password = "please_modify"
db = 0

配置Nginx/Openresty

vim your_website.conf

        # 暴露 sse-broker 的api,生产环境不建议 
        location ~ ^/(info|send|kick|token)$ {
                proxy_redirect off;
                proxy_set_header        Host $host;
                proxy_set_header        X-Real-IP $remote_addr;
                proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;

                proxy_pass http://your_sse_work_ip:8080$request_uri;
        }

        # sse-broker 长连接地址
        location /events {
                proxy_redirect off;
                proxy_set_header        Host $host;
                proxy_set_header        X-Real-IP $remote_addr;
                proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;

                # 关闭缓冲
                proxy_buffering off;
                proxy_cache off;

                # 超时配置,确保超时时间大于心跳间隔时间
                proxy_read_timeout 600s;

                proxy_pass http://your_sse_work_ip:8080/events;
        }

运行Demo页面

  1. 复制附录中的 sse-demo.html 到你的web站点根目录下
  2. 浏览器访问:your.domain.com/sse-demo.ht…
  3. 输入uid并连接sse服务
  4. 向另一个uid发送通知或消息

image.png

对比

本方案以当前站点的私有SSE通道替代了浏览器厂商的推送通道,优缺点如下:

缺点

  • 当前站点关闭后,sse通道也关闭,无法推送;而浏览器推送方案,只要浏览器实例不关闭,推送仍然可用
  • 需要redis,因为sse-broker依赖redis实现集群和LastEventId机制

优点

  • 全平台可用,包括手机端;支持http的客户端均可用;
  • 与业务紧密结合,sse-broker支持单用户多设备同时登陆,业务系统只需要通过用户ID进行推送,可通过其回调管理用户/设备在线状态

注意

  • 获取token及发送通知的接口,不应暴露在外网,应在业务系统中实现对应接口,鉴权成功后再调用sse-broker的接口
  • sse长连接在配置时注意关闭缓冲、配置远大于心跳(默认30秒)的超时时间,以确保sse连接的心跳能实时发送

附录

sse-demo.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SSE Demo</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <style>
        #messages {
            width: 100%;
            height: 300px;
            overflow-y: auto;
            border: 1px solid #ccc;
            margin-top: 20px;
            padding: 10px;
            background-color: #f9f9f9;
        }
        .message {
            margin-bottom: 10px;
        }
        input {
            min-width: 400px;
        }
        textarea {
            min-width: 400px;
            min-height: 50px;
        }
    </style>
    <script type="text/javascript">
    // 请求通知权限
    function requestNotificationPermission() {
        if ("Notification" in window) {
            // 检查是否已经获取权限
            if (Notification.permission === "granted") {
                // 权限已获取,直接继续
                console.log("通知权限已授予");
            } else if (Notification.permission !== "denied") {
                // 如果用户尚未授予或拒绝权限,则请求权限
                Notification.requestPermission().then(permission => {
                    if (permission === "granted") {
                        console.log("通知权限已授予");
                    } else {
                        console.log("用户拒绝通知权限");
                    }
                }).catch(error => {
                    console.log("通知权限请求出错", error);
                });
            }
        } else {
            console.log("浏览器不支持通知");
        }
    }


    function generateUUID() {
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
            var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    }

    function getDevice() {
        // 获取设备标识,如果没有则生成一个
        // device标识用于区分不同设备,同一个device同一时间只能有一个连接,后登陆的会将前一个挤下线
        // sse的lastEventId机制是基于devcie而不是uid的
        let device = localStorage.getItem('device');
        if (!device) {
            device = generateUUID();
            localStorage.setItem('device', device);
        }
        return device;
    }

    function getToken(uid, device) {
        // 获取一个绑定uid和device的3秒内有效的 sse token
        return new Promise((resolve, reject) => {
            $.ajax({
                url: '/token',
                method: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({
                    uid: uid,
                    device: device,
                    ttl: 3  // 3秒有效期
                }),
                success: function(response) {
                    if(response.code != 1) {
                        reject('[' + response.code + "] " + response.msg);
                    }
                    else {
                        resolve(response.result);
                    }
                },
                error: function() {
                    reject('get token failed');
                }
            });
        });
    }

    // 向消息区域添加消息并滚动到底部
    function appendMessage(type, title, data) {
        const messageDiv = $('<div class="message"></div>');
        messageDiv.html('<strong>' + title + ':</strong> ' + data);
        $('#messages').append(messageDiv);

        // 自动滚动到底部
        $('#messages').scrollTop($('#messages')[0].scrollHeight);
    }

    // 显示通知
    function showNotification(message) {
        if (Notification.permission === "granted") {
            new Notification("Notice", {
                body: message,
                icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjgyMEZGMTIyOUY1NjExRUM5MTk5REE3NjhFODY1QUY5IiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjgyMEZGMTIzOUY1NjExRUM5MTk5REE3NjhFODY1QUY5Ij4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ODIwRkYxMjA5RjU2MTFFQzkxOTlEQTc2OEU4NjVBRjkiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6ODIwRkYxMjE5RjU2MTFFQzkxOTlEQTc2OEU4NjVBRjkiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5+rU9bAAARUUlEQVR42uxd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' 
            });
        }
        else {
            appendMessage('event', 'Notice', message);
            appendMessage('warning', 'Warning', '通知失败,请允许本站点的通知权限');
        }
    }


    function startSSE() {
        requestNotificationPermission();
        const uid = $('#uid').val();
        if (uid == '') {
            alert('please input uid');
            return;
        }
        const device = getDevice();
        // 每次重连都重新获取token;
        // 此处可改为向业务系统请求token,鉴权后由业务系统调用sse的token接口生成token
        getToken(uid, device).then(token => {
            return connectSSE(token, device);
        }).catch(error => {
            setTimeout(function() {
                startSSE();  // 重连
            }, 5000);  // 5 秒后重连
        });
    }

    function connectSSE(token, device) {
        return new Promise((resolve, reject) => {
            const eventSource = new EventSource('/events?token=' + token + '&device=' + device);

            eventSource.onopen = function() {
                appendMessage('info', 'Info', '连接成功');
                $('#yourId').text('你的ID: ' + $('#uid').val());
                $('#user-form').hide();
                $('#send-form').show();
            };

            eventSource.onmessage = function(event) {
                appendMessage('message', 'Message', event.data);
            };

            eventSource.addEventListener('notice', function(event) {
                showNotification(event.data);
            });

            eventSource.onerror = function(event) {
                appendMessage('error', 'Error', '连接关闭');
                eventSource.close();
                if (eventSource.readyState === EventSource.CLOSED || eventSource.readyState === EventSource.CONNECTING) {
                    appendMessage('info', 'Info', '尝试重新连接...');
                    setTimeout(function() {
                        startSSE();  // 重连
                    }, 5000);  // 5 秒后重连
                }                
            };
            resolve(eventSource);
        });
    }

    // 发送消息,event为可选参数,如果不传表示发送message,否则发送指定event
    // 此处可改为向业务系统调用,由业务系统鉴权及检查消息内容后调用sse的send接口发送消息
    function send(event) {
        uid = $('#targetUid').val();
        if (uid == '') {
            alert('please input target uid');
            return;
        }
        data = $('#data').val();
        if (data == '') {
            alert('please input data');
            return;
        }
        $.ajax({
            url: '/send',
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({
                uid: uid,
                data: data,
                event: event
            }),
            success: function(response) {
                if(response.code != 1) {
                    alert('send message failed: [' + response.code + "] " + response.msg);
                    return;
                }
                else {
                    type = event ? event : 'message';
                    msg = 'send ' + type + ' to ' + uid + '\'s ' + response.result + ' device, data: ' +  $('#data').val();
                    appendMessage('info', 'Send', msg);
                    $('#data').val('');
                }
            },
            error: function() {
                alert('send message failed');
            }
        });
    }
    
    </script>
</head>
<body>
    <h1>SSE Demo</h1>
    <div id="user-form">
        <label for="uid">用户ID:</label>
        <input type="text" id="uid" name="uid" minlength="1" required>
        <button id="connect-sse" onclick="startSSE()">连接SSE</button>
    </div>
    <div id="send-form" style="display: none;">
        <label id="yourId" style="margin-left: 15px;"></label><br/>
        <div style="margin-bottom: 10px;"></div> 
        <label for="targetUid">对方的ID:</label>
        <input type="text" id="targetUid" name="targetUid" minlength="1" required>
        <div style="margin-bottom: 10px;"></div> 
        <label for="data">发送内容:</label>
        <textarea id="data" name="data" minlength="1" required></textarea>
        <div style="margin-bottom: 10px;"></div> 
        <button id="send-message" onclick="send()">发送消息</button>
        <button id="send-notification" onclick="send('notice')">发送通知</button>
    </div>

    <!-- 消息展示区域 -->
    <div id="messages"></div>

</body>
</html>