HTML5 WebSocket权威指南-读书笔记

1,045 阅读11分钟

概念

  • WebSocket是一种通信协议,可在单个TCP连接上进行全双工通信。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就可以建立持久性的连接,并进行双向数据传输。
    • 全双工:是通讯传输的一个术语。通信允许数据在两个方向上同时传输。(A->b,b->A 这个动作可以同时进行) (HTTP2.0)
    • 单工:是在只允许甲方向乙方传送信息,而乙方不能向甲方传送 。(A->B, b≠>A)(HTTP1.1以下)
    • 半双工: 所谓半双工就是指一个时间段内只有一个动作发生。(随着技术的不断进步,半双工会逐渐退出历史舞台。A->B B接收到A之后才能发送给A信息)(HTTP1.1)

旧的HTTP架构概览

HTTP 1.0和HTTP 1.1

HTTP 1.0

  • 早期1.0的版本,是一种无状态,无连接的协议。浏览器和服务器保持短暂的连接,浏览器的每次请求都需要与服务器建立一个TCP连接。
    • 无连接:服务器处理完成后立即断开TCP连接。每次发送请求都要需要经历连接和释放的过程,比较费事, 使得网络利用率非常低。
    • 无状态:服务器不跟踪每个客户端也不记录过去的请求。好处是服务器不需要保存有关会话的信息,但是也意味着每次请求和响应都会发送关于请求的冗余信息。

HTTP 1.1

  • 对于HTTP1.1,不仅继承了HTTP1.0简单的特点,还克服了诸多HTTP1.0性能上的问题。
    • HTTP 1.1增加了可重用连接,增加了Connection字段,通过设置Keep-Alive可以保持HTTP连接不断开。减少客服端到服务端的连接数量,降低了请求的延迟。
    • 是HTTP1.1支持请求管道化。可虽然HTTP1.1支持管道化,但是服务器也必须进行逐个响应的送回,这个是很大的一个缺陷。实际上,浏览器厂商采取了另外一种做法,允许打开多个TCP的会话,也就是我们所熟悉的浏览器对同域下并行加载6~8个资源的限制。

HTTP轮询,长轮询和流化

轮询

  • 轮询的实现思路就是每隔一段时间向服务器发送HTTP请求,服务器再收到请求,无论是否有数据都会直接响应,响应结束后就会关闭这个TCP连接。
function Polling() {
    fetch(url).then(data => {
        // somthing
    }).catch(err => {
        console.log(err);
    });
}
// 每隔5秒发出请求
setInterval(polling, 5000);

长轮询

  • 长轮询是另一种流行的通信方法,在一段时间内保持请求打开,直到客户端有可用的信息或者超时为止。
// 客户端
function Polling() {
    fetch(url).then(data => {
        Polling()
    }).catch(err => {
    	Polling()
        console.log(err);
    });
}
// 服务端
router.post('./test',async ()=>{
	return new Promise(resolve => {
        let time = new Date();
        // 每隔10ms去查询问题
        let it = setInterval(() => {
			// 如果超时了返回结果并断开连接
            if (Date.now() - time > 5000) {
                clearInterval(it);
                resolve('超时了');
            }
            // 如果找到结果
            resolve('找到结果了')
        }, 10);
    });
})

总结

  • 上述方法提供了近乎实时的通信,到那时涉及HTTP请求和响应头,包含了许多附加和不必要的头部数据和延迟。而且,每一种情况,客户端都必须等待请求返回,才能发出后续的请求,增加延迟。

Websocket介绍

  • Websocket是以一种自然的全双工,单套接字连接。使用WebSocket,你的HTTP的请求变成打开WebSocket连接的单一请求,并且重用从客服端到服务器以及服务器到客服端的同一连接。
  • websocket减少了延迟,因为一旦建立了websocket连接,服务器可以在消息可用时发送他们。和轮询不同,websocket只发出一个请求,服务器也不需要等待来自客服端的请求。相似的,客户端可以在任何时候向服务器发送请求,相比较轮询,大大减少了延迟。

