写在开头:javascript
为何要使用websocket协议(如下简称ws协议),什么场景会使用?html
我以前是作IM相关桌面端软件的开发,基于TCP长连接本身封装的一套私有协议,目前公司也有项目用到了ws协议,好像不管什么行业,都会遇到这个ws协议。前端
内容同步更新在个人:前端巅峰
微信工做公众号vue
想本身造轮子,能够参考我以前的代码和文章:java
原创:从零实现一个简单版React (附源码) react
原创:如何本身实现一个简单的webpack构建工具 【附源码】 webpack
首先它的使用是很简单的,在H5和Node.js中都是基于事件驱动git
在H5中github
在H5中的使用案例:web
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <script type="text/javascript"> function WebSocketTest() { if ('WebSocket' in window) { alert('您的浏览器支持 WebSocket!'); // 打开一个 web socket var ws = new WebSocket('ws://localhost:9998'); ws.onopen = function() { // Web Socket 已链接上,使用 send() 方法发送数据 ws.send('发送数据'); alert('数据发送中...'); }; ws.onmessage = function(evt) { var received_msg = evt.data; alert('数据已接收...'); }; ws.onclose = function() { // 关闭 websocket alert('链接已关闭...'); }; } else { // 浏览器不支持 WebSocket alert('您的浏览器不支持 WebSocket!'); } } </script> </head> <body> <div id="sse"> <a href="javascript:WebSocketTest()">运行 WebSocket</a> </div> </body> </html>
Node.js中的服务端搭建:
const { Server } = require('ws'); //引入模块 const wss = new Server({ port: 9998 }); //建立一个WebSocketServer的实例,监听端口9998 wss.on('connection', function connection(socket) { socket.on('message', function incoming(message) { console.log('received: %s', message); socket.send('Hi Client'); }); //当收到消息时,在控制台打印出来,并回复一条信息 });
这样你就愉快的通讯了,不须要关注协议的实现,可是真正的项目场景中,可能会有UDP、TCP、FTP、SFTP等场景,你仍是须要了解不一样的协议实现细节
正式开始:
为何要使用ws协议?
传统的Ajax轮询(即一直不听发请求去后端拿数据)或长轮询的操做太过于粗暴,性能更不用说。
ws协议在目前浏览器中支持已经很是好了,另外这里说一句,它也是一个应用层协议,成功升级ws协议,是101状态码,像webpack热更新这些都有用ws协议
**这就是链接了本地的ws服务器
**
如今开始,咱们实现服务端的ws协议,就是本身实现一个websocket类,而且继承Node.js的自定义事件模块,还要一个起一个进程占用端口,那么就要用到http模块
const { EventEmitter } = require('events'); const { createServer } = require('http'); class MyWebsocket extends EventEmitter {} module.exports = MyWebsocket;
这是一个基础的类,咱们继承了自定义事件模块,还引入了http的createServer方法,此时先实现端口占用
const { EventEmitter } = require('events'); const { createServer } = require('http'); class MyWebsocket extends EventEmitter { constructor(options) { super(options); this.options = options; this.server = createServer(); options.port ? this.server.listen(options.port) : this.server.listen(8080); //默认端口8080 } } module.exports = MyWebsocket;
接下来,要先分析下请求ws协议的请求头、响应头
正常一个ws协议成功创建分下面这几个步骤
客户端请求升级协议
GET / HTTP/1.1Upgrade: websocketConnection:UpgradeHost: example.com
Origin: http://example.comSec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==Sec-WebSocket-Version:13
服务端响应,
HTTP/1.1101SwitchingProtocolsUpgrade: websocketConnection:UpgradeSec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=Sec-WebSocket-Location: ws://example.com/
如下是官方对这些字段的解释:
**这里得先看这张图
**
**在第一次Http握手阶段,触发服务端的upgrade事件,咱们把浏览器端的ws地址改为咱们的本身实现的端口地址
**
websocket的协议特色:
创建在 TCP 协议之上,服务器端的实现比较容易。
与 HTTP 协议有着良好的兼容性。默认端口也是80和443,而且握手阶段采用 HTTP 协议,所以握手时不容易屏蔽,能经过各类 HTTP 代理服务器。
数据格式比较轻量,性能开销小,通讯高效。
能够发送文本,也能够发送二进制数据。
没有同源限制,客户端能够与任意服务器通讯。
回到正题,将客户端ws协议链接地址选择咱们的服务器地址,而后改造服务端代码,监听upgrade事件看看
const { EventEmitter } = require('events'); const { createServer } = require('http'); class MyWebsocket extends EventEmitter { constructor(options) { super(options); this.options = options; this.server = createServer(); options.port ? this.server.listen(options.port) : this.server.listen(8080); //默认端口8080 // 处理协议升级请求 this.server.on('upgrade', (req, socket, header) => { this.socket = socket; console.log(req.headers); socket.write('hello'); }); } } module.exports = MyWebsocket;
咱们能够看到,监听到了协议请求升级事件,并且能够拿到请求头部。上面提到过:
说人话:
就是要给一个特定的响应头,告诉浏览器,这ws协议请求升级,我赞成了。
代码实现:
const { EventEmitter } = require('events'); const { createServer } = require('http'); const crypto = require('crypto'); const MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // 固定的字符串 function hashWebSocketKey(key) { const sha1 = crypto.createHash('sha1'); // 拿到sha1算法 sha1.update(key + MAGIC_STRING, 'ascii'); return sha1.digest('base64'); } class MyWebsocket extends EventEmitter { constructor(options) { super(options); this.options = options; this.server = createServer(); options.port ? this.server.listen(options.port) : this.server.listen(8080); //默认端口8080 this.server.on('upgrade', (req, socket, header) => { this.socket = socket; console.log(req.headers['sec-websocket-key'], 'key'); const resKey = hashWebSocketKey(req.headers['sec-websocket-key']); // 对浏览器生成的key进行加密 // 构造响应头 const resHeaders = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Accept: ' + resKey, ] .concat('', '') .join('\r\n'); console.log(resHeaders, 'resHeaders'); socket.write(resHeaders); // 返回响应头部 }); } } module.exports = MyWebsocket;
看看network面板,状态码已经变成了101,到这一步,咱们已经把协议升级成功,而且写入了响应头
剩下的就是数据交互了,既然ws是长连接+双工通信,并且是应用层,创建在TCP之上封装的,这张图应该能很好的解释(来自阮一峰老师的博客)
网络链路已经通了,协议已经打通,剩下一个长连接+数据推送了,可是咱们目前仍是一个普通的http服务器
这是一个websocket的基本帧协议(其实websocket能够当作基于TCP封装的私有协议,只不过你们采用了某个标准达成了共识,有兴趣的能够看看微服务架构的相关内容,设计私有协议,端到端加密等)
其中FIN表明是否为消息的最后一个数据帧(相似TCP的FIN,TCP也会分片传输)
Mask(占1位):表示是否通过掩码处理, 1 是通过掩码的,0是没有通过掩码的。若是Mask位为1,表示这是客户端发送过来的数据,由于客户端发送的数据要进行掩码加密;若是Mask为0,表示这是服务端发送的数据。
payload length (7位+16位,或者 7位+64位),定义负载数据的长度。
1.若是数据长度小于等于125的话,那么该7位用来表示实际数据长度。
2.若是数据长度为126到65535(2的16次方)之间,该7位值固定为126,也就是 1111110,日后扩展2个字节(16为,第三个区块表示),用于存储数据的实际长度。
3.若是数据长度大于65535, 该7位的值固定为127,也就是 1111111 ,日后扩展8个字节(64位),用于存储数据实际长度。
Masking-key(0或者4个字节),该区块用于存储掩码密钥,只有在第二个子节中的mask为1,也就是消息进行了掩码处理时才有,不然没有,因此服务器端向客户端发送消息就没有这一块。
Payload data 扩展数据,是0字节,除非已经协商了一个扩展。
如今咱们须要保持长连接
⚠️:若是你是使用Node.js开启基于TCP的私有双工长连接协议,也要开启这个选项
const { EventEmitter } = require('events'); const { createServer } = require('http'); const crypto = require('crypto'); const MAGIC_STRING = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // 固定的字符串 function hashWebSocketKey(key) { const sha1 = crypto.createHash('sha1'); // 拿到sha1算法 sha1.update(key + MAGIC_STRING, 'ascii'); return sha1.digest('base64'); } class MyWebsocket extends EventEmitter { constructor(options) { super(options); this.options = options; this.server = createServer(); options.port ? this.server.listen(options.port) : this.server.listen(8080); //默认端口8080 this.server.on('upgrade', (req, socket, header) => { this.socket = socket; socket.setKeepAlive(true); console.log(req.headers['sec-websocket-key'], 'key'); const resKey = hashWebSocketKey(req.headers['sec-websocket-key']); // 对浏览器生成的key进行加密 // 构造响应头 const resHeaders = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Accept: ' + resKey, ] .concat('', '') .join('\r\n'); console.log(resHeaders, 'resHeaders'); socket.write(resHeaders); // 返回响应头部 }); } } module.exports = MyWebsocket;
OK,如今最重要的一个通讯长连接和头部已经实现,只剩下两点:
提示:若是这两点你看不懂不要紧,只是一个运算过程,当你本身基于TCP设计私有协议时候,也要考虑这些,msgType、payloadLength、服务端发包粘包、客户端收包粘包、断线重传、timeout、心跳、发送队列等
给socket对象挂载事件,咱们已经继承了EventEmitter模块
socket.on('data', (data) => { // 监听客户端发送过来的数据,该数据是一个Buffer类型的数据 this.buffer = data; // 将客户端发送过来的帧数据保存到buffer变量中 this.processBuffer(); // 处理Buffer数据 }); socket.on('close', (error) => { // 监听客户端链接断开事件 if (!this.closed) { this.emit('close', 1006, 'timeout'); this.closed = true; }
每次接受到了data,触发事件,解析Buffer,进行运算
processBuffer() { let buf = this.buffer; let idx = 2; // 首先分析前两个字节 // 处理第一个字节 const byte1 = buf.readUInt8(0); // 读取buffer数据的前8 bit并转换为十进制整数 // 获取第一个字节的最高位,看是0仍是1 const str1 = byte1.toString(2); // 将第一个字节转换为二进制的字符串形式 const FIN = str1[0]; // 获取第一个字节的后四位,让第一个字节与00001111进行与运算,便可拿到后四位 let opcode = byte1 & 0x0f; //截取第一个字节的后4位,即opcode码, 等价于 (byte1 & 15) // 处理第二个字节 const byte2 = buf.readUInt8(1); // 从第一个字节开始读取8位,即读取数据帧第二个字节数据 const str2 = byte2.toString(2); // 将第二个字节转换为二进制的字符串形式 const MASK = str2[0]; // 获取第二个字节的第一位,判断是否有掩码,客户端必需要有 let length = parseInt(str2.substring(1), 2); // 获取第二个字节除第一位掩码以后的字符串并转换为整数 if (length === 126) { // 说明125<数据长度<65535(16个位能描述的最大值,也就是16个1的时候) length = buf.readUInt16BE(2); // 就用第三个字节及第四个字节表示数据的长度 idx += 2; // 偏移两个字节 } else if (length === 127) { // 说明数据长度已经大于65535,16个位也已经不足以描述数据长度了,就用第三到第十个字节这八个字节来描述数据长度 const highBits = buf.readUInt32BE(2); // 从第二个字节开始读取32位,即4个字节,表示后8个字节(64位)用于表示数据长度,其中高4字节是0 if (highBits != 0) { // 前四个字节必须为0,不然数据异常,须要关闭链接 this.close(1009, ''); //1009 关闭代码,说明数据太大;协议里是支持 63 位长度,不过这里咱们本身实现的话,只支持 32 位长度,防止数据过大; } length = buf.readUInt32BE(6); // 获取八个字节中的后四个字节用于表示数据长度,即从第6到第10个字节,为真实存放的数据长度 idx += 8; } let realData = null; // 保存真实数据对应字符串形式 if (MASK) { // 若是存在MASK掩码,表示是客户端发送过来的数据,是加密过的数据,须要进行数据解码 const maskDataBuffer = buf.slice(idx, idx + 4); //获取掩码数据, 其中前四个字节为掩码数据 idx += 4; //指针前移到真实数据段 const realDataBuffer = buf.slice(idx, idx + length); // 获取真实数据对应的Buffer realData = handleMask(maskDataBuffer, realDataBuffer); //解码真实数据 console.log(`realData is ${realData}`); } let realDataBuffer = Buffer.from(realData); // 将真实数据转换为Buffer this.buffer = buf.slice(idx + length); // 清除已处理的buffer数据 if (FIN) { // 若是第一个字节的第一位为1,表示是消息的最后一个分片,即所有消息结束了(发送的数据比较少,一次发送完成) this.handleRealData(opcode, realDataBuffer); // 处理操做码 } }
若是FIN不为0,那么意味着分片结束,能够解析Buffer。
处理mask掩码(客户端发过来的是1,服务端发的是0)获得真正到数据
function handleMask(maskBytes, data) { const payload = Buffer.alloc(data.length); for (let i = 0; i < data.length; i++) { // 遍历真实数据 payload[i] = maskBytes[i % 4] ^ data[i]; // 掩码有4个字节依次与真实数据进行异或运算便可 } return payload; }
根据opcode(接受到的数据是字符串仍是Buffer)进行处理:
const OPCODES = { CONTINUE: 0, TEXT: 1, BINARY: 2, CLOSE: 8, PING: 9, PONG: 10, }; // 处理客户端发送过来的真实数据 handleRealData(opcode, realDataBuffer) { switch (opcode) { case OPCODES.TEXT: this.emit('data', realDataBuffer.toString('utf8')); // 服务端WebSocket监听data事件便可拿到数据 break; case OPCODES.BINARY: //二进制文件直接交付 this.emit('data', realDataBuffer); break; default: this.close(1002, 'unhandle opcode:' + opcode); } }
若是是Buffer就转换为utf8的字符串(若是是protobuffer协议,那么还要根据pb文件进行解析)
接受数据已经搞定,传输数据无非两种,字符串和二进制,那么发送也是。
下面把发送搞定
send(data) { let opcode; let buffer; if (Buffer.isBuffer(data)) { // 若是是二进制数据 opcode = OPCODES.BINARY; // 操做码设置为二进制类型 buffer = data; } else if (typeof data === 'string') { // 若是是字符串 opcode = OPCODES.TEXT; // 操做码设置为文本类型 buffer = Buffer.from(data, 'utf8'); // 将字符串转换为Buffer数据 } else { throw new Error('cannot send object.Must be string of Buffer'); } this.doSend(opcode, buffer); } // 开始发送数据 doSend(opcode, buffer) { this.socket.write(encodeMessage(opcode, buffer)); //编码后直接经过socket发送 }
首先把要发送的数据都转换成二进制,而后进行数据帧格式拼装
function encodeMessage(opcode, payload) { let buf; // 0x80 二进制为 10000000 | opcode 进行或运算就至关因而将首位置为1 let b1 = 0x80 | opcode; // 若是没有数据了将FIN置为1 let b2; // 存放数据长度 let length = payload.length; console.log(`encodeMessage: length is ${length}`); if (length < 126) { buf = Buffer.alloc(payload.length + 2 + 0); // 服务器返回的数据不须要加密,直接加2个字节便可 b2 = length; // MASK为0,直接赋值为length值便可 buf.writeUInt8(b1, 0); //从第0个字节开始写入8位,即将b1写入到第一个字节中 buf.writeUInt8(b2, 1); //读8―15bit,将字节长度写入到第二个字节中 payload.copy(buf, 2); //复制数据,从2(第三)字节开始,将数据插入到第二个字节后面 } return buf; }
服务端发送的数据,Mask的值为0
此时在外面监听事件,像平时同样使用ws协议同样便可。
const MyWebSocket = require('./ws'); const ws = new MyWebSocket({ port: 8080 }); ws.on('data', data => { console.log('receive data:' + data); ws.send('this message from server'); }); ws.on('close', (code, reason) => { console.log('close:', code, reason); });
本文仓库地址源码:
https://github.com/JinJieTan/my-websocket
历史的文章源码:
手写mini-react: https://github.com/JinJieTan/mini-react 手写mini-webpack: https://github.com/JinJieTan/react-webpack 手写静态资源服务器 : https://github.com/JinJieTan/util-static-server 手写微前端框架、vue .....