前言
- 虽然现在有比较好用的socket.io库,但是原生实现websocket特别有助于了解其到底是怎么工作的。
原理
- websocket应用层协议,它基于TCP传输协议,并复用HTTP的握手通道。
前置知识
数据帧格式
-
WebSocket客户端、服务端通信的最小单位是帧,由1个或多个帧组成一条完整的消息。
-
发送端:将消息切割成多个帧,并发送给服务端
-
接收端:接收消息帧,并将关联的帧重新组装成完整的消息
0 1 2 3 4
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
- 先别被这个图给吓到,其实不难。
- websocket的数据帧很小,就是因为它每一位有特定含义,一位(比特)能代表2个状态,前面由FIN,RSV1,RSV2,RSV3组成4位,再加上opcode,mask4位,共8位组成1字节。
- 后面payload len是不固定的,如果长度大于7位,也就是2的7次方-1,即127,那么会占用后面的extended payload length。这个扩充长度是2字节,也就是16位,2的16次方-1,即可表示65535长度。如果还不够,下面还有4字节扩充长度,以及masking-key前面2字节长度,共计9字节,也就是2的72次方减一,如果还不够,那就分包。
- 然后就是masking-key,有4字节。
- 最后payloadData就是用户发的数据了。
FIN:1个比特 如果是1,表示这是消息(message)的最后一个分片(fragment),如果是0,表示不是是消息(message)的最后一个分片(fragment)
RSV1, RSV2, RSV3:各占1个比特。一般情况下全为0。当客户端、服务端协商采用WebSocket扩展时,这三个标志位可以非0,且值的含义由扩展进行定义。如果出现非零的值,且并没有采用WebSocket扩展,连接出错。
Opcode: 4个比特。操作代码,Opcode的值决定了应该如何解析后续的数据载荷(data payload)。如果操作代码是不认识的,那么接收端应该断开连接(fail the connection)
%x0:表示一个延续帧。当Opcode为0时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。
%x1:表示这是一个文本帧(frame)
%x2:表示这是一个二进制帧(frame)
%x3-7:保留的操作代码,用于后续定义的非控制帧。
%x8:表示连接断开。
%x9:表示这是一个ping操作。
%xA:表示这是一个pong操作。
%xB-F:保留的操作代码,用于后续定义的控制帧。
Mask: 1个比特。表示是否要对数据载荷进行掩码操作。从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作,如果服务端接收到的数据没有进行过掩码操作,服务端需要断开连接。如果Mask是1,那么在Masking-key中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask都是1。
Payload length:数据载荷的长度,单位是字节。为7位,或7+16位,或7+64位。
Payload length=x为0~125:数据的长度为x字节。
Payload length=x为126:后续2个字节代表一个16位的无符号整数,该无符号整数的值为数据的长度。
Payload length=x为127:后续8个字节代表一个64位的无符号整数(最高位为0),该无符号整数的值为数据的长度。如果payload length占用了多个字节的话,payload length的二进制表达采用网络序(big endian,重要的位在前)
Masking-key:0或4字节(32位) 所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作,Mask为1,且携带了4字节的Masking-key。如果Mask为0,则没有Masking-key。载荷数据的长度,不包括mask key的长度
Payload data:(x+y) 字节
载荷数据:包括了扩展数据、应用数据。其中,扩展数据x字节,应用数据y字节。
扩展数据:如果没有协商使用扩展的话,扩展数据数据为0字节。所有的扩展都必须声明扩展数据的长度,或者可以如何计算出扩展数据的长度。此外,扩展如何使用必须在握手阶段就协商好。如果扩展数据存在,那么载荷数据长度必须将扩展数据的长度包含在内。
应用数据:任意的应用数据,在扩展数据之后(如果存在扩展数据),占据了数据帧剩余的位置。载荷数据长度 减去 扩展数据长度,就得到应用数据的长度。
掩码算法
-
掩码键(Masking-key)是由客户端挑选出来的32位的随机数。掩码操作不会影响数据载荷的长度。掩码、反掩码操作都采用如下算法:
-
对索引i模以4得到j,因为掩码一共就是四个字节
-
对原来的索引进行异或对应的掩码字节
-
异或就是两个数的二进制形式,按位对比,相同取0,不同取1
function unmask(buffer, mask) {
const length = buffer.length;
for (let i = 0; i < length; i++) {
buffer[i] ^= mask[i & 3];
}
}
- 简单说就是拿到掩码键,对每一位按掩码键进行异或操作,即可得到原本数据。由于掩码键是不能大于4个字节(见上面数据帧masking-key),所以查看掩码键每一位使用
i&3
。
Sec-WebSocket-Accept 算法
- 这个算法是公开算法,主要用于客户端发送请求后服务端是否理解要进行websocket服务。返回的响应头不对,则连接失败。
- 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=
连接流程
- 首先,客户端发起协议升级请求。可以看到,采用的是标准的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是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。
- 服务端返回内容如下,状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: aWAY+V/uyz5ILZEoWuWdxjnlb7E=
代码
客户端
<script>
let ws = new WebSocket('ws://localhost:8888')
ws.onopen = function(){
console.log('success');
ws.send('client text')
}
ws.onmessage=function(e){
console.log('server data:'+e.data);
}
</script>
服务端
let express = require('express')
let app = express()
app.use(express.static(__dirname))
app.listen(8080)
let WebSocketServer =require('ws').Server
let server = new WebSocketServer({port:8888})
server.on('connection',function(socket){
socket.on('message',function(message){
console.log('receive:'+message);
socket.send('reply:'+message)
})
})
服务端原生实现
let express = require('express')
let app = express()
app.use(express.static(__dirname))
app.listen(8080)
function unmask(buffer, mask) {
const length = buffer.length;
for (let i = 0; i < length; i++) {
buffer[i] ^= mask[i & 3];
}
}
const net = require('net')
const crypto = require('crypto')
const CODE ='258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
let server = net.createServer(function(socket){
socket.once('data',(data)=>{
data=data.toString()
if(data.match(/Upgrade: websocket/)){
let row = data.split('\r\n')
row =row.slice(1,-2)//去掉最后2个分隔符和GET
let headers = {}
row.forEach(item => {
let [key,value]=item.split(': ')//有个空格
headers[key]=value
});//做成键值对
if(headers['Sec-WebSocket-Version']==='13'){
let wsKey = headers['Sec-WebSocket-Key']
let mykey = crypto.createHash('sha1').update(wsKey+CODE).digest('base64')
let response = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
`Sec-WebSocket-Accept: ${mykey}`,
'Connection: Upgrade',
'\r\n'
].join('\r\n')
socket.write(response) // 升级协议
socket.on('data',function(buffers){
let _fin=(buffers[0]&0b10000000)===0b10000000//一个buffers8位查看第一位
let _opcode = buffers[0]&0b00001111 //看后四位
let _ismask =buffers[1]&0b10000000 === 0b10000000 //第一位是否是1
let _payloadlength = buffers[1]&0b01111111 //简化了,就当7位即可取满,正常情况需要判断
let _mask = buffers.slice(2,6)//payloadlength后就跟着掩码,共4字节
let payload = buffers.slice(6) //数据
if(_ismask){
unmask(payload,_mask)
}
let response = Buffer.alloc(2+_payloadlength)//需要请求头2字节,加数据长度
response[0]=_opcode|0b10000000 //响应头第一位改1(_FIN),表示发送结束
response[1]=_payloadlength // 负载长度
payload.copy(response,2)//把响应内容复制进去,这里是简化,需要什么响应需要自己算长度和内容位置
socket.write(response)
})
}
}
})
socket.on('end', function () {
console.log('end');
});
socket.on('close', function () {
console.log('close');
});
socket.on('error', function (error) {
console.log(error);
});
})
server.listen(8888)
- 这个原生实现简化了很多判断,具体要根据客户端发送的内容以及返回的内容长度进行各种判断拼凑响应头。