在去年的时候,写过一篇关于websocket的博文:http://www.cnblogs.com/axes/p/3586132.html ,里面主要是借助了nodejs-websocket这个插件,后来还用了socket.io作了些demo,可是,这些都是借助于别人封装好的插件作出来的,websocket究竟是怎么实现的呢本身以前真没怎么去想过,最近在看朴灵大神的《深刻浅出nodejs》时候,看到websocket那一章,看了一下websocket的数据帧的定义,就琢磨着本身用nodejs来实现一下。html
客户端的代码就不说了,websocket的API仍是很简单的,就经过onmessage、onopen、onclose,以及send方法就能够实现了。node
主要说服务端的代码:git
首先是协议的升级,这个比较简单,就简述一下:github
当在客户端执行new Websocket("ws://XXX.com/")的时候,客户端就会发起请求报文进行握手申请,报文中有个很重要的key就是Sec-WebSocket-Key,服务端获取到key,而后将这个key与字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11相连,对新的字符串经过sha1安全散列算法计算出结果后,再进行base64编码,而且将结果放在请求头的"Sec-WebSocket-Accept"中写出便可完成握手。而后便可进行数据传输web
客户端请求头截图:算法
而服务端的响应则请看代码:数组
server.on('upgrade', function (req, socket, upgradeHead) { var key = req.headers['sec-websocket-key']; key = crypto.createHash("sha1").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 ]; socket.setNoDelay(true); socket.write(headers.join("\r\n") + "\r\n\r\n", 'ascii'); var ws = new WebSocket(socket); webSocketCollector.push(ws); callback(ws); });
upgrade事件实际上是http这个模块的封装,再往底层就是net模块的实现,其实都差很少,若是直接用net模块来实现的话,就是监听net.createServer返回的server对象的data事件,接收到的第一份数据就是客户端发来的升级请求报文。浏览器
上面那段代码就完成了websocket的握手,而后就能够开始数据传输了。安全
看数据传输以前,先看看websocket数据帧的定义(由于以为深刻浅出nodejs里的帧定义图最容易理解,因此就贴这张了):服务器
上面的图中,每一列就是一个字节,一个字节总共是8位,每一位就是一个二进制数,不一样位的值会对应不一样的意义。
fin:指示这个是消息的最后片断。第一个片断可能也是最后的片断。若是为1即为最后片断。
rsv一、rsv二、rsv3: 各占一个位,用于扩展协商,基本上不怎么须要理,通常都是0
opcode:占四个位,能够表示0~15的十进制,0表示为附加数据帧,1表示为文本数据帧,2表示二进制数据帧,8表示发送一个链接关闭的数据帧,9表示ping,10表示pong,ping和pong都是用于心跳检测,当一端发送ping时,另外一端必须响应pong表示本身仍处于响应状态。
masked:占一个位,表示是否进行掩码处理,客户端发送给服务端时为1,服务端发送给客户端时为0
payload length:占7位,或者7+16位、或者7+64位。若是第二个字节的后面七个位的十进制值小于或等于125,则直接用这七个位表示数据长度;若是该值为126,说明 125<数据长度<65535(16个位能描述的最大值,也就是16个1的时候),就用第三个字节及第四个字节即16个位来表示;若是该值为127,则说明数据长度已经大于65535,16个位也已经不足以描述数据长度了,就用第三到第十个字节这八个字节来描述数据长度。
masking key:当masked为1的时候才存在,用于对咱们须要的数据进行解密。
payload data:咱们须要的数据,若是masked为1,该数据会被加密,要经过masking key进行异或运算解密才能获取到真实数据。
上面的帧定义中,fin位是初学的时候最容易搞混的,fin位看似简单表明分片的开始和结束,可是有一点要注意的就是,什么状况下才会分片?我刚开始理解的觉得是,当发送的数据比较大的时候会进行分片,可是实际操做发现并非,当我在浏览器上发送上万字节数据的时候,服务端收到的并非分片数据,而是分块数据。
什么是分块数据?其实就是把一段符合websocket数据规范的数据分红多块传输,从而服务端收到的第一块数据里fin位是1,可是附带的数据长度却比palyload length要少不少,在接下来又收到多块数据,后面收到的数据是没有上面那些控制头的,而是纯数据。总的来讲整个数据能够当作是一整块,而后分红多块后发给服务端。其实这个很容易理解,就是socket的分包发送而已。
那何时会分片?据我了解,只有当传输的数据长度不肯定的时候,才会进行分片,好比一边读某个数据一边发送给服务端。若是发送数据长度是肯定的,就算数据量很大也只是会进行分块而不会分片。
引用开涛博客里对分片的解释:
分片的主要目的是容许当消息开始但没必要缓冲该消息时发送一个未知大小的消息。若是消息不能被分片,那么端点将不得不缓冲整个消息以便在首字节发生以前统计出它的长度。对于分片,服务器或中间件能够选择一个合适大小的缓冲,当缓冲满时,写一个片断到网络。
帧定义解释完了,就能够根据数据来进行解析了,当有data过来的时候,先获取须要的数据信息,下面这段代码将获取到数据在data里的位置,以及数据长度,masking key以及opcode:
WebSocket.prototype.handleDataStat = function (data) { if (!this.stat) { var dataIndex = 2; //数据索引,由于第一个字节和第二个字节确定不为数据,因此初始值为2 var secondByte = data[1]; //表明masked位和多是payloadLength位的第二个字节 var hasMask = secondByte >= 128; //若是大于或等于128,说明masked位为1 secondByte -= hasMask ? 128 : 0; //若是有掩码,须要将掩码那一位去掉 var dataLength, maskedData; //若是为126,则后面16位长的数据为数据长度,若是为127,则后面64位长的数据为数据长度 if (secondByte == 126) { dataIndex += 2; dataLength = data.readUInt16BE(2); } else if (secondByte == 127) { dataIndex += 8; dataLength = data.readUInt32BE(2) + data.readUInt32BE(6); } else { dataLength = secondByte; } //若是有掩码,则获取32位的二进制masking key,同时更新index if (hasMask) { maskedData = data.slice(dataIndex, dataIndex + 4); dataIndex += 4; } //数据量最大为10kb if (dataLength > 10240) { this.send("Warning : data limit 10kb"); } else { //计算到此处时,dataIndex为数据位的起始位置,dataLength为数据长度,maskedData为二进制的解密数据 this.stat = { index: dataIndex, totalLength: dataLength, length: dataLength, maskedData: maskedData, opcode: parseInt(data[0].toString(16).split("")[1] , 16) //获取第一个字节的opcode位 }; } } else { this.stat.index = 0; } };
代码中均有注释,理解起来应该不难,直接看下一步,获取到数据信息后,就要对数据进行实际解析了:
通过上面handleDataStat方法的处理,stat中已经有了data的相关数据,先判断opcode,若是为9说明是客户端发起的ping心跳检测,直接返回pong响应,若是为10则为服务端发起的心跳检测。若是有masking key,则遍历数据段,对每一个字节都与masking key的字节进行异或运算(网上看到一个说法很形象:就是轮流发生X关系),^符号就是进行异或运算啦。若是没有masking key则直接经过slice方法把数据截取下来。
获取到数据后,放进datas里保存,由于有可能数据被分块了,因此再将stat里的长度减去当前数据长度,只有当stat里的长度为0的时候,说明当前帧为最后一帧,而后经过Buffer.concat将全部数据合并,此时再判断一下opcode,若是opcode为8,则说明客户端发起了一个关闭请求,而咱们获取到的数据则是关闭缘由。若是不为8,则这数据就是咱们须要的数据。而后再将stat重置为null,datas数组置空便可。至此,咱们的数据解析就完成了。
WebSocket.prototype.dataHandle = function (data) { this.handleDataStat(data); var stat; if (!(stat = this.stat)) return; //若是opcode为9,则发送pong响应,若是opcode为10则置pingtimes为0 if (stat.opcode === 9 || stat.opcode === 10) { (stat.opcode === 9) ? (this.sendPong()) : (this.pingTimes = 0); this.reset(); return; } var result; if (stat.maskedData) { result = new Buffer(data.length-stat.index); for (var i = stat.index, j = 0; i < data.length; i++, j++) { //对每一个字节进行异或运算,masked是4个字节,因此%4,借此循环 result[j] = data[i] ^ stat.maskedData[j % 4]; } } else { result = data.slice(stat.index, data.length); } this.datas.push(result); stat.length -= (data.length - stat.index); //当长度为0,说明当前帧为最后帧 if (stat.length == 0) { var buf = Buffer.concat(this.datas, stat.totalLength); if (stat.opcode == 8) { this.close(buf.toString()); } else { this.emit("message", buf.toString()); } this.reset(); } };
完成了客户端发来的数据解析,还须要一个服务端发数据至客户端的方法,也就是按照上面所说的帧定义来组装数据而且发送出去。下面的代码中基本上每一行都有注释,应该仍是比较容易理解的。
//数据发送 WebSocket.prototype.send = function (message) { if(this.state !== "OPEN") return; message = String(message); var length = Buffer.byteLength(message); // 数据的起始位置,若是数据长度16位也没法描述,则用64位,即8字节,若是16位能描述则用2字节,不然用第二个字节描述 var index = 2 + (length > 65535 ? 8 : (length > 125 ? 2 : 0)); // 定义buffer,长度为描述字节长度 + message长度 var buffer = new Buffer(index + length); // 第一个字节,fin位为1,opcode为1 buffer[0] = 129; // 由于是由服务端发至客户端,因此无需masked掩码 if (length > 65535) { buffer[1] = 127; // 长度超过65535的则由8个字节表示,由于4个字节能表达的长度为4294967295,已经彻底够用,所以直接将前面4个字节置0 buffer.writeUInt32BE(0, 2); buffer.writeUInt32BE(length, 6); } else if (length > 125) { buffer[1] = 126; // 长度超过125的话就由2个字节表示 buffer.writeUInt16BE(length, 2); } else { buffer[1] = length; } // 写入正文 buffer.write(message, index); this.socket.write(buffer); };
除此以外还要实现一个功能,就是心跳检测:防止服务端长时间不与客户端交互而致使客户端关闭链接,因此每隔十秒都会发送一次ping进行心跳检测
//每隔10秒进行一次心跳检测,若连续发出三次心跳却没收到响应则关闭socket WebSocket.prototype.checkHeartBeat = function () { var that = this; setTimeout(function () { if (that.state !== "OPEN") return; if (that.pingTimes >= 3) { that.close("time out"); return; } //记录心跳次数 that.pingTimes++; that.sendPing(); that.checkHeartBeat(); }, 10000); }; WebSocket.prototype.sendPing = function () { this.socket.write(new Buffer(['0x89', '0x0'])) }; WebSocket.prototype.sendPong = function () { this.socket.write(new Buffer(['0x8A', '0x0'])) };
最后,在主函数里直接调用,一收到消息就广播。。。
var server = http.createServer(function(req , res){ router.route(req , res); }).listen(9030); websocket.update(server , function(ws){ ws.on('close' , function(reason){ console.log("socket closed:"+reason); }); ws.on('message' , function(data){ websocket.brocast(data); }); });
至此,整个websocket的实现就完成了,此demo只是大概实现了一下websocket而已,在安全之类方面确定仍是有不少问题,如果真正生产环境中仍是用socket.io这类成熟的插件比较好。不过这仍是很值得一学的。
附上该demo的github地址:https://github.com/whxaxes/node-test/tree/master/server/websocket
里面的socket.js是该文中引用的代码完整版。socket_2.js则是后面我在公司内部进行了一次分享时写的优化版。
若是以为demo能帮到你,就给个star或者fork呗