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 | $(document).ready(function(){ |
这种方法好处是实现起来非常简单,但是缺点是非常致命的
- 会对服务器造成非常大的压力
- 当没有数据的时候带宽都浪费在传输 Header 上了
为了尝试克服这些缺点就出现了长轮询和流技术。
长轮询
长轮询其实本质上还是轮询。但是不同的是,如果没有消息的话服务器不会立即返回,而是会等待一段时间,如果有足够的消息或者超时则立即返回。
贴一段 CTBX 中的代码:
1 | void Bot::_tgbot_start_polling() { |
这里的 LongPoll 就是一个长轮询,忽略网络因素的话它在下面这两种情况会返回(接收到服务器的 Response )
- Bot 有 100 条消息待接收。
- 从接收到 Request 后过去了 10 秒
可以看出长轮询有效克服了短轮询的一些缺点。
流
另一种技术是利用 iframe 实现的长连接,这种方法比较 hack,这里直接展示一段代码:
1 | <html> |
原理非常简单,就是不断更新 iframe 的 src 来保持长连接,然后服务器返回 javascript 脚本调用相应的函数。
实际上,在 HTTP/1.1 中长连接模型代替 1.0 中的短连接模型成为了默认选项,所以这种技术的意义可能并不是很大了,而且另一个致命的问题是在加载的时候浏览器的小圈会一直转,逼死强迫症。
什么是 WebSocket
其实上面这几种技术有一个共同的名称就是 Comet,它们的出发点无非就是想让服务器有新消息的时候尽快通知前端,但是 HTTP 协议设计的时候可没这么想过,所以就有了 WebSocket。
WebSocket 本质上就是两个 Socket 的双向通信,前端绑定一个地址和端口,后端绑定一个地址和端口然后就能双向通信了,所以 WebSocket 是一种和 HTTP 完全不同的协议,不过二者都是基于 TCP。
和 HTTP 联系
虽然 WebSocket 是一种完全不同的协议,不过建立 WebSocket 的时候还是需要 HTTP 帮忙, 比如下面是一个经典的握手请求:
1 | GET /chat HTTP/1.1 |
可以看出这里有几个字段比较特殊,一个是 Connection,它被设置为了 Upgrade 表示客户端希望升级协议,而要升级的协议就是 Upgrade 字段中指明的 WebSocket。
然后这里还有一个 Sec-WebSocket-Key 字段,它要求服务器计算后返回一个 Sec-WebSocket-Accept 字段表明接受 WebSocket 连接。
此外 Sec-WebSocket-Protocol 字段用于选择子协议,它是可选的,但是在请求中应该只出现一次。
最后 Sec-WebSocket-Version 字段根据 RFC 现在固定是 13,之前的都应该被废弃。
Origin 虽然可以不设置,但出于安全考虑应该被设置。
下面是服务器的回应:
1 | HTTP/1.1 101 Switching Protocols |
首先注意到的是服务器回应了 101 状态码表示切换协议,同时正如上面提到的,包含了 Sec-WebSocket-Accept 字段表明接受 WebSocket,同时 Sec-WebSocket-Protocol 表示使用子协议 chat。
这里要强调一点,到目前为止都是 HTTP 协议的内容,接下来才是 WebSocket 的主场。
升级协议后客户端就可以打开一个 WebSocket 用于全双工通信了,比如:
1 | ws = new WebSocket( "ws://someURL:port"); |
如果要处理信息的话可以设置相应的回调函数,比如:
1 | function open(){ |
帧结构
侯捷老师曾经说过:
源码之前,了无奥秘
虽然说明了 WebSocket 的起源和特点,但是分析一个协议的话,明白它的帧结构才算是“了无奥秘”,下面是 WebSocket 的帧结构:
1 | 0 1 2 3 |
具体的分析可以参考 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 不会显示负载数据的内容。
所以下一篇文章会介绍如何更好的抓包。