WebSocket协议
发布于 7 年前 作者 JudonH 5742 次浏览 来自 分享

一、背景

1. 目标:浏览器与服务器全双工通信的机制

需要与服务器全双工通信且不需要依赖打开多个 HTTP 连接(例如,使用 XMLHttpRequest 或<iframe>和长轮询)的基于浏览器应用的提供一种机制。

2. 协议:包括一个打开阶段握手、接着是基本消息帧、TCP 之上的分层(layered over TCP)

3. 解决了以下的问题:

实现客户端和服务之间双向通信web应用,需要一个滥用的HTTP来轮询服务器进行更新但以不同的 HTTP 调用发生上行通知。

  • 服务器被迫为每个客户端使用一些不同的底层 TCP 连接: 一个用于发送 信息到客户端和一个新的用于每个传入消息。
  • 线路层协议有较高的开销,因为每个客户端-服务器消息都有一个 HTTP 头信息。
  • 客户端脚本被迫维护一个传出的连接到传入的连接的映射来跟踪回复。

4. 应用场景:

游戏、股票行情、同时编辑的多用户应用、服务器端服务以实时暴露的用户接口等

5. 与TCP和HTTP的关系

WebSocket 协议是一个独立的基于 TCP 的协议。它与 HTTP 唯一的关系是它的握手是由 HTTP 服务器解释为一个Upgrade请求。默认情况下WebSocket使用80或者443(TLS)端口进行连接。

二、过程

1. 握手

客户端发起的请求头:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
服务器响应的头字段
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

其中Key是一个16字节的base64编码,服务器接收到key,与258EAFA5-E914-47DA-95CA-C5AB0DC85B11字符串连接,通过SHA-1散列字符串,接着base64编码得到Sec-WebSocket-Accept

2. 数据传输

  • 为避免混淆网络中间件(例如拦截代理)和出于安全原因,客户端必须掩码( mask)它 发送到服务器的所有帧。
  • 服务器必须不掩码发送到客户端的所有帧。
//数据帧报文二进制格式
 0               1               2               3
 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: 1bit

代表消息是否是最后片段,为1时表示最后片段

  • RSV1, RSV2, RSV3: 每个1bit

必须为0, 拓展字段

  • Opcode: 4bit 【范围0~F】

定义负载数据

  • 0 :代表一个继续帧
  • 1 :代表一个文本帧
  • 2 :代表一个二进制帧
  • 3-7 :保留用于未来的非控制帧
  • 8 :代表连接关闭
  • 9 :代表ping
  • A :代表pong
  • B-F :保留用于未来的控制帧
  • Mask: 1bit

定义负载数据是否掩码过的,1表示有masking-key

  • Payload length: 7bits, 7+16bits, 7+64bits

负载数据的长度,以字节为单位

  • 0-125:这是负载长度
  • 126:之后两个字节无符号整数是负载长度
  • 127:之后八个字节无符号整数是负载长度
  • Masking-key: 0 or 4 bytes

客户端发送到服务器的所有帧通过一个包含在帧中的32位值来掩码

  • Payload data: (x+y)bytes

负载数据:扩展数据+应用数据

  • Extension data: x bytes

扩展数据

  • Application data: y bytes

应用数据

3. 关闭握手

两个节点中的任一个都能发送一个控制帧与包含一个指定控制序列的数据来开始关闭阶段握手

4. WebSocket URI

  • ws-URI = “ws:” “//” host [ “:” port ] path [ “?” query ] port:80
  • wss-URI = “wss:” “//” host [ “:” port ] path [ “?” query ] port:443

5. 分片(Fragmentation)

  • 目的:
  • <1>允许当消息开始但不必缓冲该消息时发送一个未知大小的消息。
  • <2>用于多路复用,一个逻辑通道上的一个大消息独占输出通道是不可取的,因此多路复用需要可以分割消息为更小的分段来更好的共享输出通道。
  • 分片例子:发送一个三个片段的文本消息 1(fin:0, opcode:1) --> 2(fin:0, opcode:0) --> 3(fin:1, opcode:0)

三、实际应用

1. 浏览器JS代码实现客户端websocket通信

var uri = "ws://echo.websocket.org/";
var websocket = new WebSocket(uri);

// 设置二进制数据帧类型 arraybuffer || blod 不设置时默认是文本消息
websocket.binaryType = "arraybuffer"; //二进制数组格式传输

// 连接成功触发连接打开事件
websocket.onopen = function(e){};

// 连接失败触发连接失败事件
websocket.onerror = function(e){};

// 接收到数据
websocket.onmessage = function(e){};

// 服务器关闭连接请求触发连接关闭事件
websocket.onclose = function(e){};

// 客户端发送数据
websocket.send();

// 客户端关闭连接
websocket.close();

2. WebSocket服务器实现

