Bilibili直播弹幕抓取(1):WebSocket

前言

最近有一个学长去分析了B站直播弹幕WebSocket协议,我算是跟风去分析了一波。

其实协议本身并不复杂,就是JSON罢了,但是分析的过程稍微有些曲折,这里算是记录一下在这个过程中学到了什么吧。

WebSocket

WebSocket 之前在看 Socket.io 的时候我就了解过一些,不过那时候我还没有自学计网,连HTTP协议都还是一脸懵逼,所以根本没看懂。现在写过一些网络编程后再看 WebSocket 就很自然了。

为什么需要 WebSocket

WebSocket 是随着 HTML5 一起提出来的,但是它本身不是基于 HTTP 协议的。

我们知道传统的 HTTP 协议中,服务器对 Request 作出相应的 Response,如果没有 Request 服务器是不能主动发出 Response 的,毕竟 HTTP 是无状态的。

但是随着 HTML5 游戏的兴起,直播产业的蓬勃发展等等,前端对实时性的要求越来越高,同时服务器也希望有主动推送消息的能力。为了解决这个需求,基于现有的 HTTP 协议有三种办法。

轮询

这种方式是最直观的,隔一定时间就向服务器发报文询问当前最新状态,比如:

1
2
3
4
5
6
7
8
9
10
11
12
$(document).ready(function(){
setInterval(function(){
$.ajax({
type : 'POST',
url : url,
data : data,
success : function(){
// do something here
}
});
}, 500);
});

这种方法好处是实现起来非常简单,但是缺点是非常致命的

  • 会对服务器造成非常大的压力
  • 当没有数据的时候带宽都浪费在传输 Header 上了

为了尝试克服这些缺点就出现了长轮询和流技术。

长轮询

长轮询其实本质上还是轮询。但是不同的是,如果没有消息的话服务器不会立即返回,而是会等待一段时间,如果有足够的消息或者超时则立即返回。

贴一段 CTBX 中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Bot::_tgbot_start_polling() {
_tgbot_thread = std::move(std::thread([this]() {
TgBot::TgLongPoll longpoll(_tgbot, 100, 10); // 最多 100 条消息,超时时间 10 秒
try {
while(_polling)
longpoll.start();
}
catch (const TgBot::TgException& e) {
logging::error(u8"Bot", "LongPoll错误,原因:" + std::string(e.what()));
}
catch (const std::exception& e) {
logging::error(u8"Bot", "LongPoll错误,原因:" + std::string(e.what()));
}
}));
}

这里的 LongPoll 就是一个长轮询,忽略网络因素的话它在下面这两种情况会返回(接收到服务器的 Response )

  • Bot 有 100 条消息待接收。
  • 从接收到 Request 后过去了 10 秒

可以看出长轮询有效克服了短轮询的一些缺点。

另一种技术是利用 iframe 实现的长连接,这种方法比较 hack,这里直接展示一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<html>
<head>
<!-- 省略 -->
</head>
<body>
<script>
function doSomething(parameters){
// iframe 返回的 javascript 会调用这个
}
</script>
<iframe id='poll_iframe' src='someURL' style='display:none;'></iframe>
<script>
$(document).ready(function(){
setInterval(function(){
var frame = document.querySelector('#poll_iframe');
frame.src = frame.src;
}, 500);
});
</script>
</body>
</html>

原理非常简单,就是不断更新 iframe 的 src 来保持长连接,然后服务器返回 javascript 脚本调用相应的函数。

实际上,在 HTTP/1.1 中长连接模型代替 1.0 中的短连接模型成为了默认选项,所以这种技术的意义可能并不是很大了,而且另一个致命的问题是在加载的时候浏览器的小圈会一直转,逼死强迫症。

什么是 WebSocket

其实上面这几种技术有一个共同的名称就是 Comet,它们的出发点无非就是想让服务器有新消息的时候尽快通知前端,但是 HTTP 协议设计的时候可没这么想过,所以就有了 WebSocket。

