从Chrome源码看WebSocket(谷歌浏览器怎么看源码)

网友投稿 1702 2022-08-20

从Chrome源码看WebSocket(谷歌浏览器怎么看源码)

从Chrome源码看WebSocket(谷歌浏览器怎么看源码)

WebSocket是为了解决双向通信的问题,因为一方面HTTP的设计是单向的,只能是一边发另一边收。而另一方面,HTTP等都是建立在TCP连接之上的,HTTP请求完就会把TCP给关了,而TCP连接本身就是一个长连接吗,只要连接双方不断关闭连接它就会一直连接态,所以有必要再搞一个WebSocket的东西吗?

我们可以考虑一下,如果不搞WebSocket怎么实现长连接:

(1)HTTP有一个keep-alive的字段,这个字段的作用是复用TCP连接,可以让一个TCP连接用来发多个http请求,重复利用,避免新的TCP连接又得三次握手。这个keep-alive的时间服务器如 Apache 的时间是5s,而 nginx 默认是75s,超过这个时间服务器就会主动把TCP连接关闭了,因为不关闭的话会有大量的TCP连接占用系统资源。所以这个keep-alive也不是为了长连接设计的,只是为了提高http请求的效率,而http请求上面已经提到它是面向单向的,要么是服务端下发数据,要么是客户端上传数据。

(2)使用HTTP的轮询,这也是一种很常用的方法,没有websocket之前,基本上网页的聊天功能都是这么实现的,每隔几秒就向服器发个请求拉取新消息。这个方法的问题就在于它也是需要不断地建立TCP连接,同时HTTP头部是很大的,效率低下。

(3)直接和服务器建立一个TCP连接,保持这个连接不中断。这个至少在浏览器端是做不到的,因为没有相关的API。所以就有了WebSocket直接和服务器建立一个TCP连接。

TCP连接是使用套接字建立的,如果你写过Linux服务的话,就知道怎么用系统底层的API(C语言)建立一个TCP连接,它是使用的套接字socket,这个过程大概如下,服务端使用socket创建一个TCP监听:

// 先创建一个套接字,返回一个句柄,类似于setTimout返回的tId

// AF_INET是指使用IPv4地址,SOCK_STREAM表示建立TCP连接(相对于UDP)

int sockfd = socket(AF_INET, SOCK_STREAM, 0));

// 把这个套接字句柄绑定到一个地址,如localhost:9000

bind(sockfd, servaddr, sizeof(servaddr));

// 开始使用这个套接字监听,最大pending的连接数为100

listen(sockfd, 100);

客户端也使用的套接字进行连接:

// 客户端也是创建一个套接字

int sockfd = socket(AF_INET, SOCK_STREAM, 0));

// 用这个套接字连接到一个serveraddr

connect(sockfd, servaddr, sizeof(servaddr));

// 向这个套接字发送数据

send(sockfd, sendline, strlen(sendline), 0);

// 关闭连接

close(sockfd);

也就是说TCP和UDP连接都是使用套接字创建的,所以WebSocket的名字就是这么来的,本质上它就是一个套接字,并变成了一个标准,浏览器器开放了API,让网页开发人员也能直接创建套接字和服务端进行通信,并且这个套接字什么时候要关闭了由你们去决定,而不像http一样请求完了浏览器或者服务器就自动把TCP的套接字连接关了。

所以说WebSocket并不是一个什么神奇的东西,它就是一个套接字。同时,WebSocket得借助于现有的网络基础,如果它再从头搞一套建立连接的标准代价就会很大。在它之前能够和服务连接的就只有http请求,所以它得借助于http请求来建立一个原生的socket连接,因此才有了协议转换的那些东西。

浏览器建立一个WebSocket连接非常简单,只需要几行代码:

// 创建一个套接字

const socket = new WebSocket('ws://192.168.123.20:9090');

// 连接成功

socket.onopen = function (event) {

    console.log('opened');

    // 发送数据

    socket.send('hello, this is from client');

};

因为浏览器已经按照文档实现好了,而要创建一个WebSocket的服务端应该怎么写呢?这里我们先抛开Chrome源码,先研究服务端的实现,然后再反过来看浏览器客户端的实现。准备用Node.js实现一个WebSocket的服务端,来研究整一个连接建立和接收发送数据的过程是怎么样的。

