>  기사  >  웹 프론트엔드  >  Node를 사용하여 WebSocket 프로토콜을 직접 작성하는 방법을 단계별로 안내합니다.

Node를 사용하여 WebSocket 프로토콜을 직접 작성하는 방법을 단계별로 안내합니다.

青灯夜游
青灯夜游앞으로
2023-02-16 14:42:452166검색

Node를 사용하여 WebSocket 프로토콜을 직접 작성하는 방법을 단계별로 안내합니다.

http는 질문과 답변 모드라는 것을 알고 있습니다. 클라이언트는 서버에 http 요청을 보내고 서버는 http 응답을 반환합니다.

이 모드는 리소스와 데이터를 로드하는 데는 충분하지만 데이터 푸시가 필요한 시나리오에는 적합하지 않습니다.

어떤 학생들은 http2에는 서버 푸시 기능이 없나요?

그냥 리소스 푸시용입니다.

예를 들어 브라우저가 html을 요청하면 서버는 CSS를 브라우저에 함께 푸시할 수 있습니다. 브라우저는 이를 수락할지 여부를 결정할 수 있습니다. [권장 관련 튜토리얼: nodejs 비디오 튜토리얼, 프로그래밍 교육]

인스턴트 메시징과 같이 실시간 요구 사항이 높은 시나리오의 경우 websocket을 사용해야 합니다.

엄밀히 말하면 websocket은 http와는 아무런 관련이 없습니다. 이는 또 다른 프로토콜 형식입니다. 하지만 http에서 websocekt로 전환하는 과정이 필요합니다.

전환 과정을 자세히 살펴보면 다음과 같습니다.

요청 시 다음 헤더를 가져오세요.

Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: Ia3dQjfWrAug/6qm7mTZOg==

처음 두 개는 이해하기 쉽습니다. 이는 websocket 프로토콜로 업그레이드한다는 의미입니다.

세 번째 헤더는 보안을 보장하는 데 사용되는 키입니다.

서버는 다음 헤더를 반환합니다.

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: JkE58n3uIigYDMvC+KsBbGZsp1A=

요청 헤더와 유사하게 Sec-WebSocket-Accept는 요청에서 가져온 Sec-WebSocket-Key를 처리한 결과입니다.

상대방이 WebSocket 기능을 가지고 있는지 확인하기 위해 이 헤더의 확인이 추가되었습니다. 그렇지 않으면 연결이 설정되었지만 상대방으로부터 메시지가 없으면 기다림이 헛된 것입니다.

Sec-WebSocket-Key를 어떻게 처리하여 Sec-WebSocket-Accept를 얻을 수 있나요?

노드를 이용하여 구현해 보니 다음과 같습니다.

const crypto = require('crypto');

function hashKey(key) {
  const sha1 = crypto.createHash('sha1');
  sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
  return sha1.digest('base64');
}

즉, 클라이언트가 전달한 키를 고정된 문자열에 추가하고, sha1로 암호화한 후 base64로 변환하는 것입니다.

이 문자열 258EAFA5-E914-47DA-95CA-C5AB0DC85B11은 수정되었습니다. 믿을 수 없다면 검색해 보세요.

Zhihu와 같은 websocket이 있는 웹사이트를 찾으세요.

필터링하세요. out ws 유형 요청의 경우 이러한 헤더를 보고 앞서 언급한 헤더인지 확인하세요.

이 Sec-WebSocket-Key는 wk60yiym2FEwCAMVZE3FgQ==

이고 해당 Sec-WebSocket-Accept는 XRfPnS+8xl11QWZherej/dkHPHM=

입니다.

아닌가 털이야!

웹소켓이 프로토콜을 업그레이드할 때 Sec-WebSocket-Key에 해당하는 Sec-WebSocket-Accept를 계산하는 과정입니다.

이 단계 후에는 새로운 프로토콜인 websocket 프로토콜로 전환합니다.

메시지 열을 확인하여 텍스트 또는 바이너리일 수 있는 전송된 메시지를 확인하세요.