//引入http服务模块
var http = require('http');
//引入加密模块
var crypto = require('crypto');
//引入url模块
var url = require('url');
//引入fs模块
var fs = require('fs');
//引入编码模块
var iconv = require('iconv-lite');
//创建一个WebSocketServer
function WebSocketServer() {

}
//用于解码
var unmask = function(mask, data) {
	//取出掩码的值
	var maskNum = mask.readUInt32LE(0, true);
	//取得data的长度,其是负载数据长度
	var length = data.length;
	var i = 0;
	//length-3是防止读取出错,由于每次读取4位
	for (; i < length - 3; i += 4) {
		//对每个32bit与掩码进行异或操作
		var num = maskNum ^ data.readUInt32LE(i, true);
		//判断num是否小于0
		if (num < 0)
			//用2^32加上num
			num = 4294967296 + num;
		//将解码后的数据写回去。
		data.writeUInt32LE(num, i, true);
	}
	//剩余的长度
	switch (length % 4) {
		//当余下的长度是3的时候,进行下面3步操作
		case 3:
			//将abc中的c与mask的第三位异或
			data[i + 2] = data[i + 2] ^ mask[2];
		case 2:
			//将abc中的b与mask的第二位异或
			data[i + 1] = data[i + 1] ^ mask[1];
		case 1:
			//将abc中的a与mask的第一位异或
			data[i] = data[i] ^ mask[0];
		case 0:
			;
	}
};
WebSocketServer.prototype.onopen = function() {

};
WebSocketServer.prototype.setSocket = function(socket) {
	this.socket = socket;
	this.socket.on('data', this.receive);
};
//接收数据
WebSocketServer.prototype.receive = function(data) {
	//暂时支持接收文本数据且数据为单帧发送
	//读取fin
	var fin = (data.readUInt8(0) & 0x80) >> 7;
	//读取opcode
	var opcode = (data.readUInt8(0) & 0x0f);
	console.log('fin: ' + fin + ' opcode: ' + opcode);
	//=>fin: 1 opcode: 1
	//必须为1
	var mask = (data.readUInt8(1) & 0x80) >> 7;
	//读取payloadlength前7bit
	var payloadlength = data.readUInt8(1) & 0x7f;
	console.log('mask: ' + mask + ' payload length(7bit): ' + payloadlength);
	//没有算上掩码的长度的
	var offset = 2;
	//将长度保存
	var length = payloadlength;
	//根据payload length(前7bit)判断
	switch (payloadlength) {
		//当前7bit是126时,后16位为数据长度
		case 126:
			//获取数据的长度
			length = data.readUInt16BE(2);
			//保存偏移量
			offset += 2;
			break;
			//当前7bit是127时,后64位为数据长度
		case 127:
			//构造一个2^32(4294967296)的数
			var data = 4294967296;
			//获取64bit的前32bit
			var value1 = data.readUInt32BE(2);
			//获取64bit的后32bit
			var value2 = data.readUInt32BE(6);
			//获取数据的长度
			if (value1 !== 0) {
				length = value1 * data + value2;
			} else {
				length = value2;
			}
			//保存偏移量
			offset += 8;
			break;
		default:
			break;
	}
	console.log('数据长度:' + length);
	//取得maskingkey的值
	var maskingkey = new Buffer(4);
	//将掩码复制到maskingkey中
	data.copy(maskingkey, 0, offset, offset + 4);
	//保存偏移量
	offset += 4;
	var buffer = new Buffer(length);
	//将数据复制到buffer中
	data.copy(buffer, 0, offset, offset + length);
	//对数据进行解码
	unmask(maskingkey, buffer);
	//将数据转码为字符串
	var str = iconv.decode(buffer, 'utf8');
	console.log(str);
};
//发送数据
WebSocketServer.prototype.send = function(data) {
	//此函数暂时实现发字符串功能
	//data='Hello';
	//将字符创转换为Buffer对象
	var buffer = new Buffer(data);
	//取出数据的长度
	var length = buffer.length;
	/**
	 * 对数据长度进行判断,有三种情况:
	 * 1、当负载数据的长度范围是[0, 125]时,payload length是(7bit[数据长度])
	 * 2、当负载数据的长度范围是[126, 2^16-1]时,payload length是126(0111110)+(16bit[数据长度])
	 * 3、当负载数据的长度范围是[65536(2^16), 2^64-1]时,payload length是127(0111111)+(64bit[数据长度])
	 */
	//存储Buffer对象的数组
	var bufs = [];
	//所有对象的长度
	var size = 0;
	//定义一个用于存储fin(1bit)+res(3bit)+opcode(4bit)+mask(1bit)+payload length(7bit)
	var buf1 = new Buffer(2);
	//由于fin(1)+res(000)+opcode(0001)=0x81(1000 0001)
	buf1[0] = 0x81;
	//判断长度是否大于等于65536
	if (length >= 65536) {
		//写入一个8bit mask(0)+payload length[(0111111)+64bit]
		//即写入(0011 1111)
		buf1.writeUInt8(127, 1);
		//将对象保存到buffer数组中		
		bufs.push(buf1);
		//累加长度
		size += buf1.length;
		//定义一个用来表示长度的64bit的buffer
		var buf2 = new Buffer(8);
		//将64bit其分割为两个32bit的数据
		//使用无符号位移第一个32bit
		var value1 = (length & 0xffffffff00000000) >>> 32;
		//写入第一个32bit
		buf2.writeUInt32BE(value1, 0);
		//获取第二个32bit的数据
		var value2 = (length & 0x00000000ffffffff);
		//写入第二个32bit
		buf2.writeUInt32BE(value2, 4);
		//将其保存到数组中
		bufs.push(buf2)
			//累加长度
		size += buf2.length;
	} else if (length >= 126) {
		//写入一个8bit mask(0)+payload length[(0111111)+16bit]
		//即写入(0011 1110)
		buf1.writeUInt8(126, 1);
		//将对象保存到数组中
		bufs.push(buf1);
		//累加长度
		size += buf1.length;
		//定义一个用于表示长度的16bit的Buffer对象
		var buf2 = new Buffer(2);
		//将其写入
		buf2.writeUInt16BE(length, 0);
		//保存到数组中
		bufs.push(buf2);
		//累加长度
		size += buf2.length;
	} else {
		//将写写入数据
		buf1.writeUInt8(length, 1);
		//将其保存到数组中
		bufs.push(buf1);
		//累加长度
		size += buf1.length;
	}
	//将保存数据的buffer保存到数组中
	bufs.push(buffer);
	//累加长度
	size += buffer.length;
	//将字节数组中的字节对象转换为一个字节对象
	buffer = Buffer.concat(bufs, size);
	console.log(buffer);
	//var b=new Buffer([0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]);
	//发送数据到客户端
	this.socket.write(buffer);
};