WebSocket已经在 RFC 6455 里面进行了标准化,我们只要按照文档的规定进行实现就能和浏览器进行对接,这个文档的说明比较有趣,特别是第1部分,有兴趣的读者可以看看,并且我们发现WebSocket的实现非常简单,读者如果有时间的话可以先尝试自己实现一个,然后再回过头来,对比本文的实现。

1. 连接建立

使用Node.js创建一个hello, world的http服务,如下代码index.js所示:

let http = require("http");

const hostname = "192.168.123.20"; // 或者是localhost

const port = "9090";

 

// 创建一个http服务

let server = http.createServer((req, res) => {

    // 收到请求

    console.log("recv request");

    console.log(req.headers);

    // 进行响应,发送数据

    // res.write('hello, world');

    // res.end();

});

 

// 开始监听

server.listen(port, hostname, () => {

    // 启动成功

    console.log(`Server running at ${hostname}:${port}`);

});

注意到这里没有任何的出错和异常处理,被省略了,在实际的代码里面为了提高程序的稳健性需要有异常处理,特别是这种server类的服务,不能让一个请求就把整个server搞挂了。相关出错处理可以参考Node.js的文档。

保存文件,执行node index.js启动这个服务。

然后写一个index.html,请求上面写的服务:

    

但是我们发现,Node.js代码里的请求响应回调函数并不会执行,查了文档发现是因为Node.js有另外一个upgrade的事件:

// 协议升级

server.on("upgrade", (request, socket, head) => {

    console.log(request.headers);

});

因为WebSocket需要先协议升级,在upgrade里面就能收到升级的请求。把收到的请求头打印出来,如下所示:

{ host: ‘192.168.123.20:9090’,

connection: ‘ Upgrade ‘,

pragma: ‘no-cache’,

‘cache-control’: ‘no-cache’,

upgrade: ‘websocket’,

origin: ‘http://127.0.0.1:8080’,

‘sec-websocket-version’: ’13’,

‘user-agent’: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36’,

‘accept-encoding’: ‘gzip, deflate’,

‘accept-language’: ‘en,zh-CN;q=0.9,zh;q=0.8,zh-TW;q=0.7’,

‘ sec-websocket-key ‘: ‘KR6cP3rhKGrnmIY2iu04Uw==’,

‘sec-websocket-extensions’: ‘permessage-deflate; client_max_window_bits’ }

这是我们建立连接收到的第一个请求,里面有两个关键的字段,一个是connection: ‘Upgrade’表示它是一个升级协议请求,另外一个是sec-websocket-key,这是一个用来确认对方身份的随机的base64字符串,下面将会用到。

我们需要对这个请求进行响应,按照文档的说明,需要包含以下字段:

server.on("upgrade", (request, socket, head) => {

    let base64Value = '';

    // 第一行是响应行(Response line),返回状态码101

    socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +

        // http响应头部字段用\r\n隔开

        'Upgrade: WebSocket\r\n' +

        'Connection: Upgrade\r\n' +

        // 这是一个给浏览器确认身份的字符串

        `Sec-WebSocket-Accept: ${base64Value}\r\n` +

        '\r\n');

});

响应报文需要按照http规定的格式,第一行是响应行,包含了http的版本号,状态码101,状态码的解释。每个头部字段用\r\n隔开,这里面最关键的一个是Sec-WebSocket-Accept,它需要计算一下返回浏览器。怎么计算呢?文档是这么规定的:

GUID(Globally_Unique_Identifier) = ‘258EAFA5-E914-47DA-95CA-C5AB0DC85B11’

Sec-WebSocket-Accept = base64(sha1(Sec-Websocket-key + GUID))

使用浏览器给我的sec-websocket-key值,拼上一个固定的字符串,这个字符串叫全局唯一标志符,然后取它的sha1值,再进行base64编码,返回给浏览器。如果浏览器发现这个值不对的话,就会抛异常,拒绝下一步的连接操作:

因为它发现你是一个假的WebSocket服务,起码不是按照文档实现的,所以不是同一个世界,没有共同语言,下面的交流就没有必要了。

为了计算这个值需要引入一个sha1库,base64转换可以使用Node.js的Buffer转换,如下代码所示:

let sha1 = require('sha1');