Websocket API概览

介绍

  • websocket API是一个接口,使应用程序能够使用Websocket协议。只需要创建一个新的Websocket对象实例,并为对象提供一个代表所要连接的URL就可以了。
	// 构造函数
	const ws = new Websocket("ws://localhost:3333");
  • 还有一个可选参数protocols,代表websocket的子协议(也可以是用户自定义的协议),可以接收一个字符串或者字符串数组。子协议可以让websocket协议支持多种数据类型的传输(json,buffer,text..),类似于HTTP请求中的Content-Type: application/json,在HTTP种都是MIME类型,在IETF RFC 6838中进行了定义和标准化,但是在websocket协议中没有这种规范,需要用子协议来定义。
  • websocket协议是支持三种本地消息类型
    • 1、文本消息
    • 2、二进制消息

    对于将要传输的二进制数据,开发者可以决定以何种方式处理,可以更好的处理数据流,Blob 对象一般用来表示一个不可变文件对象或原始数据,如果你不需要修改它或者不需要把它切分成更小的块,那这种格式是理想的;如果你还需要再处理接收到的二进制数据,那么选择ArrayBuffer 应该更合适。

    • 3、ping消息和pong消息

      1)两种消息通常被用来检查WebSocket连接的健康性,(连接是否有效)。

      2)可以通过测量Ping和Pong消息所花费的时间来测算WebSocket连接的效率。

  • websocket API完全是(真正的)事件驱动的,一旦建立全双工连接,就不需要轮询服务器获取最新状态,客户端只要监听需要的通知和更改就行了。

ws和wss

  • websocket协议定义了两种url方案————ws和wss,分别用于客户端和服务器之间的非加密与加密流量。
    • ws的默认端口是80,非加密版本,只要知道ip和端口号,任何人都可以连接。
    • wss的默认端口是433,加密版本,使用了TLS,安全性更好。

调度websocket的4个不同事件

open

  • 一旦服务器响应了webscoket连接,open事件触发并且建立一个连接,对应的回调函数是onopen
  • open事件触发后,就可以确定webscoket服务器成功处理了连接请求,并且同意进行通信。

message

  • message事件在接受到消息时触发,对应的回调函数是onmessage
  • 除了文本信息,websocket还可以处理二进制数据,这种数据作为Blob消息或者ArrayBuffer消息

error

-error事件在响应意外故障的时候触发。对应的回调事件是onerror。如果你接收到一个error事件,可以预期很快就会触发close事件。

close

  • close事件在websocket连接关闭时触发。对应的回调函数时onclose,一旦关闭连接,客户端和服务器不再能接收或者发送消息。
  • close事件中有3个有用的属性,可以通过这三个属性来分析断开的原因。
    • wasclean:是一个布尔值,表示连接是否顺利关闭。如果是对来自服务器的一个close帧,则返回true。异常关闭则返回false。
    • code 断开的识别码
    • reason 断开的原因 字符串类型
  • 其中code和reason跟websocket.close(code,reason)中传入的code和reason一致。

websocket的两个方法

send()

  • 在监听websocket的open事件完成后,就可以调用send()方法。
// 错误
const ws = new Websocket('ws://test.com')
ws.send(‘hello’)

// 正确
ws.onopen = ()=>{
	ws.send('hello')
}
  • 如果在其他地方使用send()方法,可以检查Websocket.readyState属性,并选择只在Websocket.readyState===1
  • readyState:记录连接过程中的状态值
    • 0:CONNECTING -- 连接尚未建立
    • 1:OPEN -- 连接已建立
    • 2:CLOSING -- 连接正在关闭
    • 3:CLOSED -- 连接已关闭或者不可用

close()

  • 使用close方法,可以关闭websocket连接或连接尝试。在调用close之后,就不能发送数据了。
  • close方式接收两个参数,code状态码和reason关闭原因的字符串。