//创建一个Web服务器
var server = http.createServer(function(req, res) {
	var pathname = url.parse(req.url).pathname;
	fs.readFile('/root/node-v0.12.2/project/YouMeChat' + pathname, function(err, file) {
		if (err) {
			res.writeHead(404);
			res.end('找不到相关文件。');
			return;
		}
		res.writeHead(200);
		res.end(file);
	});
});
//在监听6669端口的服务器
server.listen(7001, '192.168.41.82', function() {
	//获取服务器监听的地址和端口
	var address = server.address();
	console.log('http server listening at IP[' + address.address + '] PORT[' + address.port + ']');
	//回收内存
	address = null;
});
//监听是否是一个请求升级协议
server.on('upgrade', function(req, socket, upgradeHead) {
	//保存客户端的连接信息
	var address = socket.remoteAddress;
	var port = socket.remotePort;
	//打印出连接的客户端的信息
	console.log('Client IP[' + address + ']--PORT[' + port + '] connected OK!');
	//upgradeHead是升级后流的第一个包
	var head = new Buffer(upgradeHead.length);
	upgradeHead.copy(head);
	//取出用于安全校验的key
	var key = req.headers['sec-websocket-key'];
	var shasum = crypto.createHash('sha1');
	//用sha1安全散列算法计算出key,再进行Base64编码
	key = shasum.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
	//构建响应信息头
	var headers = [
		'HTTP/1.1 101 Switching Protocols',
		'Upgrade: websocket',
		'Connection: Upgrade',
		'Sec-WebSocket-Accept: ' + key
	];
	//取出请求头的协议字段
	var protocol = req.headers['sec-websocket-protocol'];
	//如果存在
	if (typeof protocol !== 'undefined') {
		//取出第一个协议
		protocol = protocol.split(/, */)[0];
		//将协议加入到响应头信息上
		headers.push('Sec-WebSocket-Protocol: ' + protocol);
	}
	//让数据立即发出去
	socket.setNoDelay(true);
	//拼接完成整个响应字段,http协议上需要空两行
	socket.write(headers.concat('', '').join('\r\n'));
	var websocket = new WebSocketServer();
	//设置socket
	websocket.setSocket(socket);
	//发送数据
	websocket.send('Hello World!!\n你好,世界。');
});

3. 浏览器的兼容性

查询地址

4. nginx反向代理WebSocket

# websocket 转发
location /web/websocket {
    #转发的目标地址
    proxy_pass http://10.0.1.18:3368/soc;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

注意:nginx在websocket长时间没有传输消息时,会断开连接,可以通过设置*proxy_read_timeout 7d;*增加断开连接时长,或者websocket中实现心跳功能保持连接不断开。

回到顶部