// 协议升级

server.on("upgrade", (request, socket, head) => {

    // 取出浏览器发送的key值

    let secKey = request.headers['sec-websocket-key'];

    // RFC 6455规定的全局标志符(GUID)

    const UNIQUE_IDENTIFIER = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

    // 计算sha1和base64值

    let shaValue = sha1(secKey + UNIQUE_IDENTIFIER),

        base64Value = Buffer.from(shaValue, 'hex').toString('base64');

    socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +

        'Upgrade: WebSocket\r\n' +

        'Connection: Upgrade\r\n' +

        `Sec-WebSocket-Accept: ${base64Value}\r\n` +

        '\r\n');

});

使用上面浏览器发送的key计算得到的accept值为:

RWMSYL3Zmo91ZR+r39JVM2+PxXc=

把这个值发给浏览器,Chrome就不会报刚刚那个检验出错了,确认过眼神,遇上对的人。这样WebSocket连接就建立了,没错就是这么简单。Chrome开发者工具Network面板里的websocket连接将会从pending状态变成101状态,如果连接关闭了就会变成200状态。

上面浏览器的代码在建立连接完成之后还send了一个数据过来:

socket.send('hello, this is from client');

怎么读取这个数据呢?

2. 接收数据

数据的传送,文档规定了WebSocket数据帧格式,长这个样子:

不要被这个吓到,一个个拆解来看的话,还是挺简单的。可以分成两个部分,帧头字段和有效内容或者叫有效负载(Payload Data),帧头字段主要的作用是为了解释这个帧的,如第1位(bit) FIN 如果置为1就表示它是一个结束帧,如果数据比较长就会被拆成几个帧发送,FIN为1表示它是当前数据流的最后一个帧。第4到第7倍的 opcode 是用来做一些指令控制的,如果值为1话就表示Payload Data是文本格式的,2则表示二进制内容,8表示连接关闭。第9位到第15位共7位 Payload Len 表示有效负载的字节数,7位二进制数最大表示127,如果有效负载字节数大于127的话就需要用到Extended payload length部分。

第8位的 Mask 如果设置为1就表示这个帧的有效负载内容被掩码处理过了,客户端向服务端发送的帧需要进行掩码,而服务端向客户端发送的数据帧不需要掩码。为什么要使用掩码,这个掩码计算又是怎么进行的呢?掩码计算很简单,就是把要发送的数据和另一个数字异或一下再放到Payload Data, 这个数字就是上面数据帧里的 Masking-key ,它是一个32位的数字。接收方把Payload Data再和这个数异或一下就能得到原始的数据,因为和同一个数异或两次等于原本的数,即:

a ^ b ^ b = a

并且每个帧里的Making-key要求都是随机的,不可被(代理)服务所预测的,为什么要这样呢?文档里面是这么说的:

The unpredictability of the masking key is essential to prevent authors of malicious applications from selecting the bytes that appear on the wire

这个解释有点含糊, Stackoverflow 上有人说是为了避免代理缓存中毒攻击,具体可参考 Http Cache Poinsing .

所以我们需要从这个帧里面取出掩码的key值,还原原始的paylod数据。

数据的发送和传输都要靠socket对象,因为它不是走的http请求,所以在http的响应函数里面是收不到数据的,在upgrade事件里面可以拿到这个socket,监听这个socket对象的data事件,就可以得到接收的数据:

socket.on('data', buffer => {

    console.log('buffer len = ', buffer.length);

    console.log(buffer);

});

返回的数据类型是Node.js里的Buffer对象,把这个buffer打印出来:

buffer len = 32

这个buffer就是websocket客户端给我们发送的数据帧了,总共有32个字节,上面的打印是用的16进制表示,可以改二进制0101表示,和上面那个数据帧格式图一一对照,就能够解释这个数据帧是什么意思,有什么内容。把它打印成原始二进制表示:

参照报文格式,如下图所示:

通过opcode可以知道它是一个文本数据的帧,payload len得到文本长度为26个字节,这个刚好等于上面发送的内容长度:

同时掩码Mask是打开的,掩码key值存放范围是[16, 16 + 32],因为这里不需要使用扩展字段,所以Masking-key就直接跟在Payload len后面了,再往后就是Payload Data,范围是[48, 48 + 26 * 8].

