websocket

116 阅读3分钟

1、HTTP的架构模式


Http是客户端/服务器模式中请求-响应所用的协议,在这种模式中,客户端(一般是web浏览器)向服务器提交HTTP请求,服务器响应请求的资源

1.1 HTTP的特点

  • HTTP是半双工协议,也就是说,在同一时刻数据只能单向流动,客户端向服务器发送请求(单向的),然后服务器响应请求(单向的)。
  • 服务器不能主动推送数据给浏览器。

2、双向通信


Comet是一种用于web的推送技术,能使服务器能实时地将更新的信息传送到客户端,而无须客户端发出请求,目前有三种实现方式:轮询(polling) 长轮询(long-polling)和iframe流(streaming)。

2.1 轮询

  • 轮询是客户端和服务器之间会一直进行连接,每隔一段时间就询问一次

  • 这种方式连接数会很多,一个接受,一个发送。而且每次发送请求都会有Http的Header,会很耗流量,也会消耗CPU的利用率

polling.jpg

Server

let express = require('express');
let app = express();
app.use(express.static(__dirname));
app.get('/clock', function(req,res){
  res.send(new Date().toLocaleTimeString());
})
app.listen(8080);

Client

  <div id="clock"></div>
  <script>
    setInterval(function () {
      let xhr = new XMLHttpRequest();
      xhr.open('GET', '/clock', true);
      xhr.onreadystatechange = function () {
        if (xhr.readyState == 4 && xhr.status == 200) {
          document.querySelector('#clock').innerHTML = xhr.responseText;
        }
      }
      xhr.send();
    }, 1000);
  </script>

2.2 长轮询

  • 长轮询是对轮询的改进版,客户端发送HTTP给服务器之后,看有没有新消息,如果没有新消息,就一直等待
  • 当有新消息的时候,才会返回给客户端。在某种程度上减小了网络带宽和CPU利用率等问题

longpolling.png

Client

<div id="clock"></div>
    <script>
        (function send() {
              let xhr = new XMLHttpRequest();
              xhr.open('GET', '/clock', true);
              xhr.onreadystatechange = function () {
                  if (xhr.readyState == 4 && xhr.status == 200) {
                      document.querySelector('#clock').innerHTML = xhr.responseText;
                      send();
                  }
              }
              xhr.send();
      })();
    </script>

Server

let express = require('express');
let app = express();
app.use(express.static(__dirname));
app.get('/clock', function(req,res) {
    let $timer = setInterval(function(){
      let date = new Date();
        let seconds = date.getSeconds();
        if (seconds%5 === 0) {
            res.send(date.toLocaleString());
            clearInterval($timer);
        }
    }, 1000)
})
app.listen(8080);

1.3 iframe流

  • 通过在HTML页面里嵌入一个隐藏的iframe,然后将这个iframe的src属性设为对一个长连接的请求,服务器端就能源源不断地往客户推送数据。

iframeflow.png

Server

const express = require('express');
const app = express();
app.use(express.static(__dirname));
app.get('/clock', function (req, res) {
    setInterval(function () {
        res.write(`
            <script type="text/javascript">
                parent.document.getElementById('clock').innerHTML = "${new Date().toLocaleTimeString()}";
            </script>
        `);
    }, 1000);
});
app.listen(8080);

client

<div id="clock"></div>
<iframe src="/clock" style=" display:none" />

1.4 EventSource流

  • HTML5规范中提供了服务端事件EventSource,浏览器在实现了该规范的前提下创建一个EventSource连接后,便可收到服务端的发送的消息,这些消息需要遵循一定的格式,对于前端开发人员而言,只需在浏览器中侦听对应的事件皆可
  • SSE的简单模型是:一个客户端去从服务器端订阅一条,之后服务端可以发送消息给客户端直到服务端或者客户端关闭该“流”,所以eventsource也叫作"server-sent-event`
  • EventSource流的实现方式对客户端开发人员而言非常简单,兼容性良好
  • 对于服务端,它可以兼容老的浏览器,无需upgrade为其他协议,在简单的服务端推送的场景下可以满足需求
