¿
This article includes the following contents:
WebSocket
Chapter 4 – Connection handshakeWebSocket
Protocol Chapter 5 – Data Framesnodejs ws
Library source code analysis – connection handshake processnodejs ws
Library source code analysis – data frame parsing process
reference
A deep dive into the WebSocket protocol
ws – github
This article will not cover the basic knowledge of WebSocket, such as concept, definition, explanation and purpose. It is a bit dry and long. Markdown is about 800 lines
1. Connect the handshake process
There is a common statement about websockets: Websockets reuse HTTP handshake channels, which refer to:
The client negotiates an upgrade protocol with the WebSocket server through HTTP requests. After the upgrade, subsequent data exchanges follow the WebSocket protocol
1.1 Client: Apply for a protocol upgrade
The client initiates a protocol upgrade request. According to the WebSocket protocol specification, the request header must contain the following content
GET/HTTP/1.1 Host: localhost:8080 Origin: http://127.0.0.1:3000 Connection: Upgrade Upgrade: websocket Sec-WebSocket-Version: 13 Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==Copy the code
- Request line: The request method must be GET, HTTP version at least 1.1
- The request must contain Host
- If the request is coming from a browser client, Origin must be included
- The request must contain Connection and its value must contain the “Upgrade” flag
- The request must contain Upgrade, whose value must contain the “websocket” keyword
- The request must contain sec-websocket-version, whose value must be 13
- The request must contain an SEC-websocket-key to provide basic protection, such as unintentional connections
1.2 Server: Response protocol upgrade
The response header returned by the server must contain the following content
HTTP/1.1 101 Switching Protocols Connection:Upgrade Upgrade: websocket Sec- websocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=Copy the code
- Response:
HTTP / 1.1 101 Switching separate Protocols
- The response must contain Upgrade with a value of “weboscket”
- The response must contain Connection with a value of “Upgrade”
- The response must contain sec-websocket-Accept, calculated from the sec-websocket-key at the request header
1.3 Calculation of sec-websocket-key /Accept
The specification mentions:
The sec-websocket-key value is a randomly generated 16-byte random number encoded in base64 (see chapter 4 of RFC4648)
For example, the randomly selected 16 bytes are:
// Hexadecimal digits 1 to 16 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0f 0x10Copy the code
The base64 encoding value is AQIDBAUGBwgJCgsMDQ4PEA==
The test code is as follows:
const list = Array.from({ length: 16 }, (v, index) => ++index)
const key = Buffer.from(list)
console.log(key.toString('base64'))
// AQIDBAUGBwgJCgsMDQ4PEA==
Copy the code
The sec-websocket-accept value is calculated as follows:
- will
Sec-Websocket-Key
The value of and258EAFA5-E914-47DA-95CA-C5AB0DC85B11
Joining together - through
SHA1
Compute the abstract and convert it tobase64
string
There is no need to worry about the magic string 258eafa5-e914-47DA-95CA-C5AB0DC85b11. It is just a GUID, probably generated randomly when writing an RFC
The test code is as follows:
const crypto = require('crypto')
function hashWebSocketKey (key) {
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
return crypto.createHash('sha1')
.update(key + GUID)
.digest('base64')
}
console.log(hashWebSocketKey('w4v7O6xFTi36lq3RNcgctw=='))
// Oy4NRAQ13jhfONC7bP8dTKb4PTU=
Copy the code
1.4 Functions of sec-websocket-key
As briefly mentioned above, its role is to provide basic protection and reduce malicious connections. Further elaboration is as follows:
Key
This prevents the server from receiving illegal messagesWebSocket
Connect, for examplehttp
Request a connection towebsocket
In this case, the server can reject the requestKey
Can be used to initially ensure server awarenessws
Protocol, however, does not rule out some HTTP server only processingSec-WebSocket-Key
, does not come truews
agreementKey
Reverse proxy caching can be avoided- Make an Ajax request in the browser,
Sec-Websocket-Key
And the associated headers are disallowed, so that clients do not accidentally request protocol upgrades when they send Ajax requests
Finally, it is important to note that sec-websocket-key /Accept is not used to ensure data security, because its calculation/conversion formula is public and very simple. The main purpose is to prevent unexpected situations
2. Data frames
The minimum unit of WebSocket communication is frame, which consists of one or more frames to form a complete message. During the process of data exchange, the sender and receiver need to do the following:
- Sender: The message is cut into multiple frames and sent to the server
- Receiver: Accepts message frames and reassembles the associated frames into a complete message
The format of data frames, as the core, may seem hard to understand at first glance, but the author of this article has ordered that it be understood and rushed
2.1 Detailed description of data frame format
-
FIN
: 占1bit0
Indicates not the last shard of the message1
Represents the last shard of the message
-
RSV1, RSV2, RSV3: each 1bit, generally all 0, related to Websocket expansion, if the non-zero value and no Websocket expansion, connection error
-
Opcode: bit 4
%x0
: indicates that the data transmission adopts a data fragment. The current data frame is one of the data fragments%x1
: indicates that this is a text frame%x2
: indicates a binary frame%x3-7
: Reserved action code for later defined non-control frames%x8
: Indicates that the connection is down%x9
: Indicates a heartbeat request (ping)%xA
: indicates a heartbeat response (pong)%xB-F
: Reserved action code for later defined non-control frames
-
Mask
: 占1bit0
Indicates that no mask xOR operation is performed on the data payload1
Represents mask xOR operations on data payloads
-
Payload Length: 7, 7+16, or 7+64bit
0 ~ 125
: The data length is equal to the value126
: The following two bytes represent a 16-bit unsigned integer with a value of the length of the data127
: The following 8 bytes represent a 64-bit unsigned integer with a value of the length of the data
-
Masking-key: a value of 0 or 4bytes
1
: Carries a 4-byte Masking-key0
: there is no Masking – key- The purpose of the mask is not to prevent data leakage, but to prevent problems such as proxy cache contamination attacks that existed in earlier versions of the protocol
-
Payload Data: Payload data
I think if you know the difference between byte and bit, this part is fine
2.2 Data Transfer
Each WebSocket message may be divided into multiple data frames. When receiving a data frame, the WebSocket determines whether it is the last data frame based on the FIN value
Data frame transfer example:
FIN=0, Opcode=0x1
: Sent text type, message not yet sent, and subsequent framesFIN=0, Opcode=0x0
: The message is not completed, and there are subsequent frames, followed by the previous oneFIN=1, Opcode=0x0
: The message is sent, no subsequent frames, followed by the last one to form a complete message
3. Ws library source code analysis: connection handshake process
Although I was using socket. IO, I stumbled upon WS, which was used quite heavily, with weekly downloads six times that of socket. IO
In NodeJS, the upgrade event of the HTTP module is triggered whenever a negotiated upgrade request is encountered. This is the entry point for implementing WebSocketServer. The native sample code is as follows:
// Create an HTTP server. const srv = http.createServer( (req, res) => { res.writeHead(200, {'Content-Type': 'text/plain' });
res.end('Response content');
});
srv.on('upgrade', (req, socket, head) => {// specific processing to implement the Websocket service});Copy the code
And, in general use, it is an extension of an existing httpServer to implement WebSocket, rather than creating a separate WebSocketServer
The example code ws uses, based on an existing httpServer, is
const http = require('http');
const WebSocket = require('ws');
const server = http.createServer();
const wss = new WebSocket.Server({ server });
server.listen(8080);
Copy the code
The existing httpServer is passed as a parameter to the websocket. Server constructor, so the core entry point for source code analysis is:
new WebSocket.Server({ server });
Copy the code
From this pointcut, you can fully reproduce the join handshake process
3.1 Analyzing the WebSocketServer class
Because httpServer is passed in as an argument, its constructor becomes quite simple:
Class WebSocketServer extends EventEmitter {constructor(options, callback) {super(if(options.server) {this._server = options.server} // Listen for eventsif (this._server) {
this._removeListeners = addListeners(this._server, {
listening: this.emit.bind(this, 'listening'),
error: this.emit.bind(this, 'error'HandleUpgrade (req, socket, head, (ws) => {this.emit(req, socket, head);'connection', ws, req)})}})}}} // This is a very nice piece of code, return a function to remove multiple event listeners while binding multiple event listenersfunction addListeners(server, map) {
for (const event of Object.keys(map)) server.on(event, map[event]);
return function removeListeners() {
for(const event of Object.keys(map)) { server.removeListener(event, map[event]); }}; }Copy the code
As you can see, in the constructor, a listener for the upgrade event is registered for the httpServer. When triggered, the this.handleUpgrade function is executed, which is the next step
3.2 Filtering illegal requests: handleUpgrade function
This function is used to filter out illegal requests, including:
-
The Sec – WebSocket – Key value
-
The Sec – WebSocket – Version value
-
The path of the WebSocket request
The key codes are as follows:
const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
handleUpgrade(req, socket, head, cb) {
socket.on('error', socketOnError) // Get sec-websocket-key const key = req.headers['sec-websocket-key'] !== undefined
? req.headers['sec-websocket-key']
: falseSec-websocket-version const version = +req.headers['sec-websocket-version'Const extensions = {}; // For illegal requests, break the handshakeif( req.method ! = ='GET'|| req.headers.upgrade.toLowerCase() ! = ='websocket'| |! key || ! keyRegex.test(key) || (version ! == 8 && version ! = = 13) | | / / this function is to Websocket request path of judgment, and option. The path, don't start! this.shouldHandle(req) ) {returnAbortHandshake (socket, 400)} // For valid requests, update it! this.completeUpgrade(key, extensions, req, socket, head, cb) }Copy the code
AbortHandshake: 400 bad Request abortHandshake: 400 bad request abortHandshake:
const { STATUS_CODES } = require('http');
functionAbortHandshake (socket, code, message, headers) {// net. socket is also a duplex stream, so it can be read or writtenif (socket.writable) {
message = message || STATUS_CODES[code];
headers = {
Connection: 'close'.'Content-type': 'text/html'.'Content-Length': Buffer.byteLength(message), ... headers }; Socket. Write (` HTTP / 1.1${code} ${STATUS_CODES[code]}\r\n` +
Object.keys(headers)
.map((h) => `${h}: ${headers[h]}`)
.join('\r\n') +
'\r\n\r\n'+ message ); } // Remove the error listener socket.removelistener ('error', socketOnError); Socket.destroy (); // Make sure there are no more I/O activities on the socket. }Copy the code
If all goes well, we come to the completeUpgrade function
3.3 Completing the handshake: The completeUpgrade function
This function is mainly used to return the correct response, trigger related events, record values, etc. The code is relatively simple
const { createHash } = require('crypto');
const { GUID } = require('./constants');
const WebSocket = require('./websocket');
function completeUpgrade(key, extensions, req, socket, head, cb) {
// Destroy the socket if the client has already sent a FIN packet.
if(! socket.readable || ! socket.writable)returnSocket. Destroy () // Generate sec-websocket-accept const digest = createHash('sha1')
.update(key + GUID)
.digest('base64'); // set Headers const Headers = ['the HTTP / 1.1 101 Switching separate Protocols'.'Upgrade: websocket'.'Connection: Upgrade',
`Sec-WebSocket-Accept: ${digest}`]; // Create an instance of Websocket const ws = new Websocket(null) this.emit('headers', headers, req); Socket.write (headers. Concat ('\r\n').join('\r\n'));
socket.removeListener('error', socketOnError); // ws.setsocket (socket, head, this.options. MaxPayload); // Set records the client in the connected stateif (this.clients) {
this.clients.add(ws);
ws.on('close', () => this.clients.delete(ws)); } // Trigger the connection event cb(ws); }Copy the code
At this point, the entire handshake phase is complete, but the processing of data frames is not yet involved
4. Ws library source code analysis: data frame processing
At the end of the previous chapter, the following code is revealed in completeUpgrade:
ws.setSocket(socket, head, this.options.maxPayload);
Copy the code
Enter the setSocket method in the WebSocket class, the data frame processing code can be simplified as:
Class WebSocket extends EventEmitter {
...
setSocket(Socket, head, maxPayload) {// Instantiate a writable stream for processing data frames this._extensions, maxPayload ); receiver[kWebSocket] = this; socket.on('data', socketOnData); }}function socketOnData(chunk) {
if (!this[kWebSocket]._receiver.write(chunk)) {
this.pause();
}
}
Copy the code
Many event handling, such as error, end, close, etc., are omitted here because they are irrelevant to the purpose of this article, and some apis are not covered
So the core pointcut is the Receiver class, which is the core for processing data frames
4.1 Basic structure of Receiver class
The Receiver class inherits from writable streams and needs to clarify two basic concepts:
stream
All streams areEventEmitter
An instance of the- Implementing writable flows requires implementation
writable._write
Method for internal use
const { Writable } = require('stream') class Recevier extends Writable { constructor(binaryType, extensions, maxPayload) { super() this._binaryType = binaryType || BINARY_TYPES[0]; // nodebuffer this[kWebSocket] = undefined; / / the WebSocket instance references this. _extensions = extensions | | {}; / / the WebSocket protocol to expand this. _maxPayload = maxPayload | 0; // 100 * 1024 * 1024 this._bufferedBytes = 0; This._buffers = []; // Record buffer data this._compressed =false; // Whether to compress this._payloadLength = 0; // Data frame PayloadLength this._mask = undefined; // data frames Mask this._fragmented = 0; // Whether data frames are fragmented this._masked =false; // Data frame Mask this._fin =false; // Data frame FIN this._opcode = 0; Opcode this._totalPayloadLength = 0; This._messagelength = 0; _fragments = []; This. _state = GET_INFO; // Flag bit for startLoop function this._loop =false; // Flag bit for startLoop function} _write(chunk, encoding, cb) {if (this._opcode === 0x08 && this._state == GET_INFO) returncb(); this._bufferedBytes += chunk.length; this._buffers.push(chunk); this.startLoop(cb); }}Copy the code
As you can see, every time a new data frame is received, it is logged in the _buffers array and the parsing process startLoop begins immediately
4.2 Data frame parsing process: startLoop function
startLoop(cb) {
let err;
this._loop = true;
do {
switch (this._state) {
case GET_INFO:
err = this.getInfo();
break;
case GET_PAYLOAD_LENGTH_16:
err = this.getPayloadLength16();
break;
case GET_PAYLOAD_LENGTH_64:
err = this.getPayloadLength64();
break;
case GET_MASK:
this.getMask();
break;
case GET_DATA:
err = this.getData(cb);
break;
default:
// `INFLATING`
this._loop = false;
return; }}while (this._loop);
cb(err);
}
Copy the code
The parsing process is simple:
-
GetInfo parses data such as FIN, RSV, OPCODE, MASK, and PAYLOAD LENGTH
-
The payload length is divided into three types:
-
0~125: Call haveLength
-
The getPayloadLength16 method is triggered and the haveLength method is called
-
GetPayloadLength64 (haveLength)
-
-
In the haveLength method, if there is a mask, the getMask method is called first, then the getData method is called
The overall flow and state are controlled by this._loop and this._state, which is more intuitive
4.3 Consume Buffer: consume method
The first step should be analyzing the getInfo method, but the consume method is involved. This provides a neat way to consume the obtained Buffer. The function takes an argument, n, representing the number of bytes to consume, and returns the number of bytes consumed
If we need to get the first byte of the data frame (including FIN + RSV + OPCODE), just pass this.consume(1)
The record value this._buffers is a buffer array, which initially stores complete data frames. As consumption progresses, the data will become smaller.
-
The number of bytes consumed is exactly equal to the number of bytes of a chunk
-
The number of bytes consumed is less than the number of bytes of a chunk
-
The number of bytes consumed is greater than the number of bytes of a chunk
In the first case, just move out + return
if (n === this._buffers[0].length) return this._buffers.shift()
Copy the code
In the second case, just crop + return
if (n < this._buffers[0].length) {
const buf = this._buffers[0]
this._buffers[0] = buf.slice(n)
return buf.slice(0, n)
}
Copy the code
In the third case, which is a bit more complicated, we first apply for a buffer space of the size of bytes to be consumed to store the returned buffers
// It doesn't matter if the buffer space is initialized, because eventually it will all be overwrittenCopy the code
In this case, it is guaranteed to be larger than the first chunk, but it is not certain that it will be larger than the first chunk after a chunk is consumed (the index moves forward after consumption), so a loop is required
// do... While can avoid a meaningless judgment, first execute a loop body, then judge the conditiondo{const buf = this._buffers[0] // If the buffer length is larger than the first chunk, remove the + bufferif(n >= buf.length) { this._buffers.shift().copy(dst, dst.length - n); } // If the length is less than one chunk, crop + copyelseBuf. copy(DST, dst.length -n, 0, n); // buf.copy(DST, dst.length -n, 0, n); this._buffers[0] = buf.slice(n); } n -= buf.length; }while (n > 0)
Copy the code
4.4 Analyzing data Frames: getInfo method
A minimum data frame must contain the following data:
FIN (1 bit) + RSV (3 bit) + OPCODE (4 bit) + MASK (1 bit) + PAYLOADLENGTH (7 bit)
Copy the code
At least 2 bytes, so less than 2 bytes of data frame is an error, simplified getInfo as follows
getInfo() {
if (this._bufferedBytes < 2) {
this._loop = false
return} const buf = this._fin = (buf[0] &0x80) === 0x80 this._opcode = buf[0] &0x0f This._payloadlength = buf[1] &0x7f this._masked = (buf[1] &0x80) === 0x80 // Three cases of the Payload Lengthif (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64
else return this.haveLength()
}
Copy the code
The core here is the meaning of the location operator &. Let’s start with FIN, which is the first bit in the data frame
// The value of FIN is denoted by [], where X represents the subsequent bit in the first byte [] XXXXXXX // the hexadecimal number 0x80 represents the binary 10000000 // The result is bit-independent of the following 7 bits []0000000 // therefore, This._fin = (buf[0] & 0x80) === 0x80Copy the code
OPCODE and PAYLOAD LENGTH are the same
[][][][] [][][][] [][][][] [][][][] With the press, 0111, 1111 in can x [] [] [] [] [] [] [] [] 0111 & 1111 (0 x7f)Copy the code
4.5 Payload Length Three scenarios and the size end
The three cases are as follows:
-
0-125: The actual length of the load is a number between 0-125
-
126: The actual payload length is the value of a 16-bit unsigned integer represented by the next two bytes
-
127: The actual payload length is the value of a 64-bit unsigned integer represented by the following 8 bytes
If this sounds convoluted, look at the code, for example, branch 126:
getPayloadLength16() {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
this._payloadLength = this.consume(2).readUInt16BE(0);
return this.haveLength();
}
Copy the code
As you can see, the core of the processing length is readUInt16BE(0), which involves the size side:
-
The Big endian considers the first byte to be the highest bit, similar to what we know about the size of a decimal number
-
The Little endian considers the first byte to be the least significant byte
Then, the specification says that the next two bytes represent a 16-bit unsigned integer value, which naturally refers to the big end
Big end vs small end comparison:
// If the following two bytes are binary 1111 1111 0000 0001 // convert to hexadecimal 0xFF 0x01 // the big end outputs 65281 console.log(buffer. from([0xff, 0x01]).uint16be (0).toString(10))Copy the code
In addition to this, the 7 + 64 mode has a little extra processing, which looks like this:
getPayloadLength64() {
if (this._bufferedBytes < 8) {
this._loop = false;
return;
}
const buf = this.consume(8);
const num = buf.readUInt32BE(0);
//
// The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
// if payload length is greater than this number.
//
if (num > Math.pow(2, 53 - 32) - 1) {
this._loop = false;
return error(
RangeError,
'Unsupported WebSocket frame: payload length > 2^53 - 1'.false, 1009); } this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);return this.haveLength();
}
Copy the code
4.6 Obtaining load data: getData
Before obtaining the payload, if the getInfo value of mask is 1, the getMask operation is required to obtain the mask Key(a total of four bytes).
getMask() {
if (this._bufferedBytes < 4) {
this._loop = false;
return;
}
this._mask = this.consume(4);
this._state = GET_DATA;
}
Copy the code
The source code for getData is simplified as follows
GetData (cb) {// data is buffer.alloc (0)letdata = EMPTY_BUFFER; Payload data = this.consume(this._payloadLength) // If there are masks, decode them according to the mask keyif(this._masked) unmask(data, this._mask) // Logs it into the fragmented array this._fragments.push(data) // If this data frame represents: connection disconnection, heartbeat request, heartbeat responseif (this._opcode > 0x07) returnThis.controlmessage (data) // If the data frame represents: data fragment, text frame, binary framereturn this.dataMessage()
}
Copy the code
4.7 Assembly load data: dataMessage
Next, look at the dataMessage() function, which combines data from multiple frames and is simpler after simplification
dataMessage() {
if (this._fin) {
const messageLength = this._messageLength
const fragments = this._fragments
const buf = concat(fragments, messageLength)
this.emit('message', buf.toString())}} //function concat(list, totalLength) {
if (list.length === 0) return EMPTY_BUFFER;
if (list.length === 1) return list[0];
const target = Buffer.allocUnsafe(totalLength);
let offset = 0;
for (let i = 0; i < list.length; i++) {
const buf = list[i];
buf.copy(target, offset);
offset += buf.length;
}
return target;
}
Copy the code
5. To summarize
This article is long and is not the kind of interview questions in small pieces of knowledge, reading urgent need patience, has tried to avoid Posting large sections of code, can see here I want to give you money
Through this analysis, two key phases in WebSocket are fully introduced and replicated:
-
Connection handshake phase
-
Data exchange stage
In my opinion, the key is the use of Node.js buffer module and stream module, which is also the biggest part of the harvest