一文了解服务端推送(含JS代码示例)

3,476 阅读4分钟

如果您发现错误,请一定要告诉我,拯救一个辣鸡(但很帅)的少年就靠您了!

常用的服务端推送技术,包括轮询、长轮询、websocket、server-sent-event(SSE)

传统的HTTP请求是由客户端发送一个request,服务端返回对应response,所以当服务端想主动给客户端发送消息时就遇到了问题。常见的业务场景如新消息提醒。

1、轮询(Polling)

最简单的方法是轮询,即客户端不断的发送请求来获取最新的消息。优点是实现简单。缺点是请求中有大半是无用,浪费带宽和服务器资源,同时,根据轮询的时间间隔不同,获取消息会有对应的延迟。

实例,新浪微博新消息提示。打开控制台可以发现 https://rm.api.weibo.com/2/remind/push_count.json 一个 jsonp 请求,这个请求每隔 30s 发送一次,每次需求 100ms 左右。

新浪微博新消息请求

2、长轮询(Long Polling)

长轮询也比较容易理解,就是前端发起请求,并设置一个比较长的超时时间,后端接收到请求后,如果没有相关数据,会hold住请求直到有结果了,或者等待一定时间超时才返回。返回后,客户端会立即发起下一次请求。长轮询的控制权的服务器端,出现相关数据后会立即返回,实时性较高。

实例,QQ邮箱的新消息提醒。可以看到 https://wp.mail.qq.com/poll 请求不断发送, 没有新消息时,请求每次都会需要 30s,上一次请求返回后立即发送下一次请求,而当服务端有新消息时会立即返回,实时性较高。

QQ邮箱新消息请求

用代码简单实现以上两种轮询

服务端代码

const express = require('express');
const port = 2333;
const app = express();

app.get('/start', start);
app.get('/getCurrentResult', getCurrentResult);
app.get('/getFinalResult', getFinalResult);

app.listen(port, () => console.log(`Server listening on port ${port}`));

// 开始一个任务
function start(req, res) {
    res.setHeader('Access-Control-Allow-Origin', '*'); // 允许跨域
    _startTask();
    res.json({
       code: 0,
       data: '开始任务'
    });
}
// 返回实时结果
function getCurrentResult(req, res) {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.json({
        code: 0,
        data: result
    });
}
// 任务运行结束之后再返回运行结果
async function getFinalResult(req, res) {
    res.setHeader('Access-Control-Allow-Origin', '*');
    let result = await _startTask();
    res.json({
        code: 0,
        data: result
    });
}

// 模拟执行一个任务
let result = null;
function _startTask() {
    result = null;
    return new Promise((res, rej) => {
        // 任务需要10s 10s后得到result
        setTimeout(() => {
            result = 'hello world';
            res(result);
        }, 10000);
    });
}

客户端代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>轮询&长轮询</title>
</head>
<body>
    <button onclick="start(1)">开始任务 轮询</button>
    <button onclick="start(2)">开始任务 长轮询</button>
    <div id="hint"></div>
    <script>
        function start(type) {
            console.log('===start===');
            fetch('http://localhost:2333/start').then(res => {
                return res.json();
            }).then(function(data){
                console.log(data);
                setHint('任务执行中...');
                type == 1 ? loop() : longPolling();
            }).catch(function(err){
                console.error(err);
            });
        }

        function loop() {
            fetch('http://localhost:2333/getCurrentResult').then(res => {
                return res.json();
            }).then(function(data){
                console.log(data);
                if (!data.data) {
                    setTimeout(loop, 1000); // 1s轮询一次
                } else {
                    setHint('执行成功 结果 = ' + data.data);
                }
            }).catch(function(err){
                console.error(err);
            });
        }

        function longPolling() {
            fetch('http://localhost:2333/getFinalResult').then(res => {
                return res.json();
            }).then(function(data){
                console.log(data);
                setHint('执行成功 结果 = ' + data.data);
            }).catch(function(err){
                console.error(err);
            });
        }

        function setHint(text) {
            hint.innerHTML = text;
        }
    </script>
</body>
</html>

3、WebSocket

上面两种方式,实际上还是客户端单向发送消息,而 WebSocket 本质上解决了这个问题,WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

WebSocket 握手阶段采用 HTTP 协议,客户端浏览器首先要向服务器发起一个 HTTP 请求,其中附加头信息 Upgrade: WebSocket 表明这是一个申请协议升级的 HTTP 请求,服务器端解析这些附加的头信息然后产生应答信息返回给客户端,客户端和服务器端的 WebSocket 连接就建立起来了,双方就可以通过这个连接通道自由的传递信息,并且这个连接会持续存在直到客户端或者服务器端的某一方主动的关闭连接。WebSocket 没有同源限制。