WebSocket 本质上就是两个 Socket 的双向通信,前端绑定一个地址和端口,后端绑定一个地址和端口然后就能双向通信了,所以 WebSocket 是一种和 HTTP 完全不同的协议,不过二者都是基于 TCP。

和 HTTP 联系

虽然 WebSocket 是一种完全不同的协议,不过建立 WebSocket 的时候还是需要 HTTP 帮忙, 比如下面是一个经典的握手请求:

1
2
3
4
5
6
7
8
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

可以看出这里有几个字段比较特殊,一个是 Connection,它被设置为了 Upgrade 表示客户端希望升级协议,而要升级的协议就是 Upgrade 字段中指明的 WebSocket。

然后这里还有一个 Sec-WebSocket-Key 字段,它要求服务器计算后返回一个 Sec-WebSocket-Accept 字段表明接受 WebSocket 连接。

此外 Sec-WebSocket-Protocol 字段用于选择子协议,它是可选的,但是在请求中应该只出现一次。

最后 Sec-WebSocket-Version 字段根据 RFC 现在固定是 13,之前的都应该被废弃。

Origin 虽然可以不设置,但出于安全考虑应该被设置。

下面是服务器的回应:

1
2
3
4
5
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

首先注意到的是服务器回应了 101 状态码表示切换协议,同时正如上面提到的,包含了 Sec-WebSocket-Accept 字段表明接受 WebSocket,同时 Sec-WebSocket-Protocol 表示使用子协议 chat。

这里要强调一点,到目前为止都是 HTTP 协议的内容,接下来才是 WebSocket 的主场。

升级协议后客户端就可以打开一个 WebSocket 用于全双工通信了,比如:

1
ws = new WebSocket( "ws://someURL:port");

如果要处理信息的话可以设置相应的回调函数,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function open(){
// 当连接建立的时候调用
}

function message(evt){
// 有消息的时候调用
}

function close(){
// 连接被关闭的时候调用
}

function error(){
// 发生错误的时候调用
}
ws.onopen = open;
ws.onmessage = message;
ws.onclose = close;
ws.onerror = error;

帧结构

侯捷老师曾经说过:

源码之前,了无奥秘

虽然说明了 WebSocket 的起源和特点,但是分析一个协议的话,明白它的帧结构才算是“了无奥秘”,下面是 WebSocket 的帧结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+

具体的分析可以参考 RFC,这里只挑重点讲。

FIN

如果设置为 1 就表明当前是最后一片。

opcode

这个参数决定了下面的负载(payload)如何被翻译,这里只讲几个重要的取值

  • %x1 负载是文本
  • %x2 负载是二进制
  • %x9 表示 ping
  • %xA 表示 pong

从中可以看出,WebSocket 的负载可以是文本也可以是二进制,这为传输提供了良好的灵活性,同时为了防止连接因为长时间空闲被关闭,WebSocket 也提供了 ping-pong 来保持连接。

Mask

表示负载数据是否被掩码,如果设置为 1,那么负载数据应该按照后面的 Masking-key 解码。

Payload data

负载数据实际上包含扩展数据和应用数据,这里不再赘述。

调试

最后回到我们的正题: Bilibili 直播弹幕的抓取。

刚才提到了两点:

  • WebSocket 基于 TCP,区别于 HTTP 是一种新的协议。
  • WebSocket 的帧有文本和二进制两种格式。

浏览器的控制台一般只能抓到 HTTP 包,虽然实际上大部分浏览器已经支持调试 WebSocket 了,但是就 Chrome 来说,它只支持查看文本帧,而 Bilibili 的弹幕是通过二进制帧传输的,用 Chrome 调试的话就会出现下面这样的情况:

可以看到这里出现了刚才提到的 opcode 和 mask,由于 opcode 被设置为 2,所以 Chrome 不会显示负载数据的内容。

所以下一篇文章会介绍如何更好的抓包。

参考资料

RFC6455

WebSocket Wikipedia

Comet:基于 HTTP 长连接的“服务器推”技术

HTTP/1.x 的连接管理