Bilibili直播弹幕抓取(1):WebSocket
Bilibili直播弹幕抓取(1):WebSocket
转载自https://ihomura.cn/2018/05/14/Bilibili%E7%9B%B4%E6%92%AD%E5%BC%B9%E5%B9%95%E6%8A%93%E5%8F%96-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 不会显示负载数据的内容。
所以下一篇文章会介绍如何更好的抓包。