1.4.1 浏览器端
  • 浏览器端,需要创建一个EventSource对象,并且传入一个服务端的接口URI作为参

  • 默认EventSource对象通过侦听message事件获取服务端传来的消息

  • open事件则在http连接建立后触发

  • error事件会在通信错误(连接中断、服务端返回数据失败)的情况下触发

  • 同时EventSource规范允许服务端指定自定义事件,客户端侦听该事件即可

     <script>
      var eventSource = new EventSource('/eventSource');
      eventSource.addEventListener('message', function(e){
        console.log(e.data)
      });
      eventSource.addEventListener('error',function(e){
        console.log(e);
      })
     </script>
    
1.4.2 服务端
  • 事件流的对应MIME格式为text/event-stream,而且其基于HTTP长连接。针对HTTP1.1规范默认采用长连接,针对HTTP1.0的服务器需要特殊设置。

  • event-source必须编码成utf-8

    的格式,消息的每个字段使用"\n"来做分割,并且需要下面4个规范定义好的字段:

    • Event: 事件类型
    • Data: 发送的数据
    • ID: 每一条事件流的ID
    • Retry: 告知浏览器在所有的连接丢失之后重新开启新的连接等待的时间,在自动重新连接的过程中,之前收到的最后一个事件流ID会被发送到服务端
    let  express = require('express');
    let app = express();
    app.use(express.static(__dirname));
    let sendCount = 1;
    app.get('/eventSource',function(req,res){
        res.header('Content-Type','text/event-stream',);
        setInterval(() => {
          res.write(`event:message\nid:${sendCount++}\ndata:${Date.now()}\n\n`);
        }, 1000)
    });
    app.listen(8888);
    
    参考

3、websocket


  • WebSockets_API 规范定义了一个 API 用以在网页浏览器和服务器建立一个 socket 连接。通俗地讲:在客户端和服务器保有一个持久的连接,两边可以在任意时间开始发送数据。
  • HTML5开始提供的一种浏览器与服务器进行全双工通讯的网络技术
  • 属于应用层协议,它基于TCP传输协议,并复用HTTP的握手通道。

3.1 websocket 优势

  • 支持双向通信,实时性更强。
  • 更好的二进制支持。
  • 较少的控制开销。连接创建后,ws客户端、服务端进行数据交换时,协议控制的数据包头部较小。

3.2 websocket实战

3.2.1 服务端
let express = require('express');
const path = require('path');
let app = express();
let server = require('http').createServer(app);
app.get('/', function (req, res) {
    res.sendFile(path.resolve(__dirname, 'index.html'));
});
app.listen(3000);


//-----------------------------------------------
let WebSocketServer = require('ws').Server;
let wsServer = new WebSocketServer({ port: 8888 });
wsServer.on('connection', function (socket) {
    console.log('连接成功');
    socket.on('message', function (message) {
        console.log('接收到客户端消息:' + message);
        socket.send('服务器回应:' + message);
    });
});
3.2.2 客户端
 <script>
        let ws = new WebSocket('ws://localhost:8888');
        ws.onopen = function () {
            console.log('客户端连接成功');
            ws.send('hello');
        }
        ws.onmessage = function (event) {
            console.log('收到服务器的响应 ' + event.data);
        }
    </script>

3.3 如何建立连接

WebSocket复用了HTTP的握手通道。具体指的是,客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。

3.3.1 客户端:申请协议升级

首先,客户端发起协议升级请求。可以看到,采用的是标准的HTTP报文格式,且只支持GET方法