새 프로토콜? 그렇다면 구체적으로 어떤 합의가 있는 걸까요?

이렇게:

모두에게 익숙한 http 프로토콜은 본문이 있는 key:value 헤더입니다.

텍스트 프로토콜이며 각 헤더는 이해하기 쉬운 문자입니다. .

이것은 이해하기 쉽지만 전송이 너무 많은 공간을 차지합니다.

그리고 websocket은 이진 프로토콜이므로 1바이트를 사용하여 많은 정보를 저장할 수 있습니다.

예를 들어, 프로토콜의 첫 번째 바이트는 FIN(종료 플래그), opcode(콘텐츠 유형이 이진 또는 텍스트임)를 저장합니다. ) 및 기타 정보.

두 번째 바이트는 마스크(암호화 여부)와 페이로드(데이터 길이)를 저장합니다.

단 2바이트, 얼마나 많은 정보가 저장되나요!

여기서 텍스트 프로토콜보다 바이너리 프로토콜이 더 좋습니다.

weboscket에서 메시지 송수신을 보면 맨 아래 레이어에는 실제로 이런 형식으로 철자가 적혀 있습니다.

브라우저가 이 형식의 프로토콜 데이터를 구문 분석하는 데 도움이 됩니다.

这就是 weboscket 的全部流程了。

其实还是挺清晰的,一个切换协议的过程,然后是二进制的 weboscket 协议的收发。

那我们就用 Node.js 自己实现一个 websocket 服务器吧!

定义个 MyWebsocket 的 class:

const { EventEmitter } = require('events');
const http = require('http');

class MyWebsocket extends EventEmitter {
  constructor(options) {
    super(options);

    const server = http.createServer();
    server.listen(options.port || 8080);

    server.on('upgrade', (req, socket) => {
      
    });
  }
}

继承 EventEmitter 是为了可以用 emit 发送一些事件,外界可以通过 on 监听这个事件来处理。

我们在构造函数里创建了一个 http 服务,当 ungrade 事件发生,也就是收到了 Connection: upgrade 的 header 的时候,返回切换协议的 header。

返回的 header 前面已经见过了,就是要对 sec-websocket-key 做下处理。

server.on('upgrade', (req, socket) => {
  this.socket = socket;
  socket.setKeepAlive(true);

  const resHeaders = [
    'HTTP/1.1 101 Switching Protocols',
    'Upgrade: websocket',
    'Connection: Upgrade',
    'Sec-WebSocket-Accept: ' + hashKey(req.headers['sec-websocket-key']),
    '',
    ''
  ].join('\r\n');
  socket.write(resHeaders);

  socket.on('data', (data) => {
    console.log(data)
  });
  socket.on('close', (error) => {
      this.emit('close');
  });
});

我们拿到 socket,返回上面的 header,其中 key 做的处理就是前面聊过的算法:

function hashKey(key) {
  const sha1 = crypto.createHash('sha1');
  sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
  return sha1.digest('base64');
}

就这么简单,就已经完成协议切换了。

不信我们试试看。

引入我们实现的 ws 服务器,跑起来:

const MyWebSocket = require('./ws');
const ws = new MyWebSocket({ port: 8080 });

ws.on('data', (data) => {
  console.log('receive data:' + data);
});

ws.on('close', (code, reason) => {
  console.log('close:', code, reason);
});

然后新建这样一个 html:

<!DOCTYPE HTML>
<html>
<body>
    <script>
        const ws = new WebSocket("ws://localhost:8080");

        ws.onopen = function () {
            ws.send("发送数据");
            setTimeout(() => {
                ws.send("发送数据2");
            }, 3000)
        };

        ws.onmessage = function (evt) {
            console.log(evt)
        };

        ws.onclose = function () {
        };
    </script>
</body>

</html>

用浏览器的 WebSocket api 建立连接,发送消息。

用 npx http-server . 起个静态服务。

然后浏览器访问这个 html:

这时打开 devtools 你就会发现协议切换成功了:

