在最后的阿宝哥有话说环节,阿宝哥将介绍WebSocket与HTTP之间的关系、WebSocket与长轮询有什么区别、什么是WebSocket心跳及Socket是什么等内容。
下面我们进入正题,为了让大家能够更好地理解和掌握WebSocket技术,我们先来介绍一下什么是WebSocket。
为了更加直观感受轮询与长轮询之间的区别,我们来看一下具体的代码:
这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而HTTP请求与响应可能会包含较长的头部,其中真正有效的数据可能只是很小的一部分,所以这样会消耗很多带宽资源。
在这种情况下,HTML5定义了WebSocket协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。Websocket使用ws或wss的统一资源标志符(URI),其中wss表示使用了TLS的Websocket。如:
ws://echo.websocket.orgwss://echo.websocket.orgWebSocket与HTTP和HTTPS使用相同的TCP端口,可以绕过大多数防火墙的限制。默认情况下,WebSocket协议使用80端口;若运行在TLS之上时,默认使用443端口。
WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocketAPI中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。
由于WebSocket拥有上述的优点,所以它被广泛地应用在即时通信、实时音视频、在线教育和游戏等领域。对于前端开发者来说,要想使用WebSocket提供的强大能力,就必须先掌握WebSocketAPI,下面阿宝哥带大家一起来认识一下WebSocketAPI。
在介绍WebSocketAPI之前,我们先来了解一下它的兼容性:
从上图可知,目前主流的Web浏览器都支持WebSocket,所以我们可以在大多数项目中放心地使用它。
在浏览器中要使用WebSocket提供的能力,我们就必须先创建WebSocket对象,该对象提供了用于创建和管理WebSocket连接,以及可以通过该连接发送和接收数据的API。
WebSocket构造函数的语法为:
当尝试连接的端口被阻止时,会抛出SECURITY_ERR异常。
WebSocket对象包含以下属性:
每个属性的具体含义如下:
使用addEventListener()或将一个事件监听器赋值给WebSocket对象的oneventname属性,来监听下面的事件。
介绍完WebSocketAPI,我们来举一个使用WebSocket发送普通文本的示例。
在以上示例中,我们在页面上创建了两个textarea,分别用于存放待发送的数据和服务器返回的数据。当用户输入完待发送的文本之后,点击发送按钮时会把输入的文本发送到服务端,而服务端成功接收到消息之后,会把收到的消息原封不动地回传到客户端。
//constsocket=newWebSocket("ws://echo.websocket.org");//constsendMsgContainer=document.querySelector("#sendMessage");functionsend(){constmessage=sendMsgContainer.value;if(socket.readyState!==WebSocket.OPEN){console.log("连接未建立,还不能发送消息");return;}if(message)socket.send(message);}当然客户端接收到服务端返回的消息之后,会把对应的文本内容保存到接收的数据对应的textarea文本框中。
//constsocket=newWebSocket("ws://echo.websocket.org");//constreceivedMsgContainer=document.querySelector("#receivedMessage");socket.addEventListener("message",function(event){console.log("Messagefromserver",event.data);receivedMsgContainer.value=event.data;});为了更加直观地理解上述的数据交互过程,我们使用Chrome浏览器的开发者工具来看一下相应的过程:
以上示例对应的完整代码如下所示:
constsocket=newWebSocket("ws://echo.websocket.org");socket.onopen=function(){//发送UTF-8编码的文本信息socket.send("HelloEchoServer!");//发送UTF-8编码的JSON数据socket.send(JSON.stringify({msg:"我是阿宝哥"}));//发送二进制ArrayBufferconstbuffer=newArrayBuffer(128);socket.send(buffer);//发送二进制ArrayBufferViewconstintview=newUint32Array(buffer);socket.send(intview);//发送二进制Blobconstblob=newBlob([buffer]);socket.send(blob);};以上代码成功运行后,通过Chrome开发者工具,我们可以看到对应的数据交互过程:
下面阿宝哥以发送Blob对象为例,来介绍一下如何发送二进制数据。
在以上示例中,我们在页面上创建了两个textarea,分别用于存放待发送的数据和服务器返回的数据。当用户输入完待发送的文本之后,点击发送按钮时,我们会先获取输入的文本并把文本包装成Blob对象然后发送到服务端,而服务端成功接收到消息之后,会把收到的消息原封不动地回传到客户端。
当浏览器接收到新消息后,如果是文本数据,会自动将其转换成DOMString对象,如果是二进制数据或Blob对象,会直接将其转交给应用,由应用自身来根据返回的数据类型进行相应的处理。
数据发送代码
//constsocket=newWebSocket("ws://echo.websocket.org");//constsendMsgContainer=document.querySelector("#sendMessage");functionsend(){constmessage=sendMsgContainer.value;if(socket.readyState!==WebSocket.OPEN){console.log("连接未建立,还不能发送消息");return;}constblob=newBlob([message],{type:"text/plain"});if(message)socket.send(blob);console.log(`未发送至服务器的字节数:${socket.bufferedAmount}`);}当然客户端接收到服务端返回的消息之后,会判断返回的数据类型,如果是Blob类型的话,会调用Blob对象的text()方法,获取Blob对象中保存的UTF-8格式的内容,然后把对应的文本内容保存到接收的数据对应的textarea文本框中。
数据接收代码
//constsocket=newWebSocket("ws://echo.websocket.org");//constreceivedMsgContainer=document.querySelector("#receivedMessage");socket.addEventListener("message",asyncfunction(event){console.log("Messagefromserver",event.data);constreceivedData=event.data;if(receivedDatainstanceofBlob){receivedMsgContainer.value=awaitreceivedData.text();}else{receivedMsgContainer.value=receivedData;}});同样,我们使用Chrome浏览器的开发者工具来看一下相应的过程:
通过上图我们可以很明显地看到,当使用发送Blob对象时,Data栏位的信息显示的是BinaryMessage,而对于发送普通文本来说,Data栏位的信息是直接显示发送的文本消息。
在介绍如何手写WebSocket服务器前,我们需要了解一下WebSocket连接的生命周期。
从上图可知,在使用WebSocket实现全双工通信之前,客户端与服务器之间需要先进行握手(Handshake),在完成握手之后才能开始进行数据的双向通信。
握手是在通信电路创建之后,信息传输开始之前。握手用于达成参数,如信息传输率,字母表,奇偶校验,中断过程,和其他协议特性。握手有助于不同结构的系统或设备在通信信道中连接,而不需要人为设置参数。
既然握手是WebSocket连接生命周期的第一个环节,接下来我们就先来分析WebSocket的握手协议。
WebSocket协议属于应用层协议,它依赖于传输层的TCP协议。WebSocket通过HTTP/1.1协议的101状态码进行握手。为了创建WebSocket连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”(Handshaking)。
利用HTTP完成握手有几个好处。首先,让WebSocket与现有HTTP基础设施兼容:使得WebSocket服务器可以运行在80和443端口上,这通常是对客户端唯一开放的端口。其次,让我们可以重用并扩展HTTP的Upgrade流,为其添加自定义的WebSocket首部,以完成协商。
下面我们以前面已经演示过的发送普通文本的例子为例,来具体分析一下握手过程。
当服务器接收到升级为WebSocket的握手请求时,会先从请求头中获取“Sec-WebSocket-Key”的值,然后把该值加上一个特殊字符串“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后计算SHA-1摘要,之后进行Base64编码,将结果做为“Sec-WebSocket-Accept”头的值,返回给客户端。
上述的过程看起来好像有点繁琐,其实利用Node.js内置的crypto模块,几行代码就可以搞定了:
//util.jsconstcrypto=require("crypto");constMAGIC_KEY="258EAFA5-E914-47DA-95CA-C5AB0DC85B11";functiongenerateAcceptValue(secWsKey){returncrypto.createHash("sha1").update(secWsKey+MAGIC_KEY,"utf8").digest("base64");}开发完握手功能之后,我们可以使用前面的示例来测试一下该功能。待服务器启动之后,我们只要对“发送普通文本”示例,做简单地调整,即把先前的URL地址替换成ws://localhost:8888,就可以进行功能验证。
感兴趣的小伙们可以试试看,以下是阿宝哥本地运行后的结果:
从上图可知,我们实现的握手功能已经可以正常工作了。那么握手有没有可能失败呢?答案是肯定的。比如网络问题、服务器异常或Sec-WebSocket-Accept的值不正确。
下面阿宝哥修改一下“Sec-WebSocket-Accept”生成规则,比如修改MAGIC_KEY的值,然后重新验证一下握手功能。此时,浏览器的控制台会输出以下异常信息:
WebSocketconnectionto'ws://localhost:8888/'failed:ErrorduringWebSockethandshake:Incorrect'Sec-WebSocket-Accept'headervalue如果你的WebSocket服务器要支持子协议的话,你可以参考以下代码进行子协议的处理,阿宝哥就不继续展开介绍了。
在WebSocket协议中,数据是通过一系列数据帧来进行传输的。为了避免由于网络中介(例如一些拦截代理)或者一些安全问题,客户端必须在它发送到服务器的所有帧中添加掩码。服务端收到没有添加掩码的数据帧以后,必须立即关闭连接。
要实现消息通信,我们就必须了解WebSocket数据帧的格式:
012301234567890123456789012345678901+-+-+-+-+-------+-+-------------+-------------------------------+|F|R|R|R|opcode|M|Payloadlen|Extendedpayloadlength||I|S|S|S|(4)|A|(7)|(16/64)||N|V|V|V||S||(ifpayloadlen==126/127)|||1|2|3||K|||+-+-+-+-+-------+-+-------------+---------------+|Extendedpayloadlengthcontinued,ifpayloadlen==127|+---------------+-------------------------------+||Masking-key,ifMASKsetto1|+-------------------------------+-------------------------------+|Masking-key(continued)|PayloadData|+-----------------------------------------------+:PayloadDatacontinued...:+-------------------------------+|PayloadDatacontinued...|+---------------------------------------------------------------+可能有一些小伙伴看到上面的内容之后,就开始有点“懵逼”了。下面我们来结合实际的数据帧来进一步分析一下:
在上图中,阿宝哥简单分析了“发送普通文本”示例对应的数据帧格式。这里我们来进一步介绍一下Payloadlength,因为在后面开发数据解析功能的时候,需要用到该知识点。
Payloadlength表示以字节为单位的“有效负载数据”长度。它有以下几种情形:
多字节长度量以网络字节顺序表示,有效负载长度是指“扩展数据”+“应用数据”的长度。“扩展数据”的长度可能为0,那么有效负载长度就是“应用数据”的长度。
另外,除非协商过扩展,否则“扩展数据”长度为0字节。在握手协议中,任何扩展都必须指定“扩展数据”的长度,这个长度如何进行计算,以及这个扩展如何使用。如果存在扩展,那么这个“扩展数据”包含在总的有效负载长度中。
掩码不影响数据荷载的长度,对数据进行掩码操作和对数据进行反掩码操作所涉及的步骤是相同的。掩码、反掩码操作都采用如下算法:
j=iMOD4transformed-octet-i=original-octet-iXORmasking-key-octet-j为了让小伙伴们能够更好的理解上面掩码的计算过程,我们来对示例中“我是阿宝哥”数据进行掩码操作。这里“我是阿宝哥”对应的UTF-8编码如下所示:
E68891E698AFE998BFE5AE9DE593A5而对应的Masking-Key为0x08f6efb1,根据上面的算法,我们可以这样进行掩码运算:
letuint8=newUint8Array([0xE6,0x88,0x91,0xE6,0x98,0xAF,0xE9,0x98,0xBF,0xE5,0xAE,0x9D,0xE5,0x93,0xA5]);letmaskingKey=newUint8Array([0x08,0xf6,0xef,0xb1]);letmaskedUint8=newUint8Array(uint8.length);for(leti=0,j=0;i
ee7e7e579059629b713412ced654a上述结果与WireShark中的Maskedpayload对应的值是一致的,具体如下图所示:
在WebSocket协议中,数据掩码的作用是增强协议的安全性。但数据掩码并不是为了保护数据本身,因为算法本身是公开的,运算也不复杂。那么为什么还要引入数据掩码呢?引入数据掩码是为了防止早期版本的协议中存在的代理缓存污染攻击等问题。
了解完WebSocket掩码算法和数据掩码的作用之后,我们再来介绍一下数据分片的概念。
WebSocket的每条消息可能被切分成多个数据帧。当WebSocket的接收方收到一个数据帧时,会根据FIN的值来判断,是否已经收到消息的最后一个数据帧。
利用FIN和Opcode,我们就可以跨帧发送消息。操作码告诉了帧应该做什么。如果是0x1,有效载荷就是文本。如果是0x2,有效载荷就是二进制数据。但是,如果是0x0,则该帧是一个延续帧。这意味着服务器应该将帧的有效负载连接到从该客户机接收到的最后一个帧。
Client:FIN=1,opcode=0x1,msg="hello"Server:(processcompletemessageimmediately)Hi.Client:FIN=0,opcode=0x1,msg="anda"Server:(listening,newmessagecontainingtextstarted)Client:FIN=0,opcode=0x0,msg="happynew"Server:(listening,payloadconcatenatedtopreviousmessage)Client:FIN=1,opcode=0x0,msg="year!"Server:(processcompletemessage)Happynewyeartoyoutoo!在以上示例中,客户端向服务器发送了两条消息。第一个消息在单个帧中发送,而第二个消息跨三个帧发送。
其中第一个消息是一个完整的消息(FIN=1且opcode!=0x0),因此服务器可以根据需要进行处理或响应。而第二个消息是文本消息(opcode=0x1)且FIN=0,表示消息还没发送完成,还有后续的数据帧。该消息的所有剩余部分都用延续帧(opcode=0x0)发送,消息的最终帧用FIN=1标记。
阿宝哥把实现消息通信功能,分解为消息解析与消息响应两个子功能,下面我们分别来介绍如何实现这两个子功能。
server.on("upgrade",function(req,socket){socket.on("data",(buffer)=>{constmessage=parseMessage(buffer);if(message){console.log("Messagefromclient:"+message);}elseif(message===null){console.log("WebSocketconnectionclosedbytheclient.");}});if(req.headers["upgrade"]!=="websocket"){socket.end("HTTP/1.1400BadRequest");return;}//省略已有代码});更新完成之后,我们重新启动服务器,然后继续使用“发送普通文本”的示例来测试消息解析功能。以下发送“我是阿宝哥”文本消息后,WebSocket服务器输出的信息。
要把数据返回给客户端,我们的WebSocket服务器也得按照WebSocket数据帧的格式来封装数据。与前面介绍的parseMessage函数一样,阿宝哥也封装了一个constructReply函数用来封装返回的数据,该函数的具体代码如下:
functionconstructReply(data){constjson=JSON.stringify(data);constjsonByteLength=Buffer.byteLength(json);//目前只支持小于65535字节的负载constlengthByteCount=jsonByteLength<1260:2;constpayloadLength=lengthByteCount===0jsonByteLength:126;constbuffer=Buffer.alloc(2+lengthByteCount+jsonByteLength);//设置数据帧首字节,设置opcode为1,表示文本帧buffer.writeUInt8(0b10000001,0);buffer.writeUInt8(payloadLength,1);//如果payloadLength为126,则后面两个字节(16位)内容应该,被识别成一个16位的二进制数表示数据内容大小letpayloadOffset=2;if(lengthByteCount>0){buffer.writeUInt16BE(jsonByteLength,2);payloadOffset+=lengthByteCount;}//把JSON数据写入到Buffer缓冲区中buffer.write(json,payloadOffset);returnbuffer;}创建完constructReply函数,我们再来更新一下之前创建的WebSocket服务器:
server.on("upgrade",function(req,socket){socket.on("data",(buffer)=>{constmessage=parseMessage(buffer);if(message){console.log("Messagefromclient:"+message);//新增以下代码socket.write(constructReply({message}));}elseif(message===null){console.log("WebSocketconnectionclosedbytheclient.");}});});到这里,我们的WebSocket服务器已经开发完成了,接下来我们来完整验证一下它的功能。
从图中可知,我们的开发的简易版WebSocket服务器已经可以正常处理普通文本消息了。最后我们来看一下完整的代码:
custom-websocket-server.js
WebSocket是一种与HTTP不同的协议。两者都位于OSI模型的应用层,并且都依赖于传输层的TCP协议。虽然它们不同,但是RFC6455中规定:WebSocket被设计为在HTTP80和443端口上工作,并支持HTTP代理和中介,从而使其与HTTP协议兼容。为了实现兼容性,WebSocket握手使用HTTPUpgrade头,从HTTP协议更改为WebSocket协议。
长轮询的本质还是基于HTTP协议,它仍然是一个一问一答(请求—响应)的模式。而WebSocket在握手成功后,就是全双工的TCP通道,数据可以主动从服务端发送到客户端。
网络中的接收和发送数据都是使用SOCKET进行实现。但是如果此套接字已经断开,那发送数据和接收数据的时候就一定会有问题。可是如何判断这个套接字是否还可以使用呢?这个就需要在系统中创建心跳机制。所谓“心跳”就是定时发送一个自定义的结构体(心跳包或心跳帧),让对方知道自己“在线”。以确保链接的有效性。
而所谓的心跳包就是客户端定时发送简单的信息给服务器端告诉它我还在而已。代码就是每隔几分钟发送一个固定信息给服务端,服务端收到后回复一个固定信息,如果服务端几分钟内没有收到客户端信息则视客户端断开。
在WebSocket协议中定义了心跳Ping和心跳Pong的控制帧:
网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个socket(套接字),因此建立网络通信连接至少要一对端口号。socket本质是对TCP/IP协议栈的封装,它提供了一个针对TCP或者UDP编程的接口,并不是另一种协议。通过socket,你可以使用TCP/IP协议。
关于Socket,可以总结以下几点:
下图说明了面向连接的协议的套接字API的客户端/服务器关系。