GET ws://localhost:8888/ HTTP/1.1
Host: localhost:8888
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: IHfMdf8a0aQXbwQO1pkGdA==
  • Connection: Upgrade:表示要升级协议
  • Upgrade: websocket:表示要升级到websocket协议
  • Sec-WebSocket-Version: 13:表示websocket的版本
  • Sec-WebSocket-Key:与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。
3.3.2 服务端:响应协议升级

服务端返回内容如下,状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: aWAY+V/uyz5ILZEoWuWdxjnlb7E=
3.3.3 Sec-WebSocket-Accept的计算

Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来。 计算公式为:

  • 将Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
  • 通过SHA1计算出摘要,并转成base64字符串
const crypto = require('crypto');
const number = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const webSocketKey = 'IHfMdf8a0aQXbwQO1pkGdA==';
let websocketAccept = require('crypto').createHash('sha1').update(webSocketKey + number).digest('base64');
console.log(websocketAccept);//aWAY+V/uyz5ILZEoWuWdxjnlb7E=
3.3.4 Sec-WebSocket-Key/Accept的作用
  • 避免服务端收到非法的websocket连接
  • 确保服务端理解websocket连接
  • 用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key以及其他相关的header是被禁止的
  • Sec-WebSocket-Key主要目的并不是确保数据的安全性,因为Sec-WebSocket-Key、Sec-WebSocket-Accept的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)
3.3.5 服务器实战
const net = require('net');
const crypto = require('crypto');
const CODE = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
// 创建一个 TCP 服务器,监听在 9999 端口上
let server = net.createServer(function (socket) {
    //  socket.once() 方法监听客户端发送的数据
    socket.once('data', function (data) {
        data = data.toString();
        // console.log(data)
        // 将接收到的数据转换为字符串,并通过正则表达式匹配 Upgrade 头部,判断是否是升级为 WebSocket 请求
        if (data.match(/Upgrade: websocket/)) {
            // 解析请求头
            let rows = data.split('\r\n');//按分割符分开
            rows = rows.slice(1, -2);//去掉请求行和尾部的二个分隔符
            const headers = {};
            rows.forEach(row => {
                let [key, value] = row.split(': ');
                headers[key] = value;
            });
            // 根据 Sec-WebSocket-Version 和 Sec-WebSocket-Key 生成响应头,将响应头写回客户端
            if (headers['Sec-WebSocket-Version'] == 13) {
                let wsKey = headers['Sec-WebSocket-Key'];
                let acceptKey = crypto.createHash('sha1').update(wsKey + CODE).digest('base64');
                let response = [
                    'HTTP/1.1 101 Switching Protocols',
                    'Upgrade: websocket',
                    `Sec-WebSocket-Accept: ${acceptKey}`,
                    'Connection: Upgrade',
                    '\r\n'
                ].join('\r\n');
                socket.write(response);
                // WebSocket 协议是基于 TCP 的,在成功升级协议后,服务端和客户端之间的数据传输都以帧(Frame)的形式进行,具体实现需要通过拆分、合并数据帧、计算掩码等操作完成
                // ...
            }

        }
    });
});
server.listen(9999);

4、socket.io

Socket.IO是一个WebSocket库,包括了客户端的js和服务器端的nodejs,它的目标是构建可以在不同浏览器和移动设备上使用的实时应用。

4.1 socket.io的特点

  • 易用性:socket.io封装了服务端和客户端,使用起来非常简单方便。
  • 跨平台:socket.io支持跨平台,这就意味着你有了更多的选择,可以在自己喜欢的平台下开发实时应用。
  • 自适应:它会自动根据浏览器从WebSocket、AJAX长轮询、Iframe流等等各种方式中选择最佳的方式来实现网络实时应用,非常方便和人性化,而且支持的浏览器最低达IE5.5

4.2 初步使用

4.2.1安装部署

使用npm安装socket.io

$ npm install socket.io
4.2.2启动服务

创建 app.js 文件

var express = require('express');
var path = require('path');
var app = express();

app.get('/', function (req, res) {
    res.sendFile(path.resolve('index.html'));
});