这就是一个完整的数据帧了,还需要把payload data用掩码异或一下,还原原始数据。在Node.js里面进行处理。Node.js里面的Buffer类只能操作字节级别,如读取第n个字节的内容,没办法直接操作位,如读取第n位的数据。所以额外引入一个库,网上找了一个BitBuffer,但是它的实现好像有问题,所以自已实现了一个。

如下代码所示,实现一个能够读取任意位的BitBuffer:

class BitBuffer {

    // 构造函数传一个Buffer对象

    constructor (buffer) {

        this.buffer = buffer;

    }

    // 获取第offset个位的内容

    _getBit (offset) {

        let byteIndex = offset / 8 >> 0,

            byteOffset = offset % 8;

        // readUInt8可以读取第n个字节的数据

        // 取出这个数的第m位即可

        let num = this.buffer.readUInt8(byteIndex) & (1 << (7 - byteOffset));

        return num >> (7 - byteOffset);

    }

}

原理很简单,先调Node.js的Buffer的readUInt8读取第n个字节的数据,然后计算一下所要读取的位数在这个字节的第几位,通过与运算,把这个位取出来,更多位运算可以参考: 巧用JS位运算 。

用这个代码取出第8位的Mask Flag是否有设置,如下代码:

socket.on('data', buffer => {

    let bitBuffer = new BitBuffer(buffer);

    let maskFlag = bitBuffer._getBit(8);

    console.log('maskFlag = ' + maskFlag);

});

打印maskFlag = 1。那么怎么取出连续的n位呢,如opcode,是从第4位到7位。这个也好办就是把第4位到第7位分别取出来拼成一个数就好了:

getBit (offset, len = 1) {

    let result = 0;

    for (let i = 0; i < len; i++) {

        result += this._getBit(offset + i) << (len - i - 1);

    }  

    return result;

}

这个代码的效率不是很高,但是容易理解。有个小坑就是JS的位移只支持32位整数的操作,1 << 31会变成一个负数,具体不展开讨论。用这个函数取32位的掩码值就会有问题。

可以利用这个函数取出opcode和payload len:

socket.on('data', buffer => {

    let bitBuffer = new BitBuffer(buffer);

    let maskFlag = bitBuffer.getBit(8),

        opcode = bitBuffer.getBit(4, 4),

        payloadLen = bitBuffer.getBit(9, 7);

    console.log('maskFlag = ' + maskFlag);

    console.log('opcode = ' + opcode);

    console.log('payloadLen = ' + payloadLen);

});

打印如下:

maskFlag = 1 opcode = 1 payloadLen = 26

取掩码值单独实现一下,这个掩码是拆成4个数使用的,一个字节表示一个数,借助上面的getBit函数,代码如下:

getMaskingKey (offset) {

    const BYTE_COUNT = 4;

    let masks = [];

    for (let i = 0; i < BYTE_COUNT; i++) {

        masks.push(this.getBit(offset + i * 8, 8));

    }  

    return masks;

}

这个例子的掩码值是从第16位开始,所以offset是16:

let maskKeys = bitBuffer.getMaskingKey(16);

console.log('maskKey = ' + maskKeys);

打印出来的maskKey为:

maskKeys = 76, 63, 100, 117

怎么用这个Mask Key进行异或呢,文档里面是这么规定的:

j = i MOD 4 transformed-octet-i = original-octet-i XOR masking-key-octet-j

也就是把Payload Data里面的第n,n + 1,n + 2,n + 3个字节内容分别与makKey数组的第0,1,2,3进行异或即可,所以这个实现也比较简单,如下代码所示:

getXorString (byteOffset, byteCount, maskingKeys) {

    let text = '';

    for (let i = 0; i < byteCount; i++) {

        let j = i % 4;

        // 通过异或得到原始的utf-8编码

        let transformedByte = this.buffer.readUInt8(byteOffset + i)

                                  ^ maskingKeys[j];

        // 把编码值转成对应的字符

        text += String.fromCharCode(transformedByte);

    }  

    return text;

}

异或操作之后就可以得到编码值,再借助String.fromCharCode就能得到对应的文本,如根据ASCII表,97就会被还原成字母’a’。

这个例子的payload data的偏移是第6个字节开始的,这里我们先直接写死:

let payloadLen = bitBuffer.getBit(9, 7),

    maskKeys = bitBuffer.getMaskingKey(16);

let payloadText = bitBuffer.getXorString(48 / 8, payloadLen, maskKeys);

console.log('payloadText = ' + payloadText);

打印的文本内容为:

payloadText = hello, this is from client

到这里,就把接收的数据还原出来了。如果想要发送数据,就是把读取的过程逆一下,按照帧格式去拼一个符合规范的帧发送给对方,区别是服务端的帧数据是不需要Mask的,如果你Mask了,Chrome会报一个异常,说数据不需要Mask,拒绝解析接收到的数据。

我们再从Chrome源码看Websocket客户端的实现,来补充一些细节。

Chrome的websockets代码是在src/net/websockets,例如Chrome在握手的时候是怎么生成一个随机的sec-websocket-key?如下代码所示:

std::string GenerateHandshakeChallenge() {

  std::string raw_challenge(websockets::kRawChallengeLength, '\0');

  crypto::RandBytes(base::string_as_array(&raw_challenge),

                    raw_challenge.length());

  std::string encoded_challenge;

  base::Base64Encode(raw_challenge, &encoded_challenge);

  return encoded_challenge;

}

它是用的一个crypto::RandBytes生成随机字节,而在检验sec-websocket-accept也是用的同样的计算方法:

std::string ComputeSecWebSocketAccept(const std::string& key) {

  std::string accept;

  std::string hash = base::SHA1HashString(key + websockets::kWebSocketGuid);

  base::Base64Encode(hash, &accept);

  return accept;

}

而在使用掩码计算的时候也是用的一样的方法:

inline void MaskWebSocketFramePayloadByBytes(

    const WebSocketMaskingKey& masking_key,

    size_t masking_key_offset,

    char* const begin,

    char* const end) {

  for (char* masked = begin; masked != end; ++masked) {

    *masked ^= masking_key.key[masking_key_offset++];

    if (masking_key_offset == WebSocketFrameHeader::kMaskingKeyLength)

      masking_key_offset = 0;

  }

}

其它的还有deflate压缩、cookie、扩展extensions等,本文不再展开讨论。

另外还有一个问题,使用一个WebSocket就需要操持一个TCP连接,如果有1000个用户同时在线,那么服务端就得保持1000个TCP连接,而一个TCP连接通常需要占用一个独立的线程,而线程的开销是很大的,所以WebSocket对服务端的压力特别大?其实也不见得有那么大,因为Linux有一个epoll的服务模型,它是一个事件驱动机制的,能够让一个核支持并发的很多个连接。

最后一个问题,由于连接是一直操持的,如果连接双方有一方异常退出了,没有发送一个关闭连接的包通知对方,那么对方就会傻傻地操持着这个没用的连接,所以WebSocket又引入了一个ping/pong的消息帧,帧头里的opcode为0x9就表示是一个ping帧,0x10表示pong的响应帧。所以可以让客户端不断地ping,如每隔30秒就ping一次,服务收到了ping就知道当前客户端还活着,给一个pong的响应,如果服务端太久没收到ping了如1分钟,那么就认为这个客户端已经走了直接关闭连接。而客户端如果没收到pong响应那么就认为当前连接已经断了,需要重连。浏览器JS的API没有开放ping/pong,需要自已实现一个消息类型。

本篇主要讨论了WebSocket存在的意义,给浏览器开放一个socket的API,并进行标准化,除了浏览器,APP等也都可以按照这个标准实现,弥补了HTTP单向传输的缺点。还讨论了WebSocket报文帧的格式,以及怎么用Node.js读取这个报文帧,客户端会把它发送的内容进行掩码处理,服务端收到的也需要进行掩码还原。我们发现Chrome客户端的实现有很多地方是类似的。

怎么保证WebSocket传输的稳定性可能又是另外一个话题了,包括出错重连机制,跨中美地区的可能需要使用专线等。

Post Views: 8

来自:https://yinchengli.com/2018/05/27/chrome-websocket/

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:使用爬虫技术实现 Web 页面资源可用性检测
下一篇:C# 语言历史版本特性(C# 1.0到C# 8.0汇总)
相关文章

 发表评论

暂时没有评论,来抢沙发吧~