实例,LeetCode-CN 的新消息提醒,猜测是通过 WebSocket 实时返回是否有新消息,再通过 XHR 请求具体信息。

简单代码实现,使用了 ws 包

服务端代码

const WebSocket = require('ws');
const http = require('http');

const port = 2333;

const server = http.createServer();
const wss = new WebSocket.Server({ server, path: '/ws' });

wss.on('connection', function(ws) {
    console.log('WebSocket connection established');
    let progress = 0;

    ws.send(`任务进度 -- ${progress}%`);
    let timer = setInterval(() => {
        // 推送任务完成进度
        if (++progress % 10 == 0) {
            ws.send(`任务进度 -- ${progress}%`);
        }
        if (progress == 100) {
            clearInterval(timer);
            ws.close();
        }
    }, 200);

    ws.on('close', () => {
        console.log('WebSocket connection closed');
        clearInterval(timer);
    });
});

server.listen(port, function() {
    console.log(`Server listening on port ${port}`);
});

客户端代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>WebSocket</title>
</head>
<body>
    <button onclick="start()">开始任务</button>
    <div id="hint" style="white-space: pre-line;"></div>
    <script>
        let ws;
        function start() {
            if (ws) ws.close();

            console.log('===start===');
            
            ws = new WebSocket('ws://localhost:2333/ws');
            ws.onmessage = function(ev) {
                let data = ev.data;
                console.log(data);
                showMessage(data);
            }
            ws.onerror = function() {
                console.log('WebSocket error');
            };
            ws.onopen = function() {
                console.log('WebSocket connection established');
            };
            ws.onclose = function() {
                console.log('WebSocket connection closed');
                ws = null;
            };
        }

        function showMessage(text) {
            hint.innerHTML = hint.innerHTML + '\n' + text;
        }
    </script>
</body>
</html>

4、Sever-Sent Event(SSE)

SSE 是一种能让浏览器通过 HTTP 连接自动收到服务器端推送的技术,EventSource 是 浏览器提供的对应 API。通过 EventSource 实例打开与 HTTP 服务器的持久连接,该服务器以文本/事件流格式发送事件,连接会保持打开状态,直到服务端或客户端主动关闭。

与 WebSocket 区别,SSE 基于 HTTP 协议,使用简单,SSE 默认支持断线重连,但是 SSE 只能由服务端向客户端推动消息。

SSE 有四种字段,其他的字段会被忽略。字段之间用\n 分隔,每条消息要以 \n\n 结尾。

data    // 数据项
event   // 事件项 默认为 message 可设置任意值
id       // 数据标识符,用于断线重连
retry   // 断线后重连时间

实际应用,SSE 在股价显示场景应用较多,如 东方财富网(感谢 @heart_ 提醒)

简单代码实现:

服务端代码

const express = require('express');
const port = 2333;
const app = express();

app.get('/sse', respondSSE);

function respondSSE(req, res) {
    let msg = 0;
    let timer;
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Connection': 'keep-alive',
        'Access-Control-Allow-Origin': '*'
    });

    res.write(sseMsg({
        data: '===start===',
        // 默认 event 是 'message'
    }));

    timer = setInterval(() => {
        res.write(sseMsg({
            id: Date.now(),
            event: 'custom-event',
            data: msg++,
            retry: 2000
        }));
    }, 1000);

    res.on('close', function () {
        clearInterval(timer);
        console.log('SSE connection closed');
    });
}

const sseMsg = (sseObj) => {
    let fields = ['id', 'event', 'data', 'retry'];
    return fields
        .filter(f => sseObj[f] != null)
        .map(f => f + ':' + sseObj[f]).join('\n') + '\n\n';
}

app.listen(port, () => console.log(`Server listening on port ${port}`));

客户端代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <button onclick="start()">开始</button>
    <button onclick="over()">结束</button>
    <div id="hint"></div>
    <script>
        let source;
        function start() {
            source = new EventSource('http://localhost:2333/sse');
            source.addEventListener('open', () => {
                console.log('SSE connection established');
            }, false);

            source.addEventListener('message', e => {
                console.log(e.data);
            }, false);

            source.addEventListener('custom-event', e => {
                console.log('custom-event data: ', e.data);
                showMessage('新消息: ' + e.data + ' 条');
            }, false);
        }

        function over() {
            source.close();
        }

        function showMessage(text) {
            hint.innerHTML = text;
        }
    </script>
</body>
</html>

5、HTTP/2 Server Push

文章比较少,而且和上面的推送并不一样,看到这篇讲得不错~

Node HTTP/2 Server Push 从了解到放弃

参考资料