var server = require('http').createServer(app);
var io = require('socket.io')(server);

io.on('connection', function (socket) {
    console.log('客户端已经连接');
    socket.on('message', function (msg) {
        console.log(msg);
        socket.send('sever:' + msg);
    });
});
server.listen(80);
4.2.3 客户端引用

服务端运行后会在根目录动态生成socket.io的客户端js文件 客户端可以通过固定路径/socket.io/socket.io.js添加引用 客户端加载socket.io文件后会得到一个全局的对象io connect函数可以接受一个url参数,url可以socket服务的http完整地址,也可以是相对路径,如果省略则表示默认连接当前路径

创建index.html文件

<script src="/socket.io/socket.io.js"></script>
<script>
 window.onload = function(){
    const socket = io.connect('/');
    //监听与服务器端的连接成功事件
    socket.on('connect',function(){
        console.log('连接成功');
    });
    //监听与服务器端断开连接事件
    socket.on('disconnect',function(){
       console.log('断开连接');
    });
 };
</script>
4.2.4 发送消息

成功建立连接后,我们可以通过socket对象的send函数来互相发送消息 修改index.html

var socket = io.connect('/');
socket.on('connect',function(){
   //客户端连接成功后发送消息'welcome'
   socket.send('welcome');
});
//客户端收到服务器发过来的消息后触发
socket.on('message',function(message){
   console.log(message);
});

修改app.js

var io = require('scoket.io')(server);
io.on('connection',function(socket){
  //向客户端发送消息
  socket.send('欢迎光临');
  //接收到客户端发过来的消息时触发
  socket.on('message',function(data){
      console.log(data);
  });
});

4.3 深入分析

4.3.1 send方法
  • send函数只是emit的封装
  • node_modules\socket.io\lib\socket.js源码
function send(){
  var args = toArray(arguments);
  args.unshift('message');
  this.emit.apply(this, args);
  return this;
}

emit函数有两个参数

  • 第一个参数是自定义的事件名称,发送方发送什么类型的事件名称,接收方就可以通过对应的事件名称来监听接收
  • 第二个参数是要发送的数据
4.3.2 服务端事件
事件名称含义
connection客户端成功连接到服务器
message接收到客户端发送的消息
disconnect客户端断开连接
error监听错误
4.3.3 客户端事件
事件名称含义
connect成功连接到服务器
message接收到服务器发送的消息
disconnect客户端断开连接
error监听错误

4.4 划分命名空间

4.4.1 服务器端划分命名空间
  • 可以把服务分成多个命名空间,默认/, 不同空间内不能通信

    io.on('connection', function (socket) { 
        //向客户端发送消息 
        socket.send('/ 欢迎光临'); 
        //接收到客户端发过来的消息时触发 
        socket.on('message', function(data){ 
            console.log('/'+data); 
        });
    }); 
    io.of('/news').on('connection', function (socket) { 
        //向客户端发送消息 
        socket.send('/news 欢迎光临'); 
        //接收到客户端发过来的消息时触发 
        socket.on('message',function(data){ 
            console.log('/news '+data); 
        }); 
    });
    
4.4.2 客户端连接命名空间
window.onload = function(){
var socket = io.connect('/');
//监听与服务器端的连接成功事件
socket.on('connect',function(){
    console.log('连接成功');
    socket.send('welcome');
});
socket.on('message',function(message){
    console.log(message);
});
//监听与服务器端断开连接事件
socket.on('disconnect',function(){
     console.log('断开连接');
});

var news_socket = io.connect('/news');
//监听与服务器端的连接成功事件
news_socket.on('connect',function(){
    console.log('连接成功');
     socket.send('welcome');
});
news_socket.on('message',function(message){
    console.log(message);
});
//监听与服务器端断开连接事件
 news_socket.on('disconnect',function(){
    console.log('断开连接');
});
};

4.5 参考