这 3 个 header 还有 101 状态码都是我们返回的。

message 里也可以看到发送的消息:

再去服务端看看,也收到了这个消息:

只不过是 Buffer 的,也就是二进制的。

接下来只要按照协议格式解析这个 Buffer,并且生成响应格式的协议数据 Buffer 返回就可以收发 websocket 数据了。

这一部分还是比较麻烦的,我们一点点来看。

我们需要第一个字节的后四位,也就是 opcode。

这样写:

const byte1 = bufferData.readUInt8(0);
let opcode = byte1 & 0x0f;

读取 8 位无符号整数的内容,也就是一个字节的内容。参数是偏移的字节,这里是 0。

通过位运算取出后四位,这就是 opcode 了。

然后再处理第二个字节:

第一位是 mask 标志位,后 7 位是 payload 长度。

可以这样取:

const byte2 = bufferData.readUInt8(1);
const str2 = byte2.toString(2);
const MASK = str2[0];
let payloadLength = parseInt(str2.substring(1), 2);

还是用 buffer.readUInt8 读取一个字节的内容。

先转成二进制字符串,这时第一位就是 mask,然后再截取后 7 位的子串,parseInt 成数字,这就是 payload 长度了。

这样前两个字节的协议内容就解析完了。

有同学可能问了,后面咋还有俩 payload 长度呢?

这是因为数据不一定有多长,可能需要 16 位存长度,可能需要 32 位。

于是 websocket 协议就规定了如果那个 7 位的内容不超过 125,那它就是 payload 长度。

如果 7 位的内容是 126,那就不用它了,用后面的 16 位的内容作为 payload 长度。

如果 7 位的内容是 127,也不用它了,用后面那个 64 位的内容作为 payload 长度。

其实还是容易理解的,就是 3 个 if else。

用代码写出来就是这样的:

let payloadLength = parseInt(str2.substring(1), 2);

let curByteIndex = 2;

if (payloadLength === 126) {
  payloadLength = bufferData.readUInt16BE(2);
  curByteIndex += 2;
} else if (payloadLength === 127) {
  payloadLength = bufferData.readBigUInt64BE(2);
  curByteIndex += 8;
}

这里的 curByteIndex 是存储当前处理到第几个字节的。

如果是 126,那就从第 3 个字节开始,读取 2 个字节也就是 16 位的长度,用 buffer.readUInt16BE 方法。

如果是 127,那就从第 3 个字节开始,读取 8 个字节也就是 64 位的长度,用 buffer.readBigUInt64BE 方法。

这样就拿到了 payload 的长度,然后再用这个长度去截取内容就好了。

但在读取数据之前,还有个 mask 要处理,这个是用来给内容解密的:

读 4 个字节,就是 mask key。

再后面的就可以根据 payload 长度读出来。

let realData = null;

if (MASK) {
  const maskKey = bufferData.slice(curByteIndex, curByteIndex + 4);  
  curByteIndex += 4;
  const payloadData = bufferData.slice(curByteIndex, curByteIndex + payloadLength);
  realData = handleMask(maskKey, payloadData);
} else {
  realData = bufferData.slice(curByteIndex, curByteIndex + payloadLength);;
}

然后用 mask key 来解密数据。

这个算法也是固定的,用每个字节的 mask key 和数据的每一位做按位异或就好了:

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];
  }
  return payload;
}

这样,我们就拿到了最终的数据!

但是传给处理程序之前,还要根据类型来处理下,因为内容分几种类型,也就是 opcode 有几种值:

const OPCODES = {
  CONTINUE: 0,
  TEXT: 1, // 文本
  BINARY: 2, // 二进制
  CLOSE: 8,
  PING: 9,
  PONG: 10,
};

我们只处理文本和二进制就好了:

