前言
本文为初入研究 Websocket协议,对于真正应用中,各种语言都有实现库,建议采用库,而不是自己实现,本文基于node.js,但其他语言都适用
本文主要简述:
- Websocket相关知识
- Websocket的协议概要
- HTTP 与 Websocket 混合服务器简易demo
为什么要实现一个 HTTP 与 Websocket 混合服务器呢?
因为有个朋友 之前被 他的技术总监 蠢哭了...
他的技术总监让他用php连接其他websocket服务器,原因是:不能在js出现websocket的地址....那时我正在看 RFC6455,于是我就开玩笑的说,实现一个 HTTP+Websocket 服务器吧😏
于是,我就顺便写了一个这样的一个demo(顺便实现了聊天室),代码量也从原来简易50行,变成了150行,这个demo纯娱乐学习,实际工作中,最好不要这样写,除非你经过深思熟虑,下面demo中,存在的一些问题,因为是简易实现demo的原因,就不进行更多的扩展了
WebSocket相关知识
WebSocket 简述####
WebSocket 是基于TCP的一个双向传输数据的协议,和HTTP协议一样,是在应用层的.他的出现,是为了解决网页进行 持久双向传输数据 的问题
WebSocket 与 HTTP的关系 与 TCP链接的关系
其实WebSocket 和 HTTP 实际上都是一个TCP链接, WebSocket协议和HTTP协议的作用就是 规定他们用TCP对话的规矩
可以查看 RFC6455 文档,来看version:13具体的协议
HTTP1.1可以查看RFC 2616
WebSocket协议的请求(握手),是和HTTP兼容的,可以理解成是一种"升级",但应答规则不一样了
Websocket协议概要
WebSocket 基本步骤如下:
- 先进行TCP连接
- 客户端发送握手
- 服务器响应握手
- 握手完毕后,可以相互传输数据
- 连接结束,发送关闭控制帧并断开TCP
TCP连接
这部分就不详细讲了(TCP连接细节也不细说了),每个语言的都差不多
服务端:监听TCP端口(本文监听80)
客户端:发起TCP连接
由于本文采用浏览器+服务器的形式,所以是由浏览器发起的连接
握手协议
在TCP连接连接后,客户端发送握手(一段字符串,每个句末为\r\n换行):
//来自 客户端的握手,注释内容,实际上不会出现,
GET /chat HTTP/1.1 //必须,一定要是GET请求,HTTP协议一定要为1.1以上
Host: server.example.com //必须(不知有何实际作用,标示入口,但可伪造)
Upgrade: websocket //必须,值为大小写不敏感的websocket
Connection: Upgrade //必须,值为大小写不敏感的Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== //必须,客户端定义的值,经过了base64编码,用于验证握手
Origin: http://example.com //必须,浏览器都会附上,可伪造
Sec-WebSocket-Protocol: chat, superchat //可选,希望采用的交流协议,按优先级排序
Sec-WebSocket-Version: 13 //必须,websocket的协议版本
Sec-WebSocket-Extensions:x-webkit-deflate-frame //可选,希望采用的扩展协议
由于客户端的握手,是和HTTP的请求是兼容的,所以也适应HTTP请求中的规矩
服务端收到来客户端的握手后,应该响应握手(字符串,每个句末为\r\n换行):
HTTP/1.1 101 Switching Protocols //必须,只能返回101,否则出错
Upgrade: websocket //必须,值为"websocket"
Connection: Upgrade //必须,值为"Upgrade"
Sec-WebSocket-Accept:s3pPLMBiTxaQ9kYGzzhZRbK+xOo= //必须,客户端请求中的 Sec-WebSocket-Key的值+258EAFA5-E914-47DA-95CA-C5AB0DC85B11,再进行base64
Sec-WebSocket-Protocol: chat, superchat //可选,采用的交流协议
Sec-WebSocket-Extensions:x-webkit-deflate-frame //可选,采用的扩展协议
经过接受客户端的握手,并返回服务端的握手(握手没有错误),则建立了websocket连接了
数据传输
互相传输时的数据,并不能直接发送,需要按照一定的格式
每次传输数据,都需要构建帧,并整帧传输(注意,整个帧将转成2进制数据,经过tcp连接传输到另外一端)
帧结构
帧组成: FIN + RSV1 + RSV2 + RSV3 + Opcode + Mask + Payload length + Masking-key + Extension data + Application data
翻译过来就是:
是否结束+三个扩展码(RSV1-3)+操作码+是否有掩码+数据长度+掩码(可有可无)+扩展段(可有可无)+数据
所以,你需要在你发送的数据前面添加以上数据
注意,服务端发送的帧不能有掩码,否则应该报错
- FIN: 1bit ,表示是否最后一帧,用于分片
- RSV1-3: 各1bit,在扩展协议中指定,否则无效
- Opcode:4bit,用来说明这个帧是干什么的用,按照该值区分控制帧和数据帧
- Mask:1bit,表示是否有掩码
-
Payload length:7bit 或者 7+16bit 或者 7+64bit,用于表示数据长度(这个长度是转化为二进制数据的长度),这个长度有点复杂:
- ①当 要发送的数据的长度 小于126时 占用 7bit,7bit中直接填充数据长度
- ②当长度少于65536(2^16)时,占用7+16bit,前7bit为126(0x7E,无掩码时),后面16bit为实际长度
- ③当长度大于等于65536时,占用7+64bit,前7bit为127(0x7F,无掩码时),后面64bit为实际长度
- Masking-key:0或32bit,当前面的MASK表示有掩码时,就会加入4个字节的掩码,掩码由发送方定义,用于加密Application data
- Extension data: 0bit 或 自定义字节数,扩展协议自己定义的数据,如果没有使用扩展协议,或者扩展协议没有定义,则为0bit
- Application data: 要发送的信息,如果Mask为1则内容需要Masking-key来进行掩码处理
Opcode中的值代表着这个帧的作用(0-7:数据帧 8-F:控制帧)
- 0:后续帧,分片时用到
- 1:文本帧,说明发过来的数据是文本
- 2:二进制帧,说明发过来的数据是二进制
- 3-7:保留的数据帧,暂时无作用
- 8:关闭帧,说明对面要关闭连接了
- 9:ping帧,对方ping过来,你就要pong回去→_→
- A:pong帧,对方ping过来时,需要返回pong帧回去,以示响应
- B-F:保留的控制帧,暂时无作用
帧的结构,就是如上了,当要发送数据的时候,按照以上格式,发送即可
掩码加密与解密
当发送的数据需要掩码加密(解密也一样)的时候,一共有4字节位掩码,规则如下
第i个数据(Application data) 需要和 第 i%4 个掩码做 异或运算,即
//原始数据
var data = new Buffer("我是demo")
//四个字节的掩码
var mask = [0x24,0x48,0xad,0x54]
for(var i = 0;i<data.length;i++){
data[i] = data[i] ^ mask[i%4];
}
//data即可变成 加密或解密后的数据
数据分片
当你没法一个帧就把想要的数据发送完毕,你可以选择数据分片,注意,控制帧不能分片(数据长度也不得超过125)
方法如下 (当只有份2片时,只执行1,3):
- 发送第一个帧 FIN 为0,Opcode 为响应的数据类型
- 发送其他分片帧 FIN 为0,Opcode 为0
- 发送最后一个帧 FIN 为1,Opcode 为0
只有FIN和Opcode需要变化,其他的该怎么写,还是怎么写
关闭帧
- 当接收到关闭帧这个控制帧后,应该 尽快吧没有发送完毕的数据发送完(例如分片),然后再响应一个关闭帧.
- 关闭帧内可能会有数据,可以用来说明关闭的理由等等,但是没有规定是人类可读语言,所以不一定是字符串
ping帧
- 当接收到ping帧的时候,应该返回一个pong帧,而且,ping帧可能带有数据,那么pong帧也需要带上ping过来的数据并返回
数据传输的基本注意事项,就以上了
连接结束####
当连接不需要继续存在时,就可以结束了
基本流程是:
- 一端发送一个 关闭帧
- 另外一端再响应一个关闭帧
- 断开TCP
完成这三步即可,但是,存在特殊情况
- 有一端的程序关闭了,TCP连接直接关闭,并没有发送 关闭帧
- 有一端的程序发送关闭帧以后,马上断开了TCP,另外一端发送关闭帧的时候,报错了
我就被以上的坑坑过,所以要注意一下,当TCP连接出错时,直接当成已经关闭即可
如果 浏览器发送关闭帧,服务器没有响应的话,大概会在30-60秒左右会断开TCP,所以不需要怕发了关闭帧缺没有断开TCP(但如果是自己实现的客户端就要注意了!!!)
经过 TCP连接 → 握手协议 → 数据传输 → 连接结束 就基本走完一个websocket流程了
HTTP 与 Websocket 混合服务器简易demo
看完以上,就基本知道websocket的基本原理了,下面这个demo 是可选看的,但看完以后,或许会加深一点理解,本来想一块一块说明,但是发现很多余,所以直接贴代码了,并补回了一点注释
var i = 0;
//聊天室相关语句
var websocket_pool = new Set();
var history = [];
//工具包函数集合
var Websocket_Http_Util = {
//用来解析头部的函数
getHeaders: function (headerString) {
var header_arr = headerString.split("\r\n");
var headers = {};
for (var i in header_arr) {
var tmp = header_arr[i].split(":");
if (tmp.length < 2) continue;
headers[tmp[0].trim()] = tmp[1].trim();
}
//这部分是不标准的
var first_line = header_arr[0].split(" ");
headers.method = first_line[0];
headers.path = first_line[1];
return headers;
},
//用来把想要发送的数据打包成 数据帧的函数
packMessage: function (message) {
var message_len = Buffer.byteLength(message);
var len = message_len > 65535 ? 10 : (message_len > 125 ? 4 : 2);
var buf = new Buffer(message_len + len);
buf[0] = 0x81;
if (len == 2) {
buf[1] = message_len;
} else if (len == 4) {
buf[1] = 126;
buf.writeUInt16BE(message_len, 2);
} else {
buf[1] = 127;
buf.writeUInt32BE(message_len >>> 32, 2);
buf.writeUInt32BE(message_len & 0xFFFFFFFF, 6);
}
buf.write(message, len);
return buf;
}
};
//创建TCP服务器
var http_websocket = require('net').createServer(socket => {
//当有客户端连接成功时,则会执行本函数
//当TCP连接接收到消息时,运行一次下面的函数
socket.once("data", data => {
//解析头部,这里没有做验证函数,
var headers = Websocket_Http_Util.getHeaders(data.toString());
//这里的验证是否websocket也采用粗略验证,这个是不正规的!
if (headers["Sec-WebSocket-Key"]) {
//websocket
var name = 'socke[' + (i++) + ']'; //用来标记连接名字,无实际作用
var tmpData = new Buffer(0); //分片数据集合
var tmpType = null; //储存分片数据的类型
//给websocket时间做得触发器,会出发data:接受到消息 时间,和 end:关闭连接 事件
var websocketEmitter = new (require('events').EventEmitter)();
//聊天室相关语句
websocket_pool.add(socket);
//握手实现,这里取最简单的
socket.write(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept:" +
require('crypto').createHash('sha1').update(headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest('base64') +
"\r\n\r\n"
);
//接受包
socket.on("data", data => {
var index = 2;
var isFinish = data[0] >>> 7 == 1;
var opcode = data[0] & 15;
var len = data[1] & 127;
//分析长度
if (len == 126) {
len = data.readUIntBE(index, index += 2);
} else if (len == 127) {
len = data.readUIntBE(index, index += 8);
}
//掩码解析并解码
var mask = (data[1] >>> 7 > 0) ? data.slice(index, index += 4) : null;
if (mask) {
for (var i = index; i < data.length; i++) {
data[i] = mask[(i - index) % 4] ^ data[i];
}
}
//截获消息
data = data.slice(index);
if (isFinish) {
//消息响应
if (opcode == 8) {
//关闭帧响应
socket.write(new Buffer([0x88, 0x00]));
socket.end();
websocketEmitter.emit("end");
} else if (opcode == 9) {
//ping帧响应
console.log("接受ping,来自:" + name);
socket.write(Buffer.concat([new Buffer([0x8A, data.length]), data]));
} else if (opcode == 0 || opcode == 1 || opcode == 2) {
//数据响应
websocketEmitter.emit("data", Buffer.concat([tmpData, data]));
tmpData = new Buffer(0);
tmpType = null;
}
} else {
//当分片时,纪录缓存数据
tmpData = Buffer.concat([tmpData, data]);
if (tmpType === null) tmpType = opcode;
}
})
websocketEmitter.on("data", data => {
//接受信息
console.log(name, data.toString());
//聊天室相关语句
var message = Websocket_Http_Util.packMessage(name + ":" + data);
for(var s of websocket_pool){
s.write(message);
}
history.push(name + ":" + data.toString());
if(history.length > 100) history.shift();
});
websocketEmitter.once("end", () => {
//连接断开(绑定一次)
console.log(name, "断开连接");
//聊天室相关语句
websocket_pool.delete(socket)
});
socket.on("end", ()=> {
console.log("socket断开连接");
websocketEmitter.emit("end");
});
socket.on("error", (err) => {
websocketEmitter.emit("end");
})
} else {
//HTTP这块做得非常粗略
//直接返回了内容,没有判断他的头部+请求内容
//这里就可以去写更多的代码,去实现好HTTP的实际服务器
console.log("网页访问");
socket.write("HTTP/1.1 200 OK\r\nserver: Meislzhua\r\n\r\n");
socket.write("you get path in:" + headers.path);
for(var message of history){
socket.write(message+"<br>\r\n");
}
socket.end();
}
});
});
//绑定在80端口
http_websocket.listen(80);
浏览器端,做得好丑,只为了实现功能
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>socket-test</title>
<script src="//cdn.bootcss.com/jquery/2.2.4/jquery.min.js"></script>
<style>
#message-box {
max-height: 300px;
overflow: auto;
}
</style>
<script>
var addMessage = function (message) {
$("#message-box .content").append(message + "<br>")
};
var ws = new WebSocket("ws://localhost");
ws.onopen = function () {
addMessage("连接服务器成功!")
};
ws.onclose = function () {
addMessage("与服务器断开连接")
};
ws.onerror = function(evt) {
addMessage("出错:");
addMessage(JSON.stringify(evt));
};
ws.onmessage = function(message){
console.log(message);
$("#response-box .content").append(message.data + "<br>")
}
</script>
</head>
<body>
<input type="text" id="send-text">
<button id="commit" onclick="ws.send($('#send-text').val());$('#send-text').val('')">提交</button>
<div id="message-box">
<div class="content"></div>
</div>
<div id="response-box">
<div class="content"></div>
</div>
</body>
</html>