bufferedAmount

  • websocket.bufferedAmount是一个只读属性,用于返回已经被send()方法放入队列中单还没有被发送到网络中的数据字节数。
  • 如果在发送过程中被关闭了,则属性值不会重置为0,继续调用send()后,该属性值会持续增长。
  • 可以通过判断websocket.bufferedAmount===0来判断消息是否全部发送完毕,也可以用来获取队列中未发送的数据量,来控制发送数据的速率。
const ws = new Websocket('ws://test.com')
ws.onopen = ()=>{
	setInterval(()=>{
    	if(ws.bufferedAmount<1024*10){
        	ws.send('未发送数据量小于10K,继续发送消息')
        }
    },1000)
}

protocol

  • websocket.protocol表示在websocket上使用的协议,是一个只读属性。protocol特性在最初握手完成之前未空,如果服务器没有选择客户端提供的某个协议,则protocol保持空值。

webscoket协议

webscoket初始握手

  • 每个websocket连接都始于一个HTTP请求,与其他请求相似,但是包含一个特殊的请求头——UpgradeUpgrade请求头表示客户端把连接升级到不同协议。
Upgrade: websocket

webscoket响应头

  • 响应中必须包含了101状态码,UpgradeSec-Websocket-Accept的响应头,否则websocket连接不能成功。Sec-Websocket-Accept响应头的值时从Sec-Websocket-Key中继承而来。
	// node.js
	// 计算Sec-Websocket-Accept值
    const crypto = require('crypto');
    // KEY_SUFFIX是一个协议规范中包含的固定键值后缀
    const KEY_SUFFIX = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
    const hashWebSocketKey = (key) => {
    	// 创建hash函数
        const sha1 = crypto.createHash('SHA1');
        // 更新hash的内容为指定的key
        sha1.update(key + KEY_SUFFIX);
        // 输出base64格式的编码
        return sha1.digest("base64");
    }

websocket与HTTP区别

  • HTTP1.0中,一个请求,一个响应这个HTTP链接就结束了。而在HTTP1.1中改进了链接,增加了keep-alive,也就是在一个HTTP请求,可以有多个请求和响应,但是只能请求->响应,每次响应都是被动的,不能主动给客户端发送信息。而websocket解决了HTTP链接的被动性,能够主动向客户端发送信息。

websocket安全问题

跨域请求

问题描述

  • 在发起websocket握手时,浏览器会在请求中添加一个名为Origin的请求头,表示发起请求的源,而websocket规范并没有强制规定握手阶段必须要有Origin的请求头,而Sec-WebSocket-Key是浏览器自动生成的。如果用户被诱骗访问了某个恶意网页,而恶意网页中植入了一段js代码,这时候用户会自动带上Cookie等身份认证的参数,恶意网页就可以成功绕过身份认证连接到 WebSocket 服务器,进而窃取到服务器端发来的信息,或者发送伪造信息到服务器端篡改服务器端数据。

解决

  • 1.服务端对Origin检查,如果客户端发来的 Origin 信息来自不同域,服务器端可以拒绝该请求。
  • 2.可以借鉴CSRF的解决方案,握手前生成token,在握手的时候带上token,服务端去验证token的准确性(生成的token具有唯一性,随机性)。

拒绝服务

客户端

  • WebSocket连接限制不同于HTTP连接限制,和HTTP相比,WebSocket有一个更高的连接限制,不同的浏览器有自己特定的最大连接数(如IE最大连接数6个,chrome最大连接数是255),通过发送恶意内容,用尽允许的所有Websocket连接耗尽浏览器资源,引起拒绝服务。

服务端

  • 攻击者可以申请大量的websocket持久连接,耗尽服务器资源。针对这种攻击,可以限制单ip的最大连接数。
  • 攻击者可以发送单个庞大的数据流,来耗尽服务器内存,引发拒绝服务。针对这种攻击,可以限制总消息大小来防范。