handleRealData(opcode, realDataBuffer) {
    switch (opcode) {
      case OPCODES.TEXT:
        this.emit(&#39;data&#39;, realDataBuffer.toString(&#39;utf8&#39;));
        break;
      case OPCODES.BINARY:
        this.emit(&#39;data&#39;, realDataBuffer);
        break;
      default:
        this.emit(&#39;close&#39;);
        break;
    }
}

文本就转成 utf-8 的字符串,二进制数据就直接用 buffer 的数据。

这样,处理程序里就能拿到解析后的数据。

我们来试一下:

之前我们已经能拿到 weboscket 协议内容的 buffer 了:

而现在我们能正确解析出其中的数据:

至此,我们 websocket 协议的解析成功了!

这样的协议格式的数据叫做 frame,也就是帧:

解析可以了,接下来我们再实现数据的发送。

发送也是构造一样的 frame 格式。

定义这样一个 send 方法:

send(data) {
    let opcode;
    let buffer;
    if (Buffer.isBuffer(data)) {
      opcode = OPCODES.BINARY;
      buffer = data;
    } else if (typeof data === &#39;string&#39;) {
      opcode = OPCODES.TEXT;
      buffer = Buffer.from(data, &#39;utf8&#39;);
    } else {
      console.error(&#39;暂不支持发送的数据类型&#39;)
    }
    this.doSend(opcode, buffer);
}

doSend(opcode, bufferDatafer) {
   this.socket.write(encodeMessage(opcode, bufferDatafer));
}

根据发送的是文本还是二进制数据来对内容作处理。

然后构造 websocket 的 frame:

function encodeMessage(opcode, payload) {
  //payload.length < 126
  let bufferData = Buffer.alloc(payload.length + 2 + 0);;
  
  let byte1 = parseInt(&#39;10000000&#39;, 2) | opcode; // 设置 FIN 为 1
  let byte2 = payload.length;

  bufferData.writeUInt8(byte1, 0);
  bufferData.writeUInt8(byte2, 1);

  payload.copy(bufferData, 2);
  
  return bufferData;
}

我们只处理数据长度小于 125 的情况。

第一个字节是 opcode,我们把第一位置 1 ,通过按位或的方式。

服务端给客户端回消息不需要 mask,所以第二个字节就是 payload 长度。

分别把这前两个字节的数据写到 buffer 里,指定不同的 offset:

bufferData.writeUInt8(byte1, 0);
bufferData.writeUInt8(byte2, 1);

之后把 payload 数据放在后面:

 payload.copy(bufferData, 2);

这样一个 websocket 的 frame 就构造完了。

我们试一下:

收到客户端消息后,每两秒回一个消息。

收发消息都成功了!

就这样,我们自己实现了一个 websocket 服务器,实现了 websocket 协议的解析和生成!

完整代码如下:

MyWebSocket:

//ws.js
const { EventEmitter } = require('events');
const http = require('http');
const crypto = require(&#39;crypto&#39;);

function hashKey(key) {
  const sha1 = crypto.createHash(&#39;sha1&#39;);
  sha1.update(key + &#39;258EAFA5-E914-47DA-95CA-C5AB0DC85B11&#39;);
  return sha1.digest(&#39;base64&#39;);
}

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];
  }
  return payload;
}

const OPCODES = {
  CONTINUE: 0,
  TEXT: 1,
  BINARY: 2,
  CLOSE: 8,
  PING: 9,
  PONG: 10,
};

function encodeMessage(opcode, payload) {
  //payload.length < 126
  let bufferData = Buffer.alloc(payload.length + 2 + 0);;
  
  let byte1 = parseInt(&#39;10000000&#39;, 2) | opcode; // 设置 FIN 为 1
  let byte2 = payload.length;

  bufferData.writeUInt8(byte1, 0);
  bufferData.writeUInt8(byte2, 1);

  payload.copy(bufferData, 2);
  
  return bufferData;
}

class MyWebsocket extends EventEmitter {
  constructor(options) {
    super(options);

    const server = http.createServer();
    server.listen(options.port || 8080);

    server.on('upgrade', (req, socket) => {
      this.socket = socket;
      socket.setKeepAlive(true);

      const resHeaders = [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        'Sec-WebSocket-Accept: ' + hashKey(req.headers['sec-websocket-key']),
        '',
        ''
      ].join('\r\n');
      socket.write(resHeaders);

      socket.on('data', (data) => {
        this.processData(data);
        // console.log(data);
      });
      socket.on('close', (error) => {
          this.emit('close');
      });
    });
  }

  handleRealData(opcode, realDataBuffer) {
    switch (opcode) {
      case OPCODES.TEXT:
        this.emit('data', realDataBuffer.toString('utf8'));
        break;
      case OPCODES.BINARY:
        this.emit('data', realDataBuffer);
        break;
      default:
        this.emit('close');
        break;
    }
  }

  processData(bufferData) {
    const byte1 = bufferData.readUInt8(0);
    let opcode = byte1 & 0x0f; 
    
    const byte2 = bufferData.readUInt8(1);
    const str2 = byte2.toString(2);
    const MASK = str2[0];

    let curByteIndex = 2;
    
    let payloadLength = parseInt(str2.substring(1), 2);
    if (payloadLength === 126) {
      payloadLength = bufferData.readUInt16BE(2);
      curByteIndex += 2;
    } else if (payloadLength === 127) {
      payloadLength = bufferData.readBigUInt64BE(2);
      curByteIndex += 8;
    }

    let realData = null;
    
    if (MASK) {
      const maskKey = bufferData.slice(curByteIndex, curByteIndex + 4);  
      curByteIndex += 4;
      const payloadData = bufferData.slice(curByteIndex, curByteIndex + payloadLength);
      realData = handleMask(maskKey, payloadData);
    } 
    
    this.handleRealData(opcode, realData);
  }

  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');
    } else {
      console.error('暂不支持发送的数据类型')
    }
    this.doSend(opcode, buffer);
  }

  doSend(opcode, bufferDatafer) {
    this.socket.write(encodeMessage(opcode, bufferDatafer));
  }
}

module.exports = MyWebsocket;

Index:

const MyWebSocket = require(&#39;./ws&#39;);
const ws = new MyWebSocket({ port: 8080 });

ws.on(&#39;data&#39;, (data) => {
  console.log(&#39;receive data:&#39; + data);
  setInterval(() => {
    ws.send(data + &#39; &#39; + Date.now());
  }, 2000)
});

ws.on(&#39;close&#39;, (code, reason) => {
  console.log(&#39;close:&#39;, code, reason);
});

html:

<!DOCTYPE HTML>
<html>
<body>
    <script>
        const ws = new WebSocket("ws://localhost:8080");

        ws.onopen = function () {
            ws.send("发送数据");
            setTimeout(() => {
                ws.send("发送数据2");
            }, 3000)
        };

        ws.onmessage = function (evt) {
            console.log(evt)
        };

        ws.onclose = function () {
        };
    </script>
</body>

</html>

总结

实时性较高的需求,我们会用 websocket 实现,比如即时通讯、游戏等场景。

websocket 和 http 没什么关系,但从 http 到 websocket 需要一次切换的过程。

这个切换过程除了要带 upgrade 的 header 外,还要带 sec-websocket-key,服务端根据这个 key 算出结果,通过 sec-websocket-accept 返回。响应是 101 Switching Protocols 的状态码。

这个计算过程比较固定,就是 key + 固定的字符串 通过 sha1 加密后再 base64 的结果。

加这个机制是为了确保对方一定是 websocket 服务器,而不是随意返回了个 101 状态码。

之后就是 websocket 协议了,这是个二进制协议,我们根据格式完成了 websocket 帧的解析和生成。

这样就是一个完整的 websocket 协议的实现了。

我们自己手写了一个 websocket 服务,有没有感觉对 websocket 的理解更深了呢?

更多node相关知识,请访问:nodejs 教程

위 내용은 Node를 사용하여 WebSocket 프로토콜을 직접 작성하는 방법을 단계별로 안내합니다.의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 juejin.cn에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제