简单的 websocket 服务器实现(协议实现)
文章目录
众所周知,在http2之前,受制于协议本身的原因,http并不支持服务端主动推送。客户端需要采用轮询等方式与服务端进行实时通信,但产生的开销比较大。而html5中提出了一套websocket协议规范,使得客户端浏览器与服务端进行双向实时通信成为可能(具体参见rfc6455),本文将介绍用node.js原生模块打造一个简单的ws服务器,点击这里获取完整代码。
websocket protocol分为两部分:握手和数据传输
握手阶段
客户端握手请求报文:
GET /chat HTTP/1.1 //请求行 Host: server.example.com Upgrade: websocket //required Connection: Upgrade //required Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== //required Origin: http://example.com // 用于防止未认证的跨域脚本使用浏览器websocket api与服务端进行通信 Sec-WebSocket-Protocol: chat, superchat //optional, 子协议协商字段 Sec-WebSocket-Version: 13
服务端响应报文:
HTTP/1.1 101 Switching Protocols //状态行 Upgrade: websocket //required Connection: Upgrade //required Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= //required Sec-WebSocket-Protocol: chat //表明选择的子协议
握手阶段,具体来讲,就是当浏览器脚本new WebSocket(url)后,浏览器对服务器发送一个协议升级的请求,请求中带有Sec-WebSocket-Key字段。服务端接收到协议提升请求后对这个字段加上一个特定的GUID后做一次sha1运算,然后再获取结果的base64格式摘要,作为Sec-WebSocket-Accept响应头的值响应回客户端浏览器,就完成了握手。具体代码如下:
server.on('upgrade', (req, socket, head) => {
// 固定GUID
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
// 获取客户端返回的key与GUID进行sha1编码后获取base64格式摘要
let key = req.headers['sec-websocket-key'];
key = crypto.createHash('sha1').update(key + GUID).digest('base64');
// 返回101协议切换响应
const resMsg = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Accept: ' + key,
'\r\n'
].join('\r\n');
socket.write(resMsg);
});
ws帧解码与编码
握手成功后,就可以进行数据传输了,然而不进行解码操作是得不到正确的结果的。
socket.on('data', console.log.bind(console)); // 打印的数据类似是这样的格式<Buffer aa bb cc>
我们可以来看一下ws帧的完整格式:
1 2 3 4
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|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 ... |
+---------------------------------------------------------------+
解释如下: - FIN: 表示帧是否结束,1结束,0没结束 - RSV[1-3]: 通常来说置零即可,但可以根据扩展协商非零值的具体含义 - opcode: 操作码,0,1,2属于数据帧,8,9,10属于控制帧,具体含义如下 - 0: 附加帧 - 1: 文本帧 - 2: 二进制帧 - 3-7: 保留作为未来的非控制帧 - 8: 关闭帧 - 9: ping帧 - 10: pong帧 - 11-15: 保留作为未来的控制帧 - MASK: 掩码,0表示不使用掩码,1表示使用Masking-key对负载数据进行掩码运算 - Payload len: - 0-125: 实际负载数据长度 - 126: 接下来的两字节对应的无符号整数作为负载长度 - 127: 扩展的8字节对应的无符号帧数作为负载长度 Masking-key: 如果MASK为1时,后续的四字节作为Masking-key,MASK为0时则缺省Masking-key Payload Data: (x+y) bytes 负载数据 - Extension data(x bytes): 扩展数据通常来说是0字节,除非协商了一个扩展 - Application data(y bytes): 应用数据
解码操作代码如下:
function decodeWsFrame(data) {
// 游标
let start = 0;
// 定义帧字段格式
let frame = {
isFinal: (data[start] & 0x80) === 0x80,
opcode: data[start++] & 0xF,
masked: (data[start] & 0x80) === 0x80,
payloadLen: data[start++] & 0x7F,
maskingKey: '',
payloadData: null
};
// 接下来的两字节对应的无符号整数作为负载长度
if(frame.payloadLen === 126) {
frame.payloadLen = (data[start++] << 8) + data[start++];
} else if(frame.payloadLen === 127) { // 扩展的8字节对应的无符号帧数作为负载长度
frame.payloadLen = 0;
for(let i = 7; i >= 0; --i) {
frame.payloadLen += (data[start++] << (i * 8));
}
}
if(frame.payloadLen) {
// 如果使用了掩码
if(frame.masked) {
// 掩码键
const maskingKey = [
data[start++],
data[start++],
data[start++],
data[start++]
];
frame.maskingKey = maskingKey;
// 负载数据与四字节的掩码键的每一个字节轮流进行按位抑或运算
frame.payloadData = data
.slice(start, start + frame.payloadLen)
.map((byte, idx) => byte ^ maskingKey[idx % 4]);
} else {
frame.payloadData = data.slice(start, start + frame.payloadLen);
}
}
return frame;
}
解码数据帧结果
/* 打印结果
{ isFinal: true,
opcode: 0,
masked: false,
payloadLen: 3,
maskingKey: '',
payloadData: Buffer [68 65 6c 6c 6f 20 67 65 65 6d 6f] }
hello geemo
*/
socket.on('data', data => {
data = decodeWsFrame(data); //数据帧解码
console.log(data); //打印帧
console.log(String(data.payloadData)) //打印帧负载字符串格式结果
});
既然已经能解码客户端发送的帧后,我们接着来实现服务端编码帧响应回客户端。rfc文档中说服务端响应回客户端的帧不能进行掩码操作,那太好了!然而服务端虽然不需要考虑mask,但是还需要考虑分片问题。。。
// 编码ws帧
function encodeWsFrame(data) {
const isFinal = data.isFinal !== undefined ? data.isFinal : true, // 没有isFinal字段默认为终止帧
opcode = data.opcode !== undefined ? data.opcode : 1, // 默认编码为文本帧
payloadData = data.payloadData ? new Buffer(data.payloadData) : null,
payloadLen = payloadData ? payloadData.length : 0;
let frame = [];
// 帧的第一个字节
if(isFinal) frame.push((1 << 7) + opcode);
else frame.push(opcode);
// 帧的负载长度处理
if(payloadLen < 126) {
frame.push(payloadLen);
} else if(payloadLen < 65536){
frame.push(126, payloadLen >> 8, payloadLen & 0xFF);
} else {
frame.push(127);
for(let i = 7; i >= 0; --i) {
frame.push((payloadLen & (0xFF << (i * 8))) >> (i * 8));
}
}
// 合并头部和负载数据
frame = payloadData ? Buffer.concat([new Buffer(frame), payloadData]) : new Buffer(frame);
console.dir(decodeWsFrame(frame));
return frame;
}
最后是处理分片情况,所谓分片,就是一个完整数据分为多个数据帧进行发送,其可以分为三个部分: - 起始帧(数量==1): FIN == 0, opcode != 0 - 附加帧(数量>=0): FIN == 0, opcode == 0 - 终止帧(数量==1): FIN == 1, opcode == 0
具体分片处理代码实现如下:
function rawFrameParseHandle(socket) {
let frame,
frameArr = [], // 用来保存分片帧的数组
totalLen = 0; // 记录所有分片帧负载叠加的总长度
socket.on('data', rawFrame => {
frame = decodeWsFrame(rawFrame);
if(frame.isFinal) {
// 分片的终止帧
if(frame.opcode === 0) {
frameArr.push(frame);
totalLen += frame.payloadLen;
let frame = frameArr[0],
payloadDataArr = [];
payloadDataArr = frameArr
.filter(frame => frame.payloadData)
.map(frame => frame.payloadData);
// 将所有分片负载合并
frame.payloadData = Buffer.concat(payloadDataArr);
frame.payloadLen = totalLen;
// 根据帧类型进行处理
opHandle(socket, frame);
frameArr = [];
totalLen = 0;
} else { // 普通帧
opHandle(socket, frame);
}
} else { // 分片起始帧与附加帧
frameArr.push(frame);
totalLen += frame.payloadLen;
}
});
}
进行测试
// 测试代码
socket.write(encodeWsFrame({isFinal: false, opcode: 1, payloadData: 'bbb'}));
socket.write(encodeWsFrame({isFinal: false, opcode: 0, payloadData: 'ccc'}));
socket.write(encodeWsFrame({isFinal: true, opcode: 0, payloadData: 'ddd'}));
// 客户端将三个帧进行拼接为'bbbcccddd'
结尾
好啦,大致实现基本完成,当然还有各种子协议,响应状态码等还没有研究,毕竟只是实现一个玩具嘛。
文章作者 geemo
上次更新 2018-02-20