输入校验

  • WebSocket应用和传统Web应用一样,都需要对输入进行校验,来防范来客户端的XSS攻击,服务端的SQL注入,代码注入等攻击。

总结

  • websocket是基于TCP的新协议,websocket有很大性能优势,但是并不会解决传统Web应用中存在的安全问题,这就需要开发者了解webscoket的安全问题,以及如果防范和规避这些安全问题。

websokcet心跳重连

  • websocket是前后端交互的长连接,前后端也都可能因为一些情况导致连接失效并且相互之间没有反馈提醒。因此为了保证连接的可持续性和稳定性,websocket心跳重连就应运而生。
  • 如果在使用webscoket的时候网络突然断开,不会触发websocket任何事件,只会在下一次send()的时候,浏览器才会发现已经断开连接,会在短时间内触发onclose事件(不同浏览器触发的时间不一样)。后端的websocket服务也有可能触发异常,造成连接断开,但这个时候不会通知前端。所以需要前端定时发送ping消息,后端接受到ping消息后,立马返回pong消息。如果前端没有如期的收到pong消息,就说明连接不正常触发重连操作。
// index.html
...
<script>
function initWebsocket (){
  const ws = new Webscoket('ws://localhost:8888');
      let timeoutCount;
      const beatTime = 10000;
      ws.onopen = function(){
          // 开始心跳
          timeoutCount = setTimeout(function(){
              ws.send('HeartBeat')
          },beatTime);
      }

      ws.onmessage=function(){
          // 收到消息时清除之前的定时器,重新开始心跳
          clearTimeout(timeoutCount);
          heartbeart()
      }
      ws.onclose = function () {
      	  // 当发送心跳到服务器时,服务器如果出现异常,不能返回心跳响应消息
          // 就会触发'close'事件,这时就可以发起重连
          initWebsocket();
      };
}
initWebsocket();
    
</script>
...

创建简单的聊天室

  • 创建一个简单的聊天室,展示websocket的广播功能
  • 前端代码
// index.html
<!DOCTYPE html>
<html lang="en">
<body>
    <div style="height: 700px;">
        <div id="chat" style="height: 300px;"></div>
        <input id="name" />
        <input id="message" />
        <button onclick="sendMessage()">发送</button>
    </div>
    <script>
        const ws = new WebSocket('ws://localhost:8080');
        //显示消息
        function appendLog(nickname, message) {
            const messages = document.getElementById('chat');
            const messageElem = document.createElement("li");
            messageElem.innerHTML = `${nickname}${message}`;
            messages.appendChild(messageElem);
        }
        //发送消息
        function sendMessage() {
            const messageField = document.getElementById('message');
            const nameField = document.getElementById('name');
            if(nameField.value===''){
                alert('请输入名称');
                return
            }
            if (ws.readyState === WebSocket.OPEN) {
                ws.send(JSON.stringify({ name: nameField.value, message: messageField.value }));
            }
            messageField.value = '';
            messageField.focus();
        }

        ws.onmessage = function (e) {
            const data = JSON.parse(e.data);
            appendLog(data.name, data.message);
        }
    </script>
</body>

</html>
  • node代码
// index.js
const ws = require('ws').Server;
const wss = new WebSocketServer({ port: 8080 });

// 连接池
var clients = [];

// 发送消息
function broadcastSend(name, message) {
    clients.forEach(function (v, i) {
        if (v.ws.readyState === ws.OPEN) {
            v.ws.send(JSON.stringify({
                name,
                message
            }));
        }
    })
}
//监听连接
wss.on('connection', function (ws) {
    clients.push({
        "ws": ws,
    });
    /*监听消息*/
    ws.on('message', function (message) {
        const data = JSON.parse(message);
        console.log('data',data);
        broadcastSend(data.name, data.message);
    });
})

参考文章: