惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Spread Privacy
Spread Privacy
K
Kaspersky official blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Forbes - Security
Forbes - Security
Hacker News - Newest:
Hacker News - Newest: "LLM"
The Last Watchdog
The Last Watchdog
SecWiki News
SecWiki News
Attack and Defense Labs
Attack and Defense Labs
Google DeepMind News
Google DeepMind News
Security Archives - TechRepublic
Security Archives - TechRepublic
S
Secure Thoughts
WordPress大学
WordPress大学
Microsoft Security Blog
Microsoft Security Blog
P
Proofpoint News Feed
云风的 BLOG
云风的 BLOG
V
Visual Studio Blog
Security Latest
Security Latest
TaoSecurity Blog
TaoSecurity Blog
Cyberwarzone
Cyberwarzone
S
SegmentFault 最新的问题
Cloudbric
Cloudbric
aimingoo的专栏
aimingoo的专栏
S
Schneier on Security
N
Netflix TechBlog - Medium
MyScale Blog
MyScale Blog
T
The Blog of Author Tim Ferriss
H
Hacker News: Front Page
C
Cybersecurity and Infrastructure Security Agency CISA
小众软件
小众软件
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
AWS News Blog
AWS News Blog
AI
AI
G
GRAHAM CLULEY
IT之家
IT之家
P
Privacy & Cybersecurity Law Blog
L
Lohrmann on Cybersecurity
Last Week in AI
Last Week in AI
D
Docker
Recent Announcements
Recent Announcements
O
OpenAI News
T
Threat Research - Cisco Blogs
GbyAI
GbyAI
S
Security @ Cisco Blogs
T
Troy Hunt's Blog
C
Check Point Blog
博客园 - 三生石上(FineUI控件)
A
About on SuperTechFans
The Cloudflare Blog
阮一峰的网络日志
阮一峰的网络日志
N
News and Events Feed by Topic

文章列表 - 她和她的猫

使用小鹤双拼一个月 折腾 Hugo PaperMod 主题 去长鹿旅游休博园 1Password 自动化登录堡垒机 深入 Hyperf:Inject 注解是如何工作的? 译|理解容器镜像层 使用 Rust 实现高性能预写日志(Write Ahead Log) 译|使用 Linux 命名空间隔离系统 深入 Hyperf:HTTP 服务是如何处理请求的? Linux 文件权限学习笔记 译|了解 Bitcask:基于日志结构的 KV 存储引擎 还原 SM2 压缩公钥的几种方法 深入 Hyperf:HTTP 服务启动时发生了什么? 一次 Hyperf 注解失效问题分析 将博客迁移到又拍云 译|PHP 7 新的 Hashtable 实现 排查 ES 查询问题:深入了解 json_encode() 函数 浅析 Redlock 分布式锁实现原理 Yar 源码阅读笔记:RPC 服务端 Yar 源码阅读笔记:客户端的并行调用 Yar 源码阅读笔记:客户端的同步调用 Yar 源码阅读笔记:数据传输模块 Yar 源码阅读笔记:消息编码模块 Yar 源码阅读笔记:RPC 通信协议 Yar 源码阅读笔记:开篇 PHP 8 新特性介绍 基于 OpenSSL 实现国密 SM4 加解密 PHP 源码阅读笔记:编译与调试 PHP 什么是惊群问题 大端模式和小端模式 基于 GitHub Actions 定时推送网址到百度站长平台 聊聊五种 I/O 模型 Workerman 源码分析:文件上传 浅入浅出 HTTP 协议 PHP 网络编程:构建 MySQL 蜜罐获取攻击者微信 ID 使用 GitHub Actions 自动部署 Hexo 什么是二进制安全 【转载】PHP 程序员进阶之路 优化 Workerman 检查主进程是否存活的逻辑 基于 Redis 实现分布式锁 《PHP 实现 Base64 编码/解码》笔记 PHP 多进程下载必应壁纸 基于 Redis 实现延迟队列 【转载】PHP 实现 Base64 编码/解码 使用 MySQL FIELD() 函数自定义排序规则 Laravel 的 Facades 实现原理 C语言单链表实现约瑟夫环 数据结构学习笔记:顺序栈和链栈(C语言) 使用 QueryList + Redis 下载壁纸 在 Laravel 登录/注册中使用 mews/captcha 扩展包 使用 mews/captcha 扩展包为 Laravel 应用添加图片验证码 给 YOURLS 短网址系统编写插件《批量生成短网址》 给 YOURLS 短网址系统编写插件《Hello World!》 PHP 7.1 使用 json_encode 函数造成浮点类型数据出现精度问题 Laravel 授权策略(Policy)的基本使用 Ubuntu 系统下 WiFi 频繁掉线解决方法 Laravel 应用部署到 Nginx 服务器上的第一个坑 Ubuntu 16.04 LST 安装 Redis 和 Composer PHP 采集逐浪小说章节列表 Nginx 环境 SSL 强制 HTTP 301 跳转到 HTTPS 【工作&生活】第一周 ServiceStack.Redis 与多线程 Count the number of Duplicates C# Base64 加密及解密 ASP.NET MVC 使用动态类型传递数据 Django 使用 order_by() 对数据进行排序操作 ASP.NET + jQuery + AJAX 实现用户登录 ASP.NET 从客户端中检测到有潜在危险的 Request.Form 值 ASP.NET 使用 FormData 实现 AJAX 上传图片 Python 使用 BeautifulSoup 抓取网页 贪心算法之字符串的完美度 一言不和就造轮子之 CookieHelper ASP.NET 上传图片 DBHelper 类 C# 捕获屏幕源码 用 JavaScript 做一些有趣的事情 第一篇文章 好久不见。 如何防止非法调用 PHP 文件 清明小记。 PHP 生成随机字符串 Z-Blog PHP 程序做 301 跳转 PHP 处理数组常用的几个函数 Sublime Text 3 快捷键总结 PHP 使用 file_get_contents() 函数实现采集网页 使用 C# 编写学生信息管理系统 终于到了。 zblog报错: magic_quotes_gpc 和 safe_mode 世事无常。 如何使用 PHP 上传文件 安卓手机编写和运行 PHP 程序的软件 使用 AJAX 刷新、验证 PHP 图片验证码 PHP 生成图片验证码 PHP 之 session 的使用方法 PHP 之 cookie 的使用方法 PHP 实现分页原理详解 Thinkphp 框架中系统常量在 Js 文件中不解析的解决方法
使用 Workerman 接入 Bilibili 直播弹幕协议
她和她的猫 · 2021-04-01 · via 文章列表 - 她和她的猫

逛 B 站的时候,突然想到可以用 PHP 接入直播弹幕,然后在命令行显示弹幕消息。

经过搜索发现了一篇讲解 Bilibili 直播弹幕协议的文章(链接在文末),通过这篇文章了解到了弹幕的协议格式以及大致的流程,开发过程中遇到的一些问题参考了弹幕姬的解决思路。

本文源码的 GitHub 地址:https://github.com/her-cat/bilibili-barrage

弹幕协议的介绍

弹幕协议由头部和数据组成,头部的长度是固定的 16 字节,数据的长度 = 数据包总长度 - 头部的长度。

协议的字节序均为大端模式。高字节在低地址,低字节在高地址,比如 0x1234,在大端模式下存储是 0x12 0x34,在小端模式下是 0x34 0x12。

弹幕协议图示

下面是弹幕协议的格式。

字段对照表:

字段含义
packet_len数据包的总长度
header_len头部长度(固定为 16 字节)
version协议版本号(默认为 2)
opcode操作码,用来标识数据包的类型(详情见下表)
magic_number魔术数字(默认为 1)
data携带的数据,长度 = packet_len - header_len

已知的操作码:

操作码常量含义
2Opcode::CLIENT_HEARTBEAT客户端发送的心跳包
3Opcode::POPULARITY_VALUE人气值,数据是 4 字节整数
5Opcode::CMD命令,数据中[‘cmd’]表示具体命令(见下表)
7Opcode::AUTHENTICATION认证并加入房间
8Opcode::SERVER_HEARTBEAT服务器发送的心跳包

已知的命令:

命令常量含义
INTERACT_WORDCMD::INTERACT_WORD进入直播间
DANMU_MSGCMD::DANMU_MSG弹幕消息
SEND_GIFTCMD::SEND_GIFT送礼物
COMBO_SENDCMD::COMBO_SEND连续送礼物
NOTICE_MSGCMD::NOTICE_MSG通知消息
ONLINE_RANK_V2CMD::ONLINE_RANK_V2在线 PK

常量列是对应的值在代码中的常量名。

处理弹幕协议

跟协议相关的操作都放在了 Packet 类中,将一些固定的值设置成了类的常量。

/**
 * 头部长度
 */
const HEADER_LEN = 16;

/**
 * 协议版本
 */
const PROTOCOL_VERSION = 2;

/**
 * 魔法数字,设置为 1 即可
 */
const MAGIC_NUMBER = 1;

打包协议

先来看看打包弹幕协议的逻辑,先计算出数据包的总长度,然后将头部信息及数据打包成二进制数据。

public static function pack($opcode, $payload = '')
{
    $packetLen = static::HEADER_LEN;
    if (!empty($payload)) {
        $packetLen += strlen($payload);
    }

    return pack('NnnNN', $packetLen, static::HEADER_LEN, static::PROTOCOL_VERSION, $opcode, static::MAGIC_NUMBER).$payload
}

pack/unpack 函数

这里简单讲下 pack/unpack 函数的使用。

pack 就是将输入参数打包成指定格式二进制数据,上面的 n、N 就是指定的格式,分别表示无符号短整型(16位,大端字节序)无符号长整型(32位,大端字节序)

第一个 N 就是以 无符号长整型(32位,大端字节序) 的格式打包 数据包总长度。 第二个 n 就是以 无符号短整型(16位,大端字节序) 的格式打包 头部长度。 第三个 n 就是以 无符号短整型(16位,大端字节序) 的格式打包 协议版本号。 后面的以此类推…

上面使用的是 PHP 可变参数的方式进行打包,也可以将每个数据单独打包最后再拼在一起,效果也是一样的。

return sprintf(
    '%s%s%s%s%s%s',
    pack('N', $packetLen),
    pack('n', static::HEADER_LEN),
    pack('n', static::PROTOCOL_VERSION),
    pack('N', $opcode),
    pack('N', static::MAGIC_NUMBER),
    $payload
);

更多的介绍可以看 https://www.php.net/manual/zh/function.pack.php

unpack 就是 pack 的反向操作,根据指定的格式将二进制数据解压到数组中。

每条数据以 指定的格式 + key 的方式组成,多条数据用 / 分隔。

举个例子:

$data = pack('Nnn', 2021, 3, 31);

var_dump($data);

$arr = unpack('Nyear/nmonth/nday', $data);

var_dump($arr);

// 输出:

string(8) "\000\000\000\000"
array(3) {
  'year' => int(2021)
  'month' =>int(3)
  'day' => int(31)
}

打包的时候是按照 Nnn 的格式打包的,所以解压的时候也是按照 Nnn 的格式来的,只不过需要在每个格式的右边指定以这个格式解压出来的数据对应的 key 是什么。

Nyear 就是以 无符号长整型(32位,大端字节序) 的格式解压,并将 year 作为该数据的 key。 nmonth 就是以 无符号短整型(16位,大端字节序) 的格式解压,并将 month 作为该数据的 key。 …

解压弹幕协议

接下来看看解压弹幕协议的逻辑,其实跟上面说的一样,按照打包的顺序然后指定对应的 key 就可以了。

public static function unpack($data)
{
    if (empty($data)) {
        return [];
    }

    return unpack('Npacket_len/nheader_len/nprotocol_version/Nopcode/Nmagic_number/a*payload', $data);
}

a 表示字符串,* 表示任意长度,更严谨一点应该将 * 改为数据的长度( 数据包总长度 - 头部长度)

使用 Node.js 处理协议

这篇文章发出来之后,我试着用 Node.js 来处理弹幕协议,发现写起来是真的舒服。

const PACKET_HEADER_LEN = 16;
const PACKET_PROTOCOL_VERSION = 2;
const PACKET_MAGIC_NUMBER = 1;

class Packet {
    static pack(opcode, payload = '') {
        let packet_len = PACKET_HEADER_LEN;
        if (payload.length > 0) {
            packet_len += payload.length;
        }

        let buffer = Buffer.alloc(packet_len);

        buffer.writeInt32BE(packet_len, 0);
        buffer.writeInt16BE(PACKET_HEADER_LEN, 4);
        buffer.writeInt16BE(PACKET_PROTOCOL_VERSION, 6);
        buffer.writeInt32BE(opcode, 8);
        buffer.writeInt32BE(PACKET_MAGIC_NUMBER, 12);

        if (payload.length > 0) {
            buffer.write(payload, PACKET_HEADER_LEN, payload.length);
        }

        return buffer;
    }

    static unpack(data) {
        let buffer = Buffer.from(data);

        return {
            packet_len: buffer.readInt32BE(0),
            header_len: buffer.readInt16BE(4),
            version: buffer.readInt16BE(6),
            opcode: buffer.readInt32BE(8),
            magic_number: buffer.readInt32BE(12),
            data: buffer.slice(PACKET_HEADER_LEN),
        };
    }
}

与弹幕服务器的交互

接下来看看如何通过弹幕服务器的认证,并在加入房间之后维护在线状态,我将这部分逻辑都放在了 BilibiliBarrage 类中。

获取弹幕服务器信息

在连接弹幕服务器之前,需要通过房间 id 获取到弹幕服务器的地址和端口号,还有认证需要用到的 token。

 const CHAT_CONFIG_URL = 'https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id=%d';

/**
 * 获取直播间配置
 * @param $room_id
 * @return mixed
 * @throws \Exception
 */
public static function getChatConfig($room_id)
{
    if (isset(static::$roomConfigs[$room_id])) {
        return static::$roomConfigs[$room_id];
    }

    $response = file_get_contents(sprintf(self::CHAT_CONFIG_URL, $room_id));
    $response = json_decode($response, true);

    if (empty($response) || $response['code'] != 0) {
        throw new \Exception("Get chat conf failed, reason: {$response['msg']}");
    }

    static::$roomConfigs[$room_id] = $response['data'];

    return $response['data'];
}

接口返回的内容(省略掉了无关的内容):

{
    "code":0,
    "msg":"ok",
    "message":"ok",
    "data":{
        "refresh_row_factor":0.125,
        "refresh_rate":100,
        "max_delay":5000,
        "port":2243,
        "host":"broadcastlv.chat.bilibili.com",
        "token":"pMF5ippgZMpHIHzTsfKHp9YyqmW3yEfuIuL3pXSMXJ8_9UFN-qSRIPTRxNjpNOr5HQ2-ajI0RcSQkMud2_-lMLoEN92k1glp9fOslshF5SFDqDhlEJRAvUwezoyz72ZdNh-sSqHMPsGOJGMOXZmRNA"
    }
}

认证并加入房间

通过 data 中的 host 和 port 就可以对弹幕服务器发起连接,连接建立后需要发送认证包加入房间。

认证包的内容:

{
  "uid": "0 表示未登录,否则为用户ID",
  "roomid": "房间ID",
  "protover": "协议版本号",
  "platform": "平台",
  "clientver": "客户端版本号",
  "token": "接口返回的 token"
}

认证包的内容就是弹幕协议中携带的数据。

public static function getAuthenticatePacket($room_id, $token = null)
{
    if (empty($token)) {
        $token = static::getChatConfig($room_id)['token'];
    }

    $payload = \json_encode([
        'uid' => 0,
        'roomid' => $room_id,
        'protover' => Packet::PROTOCOL_VERSION,
        'platform' => 'web',
        'token' => $token,
    ]);

    return Packet::pack(Opcode::AUTHENTICATION, $payload);
}

返回的内容:

\000\000\000�\000\000\000\000\000\000\000\000{"uid":0,"roomid":22590309,"protover":2,"platform":"web","token":"pMF5ippgZMpHIHzTsfKHp9YyqmW3yEfuIuL3pXSMXJ8_9UFN-qSRIPTRxNjpNOr5HQ2-ajI0RcSQkMud2_-lMLoEN92k1glp9fOslshF5SFDqDhlEJRAvUwezoyz72ZdNh-sSqHMPsGOJGMOXZmRNA"}

弹幕服务器收到认证包后,会回复我们加入成功的消息,Packet::unpack 后得到消息内容:

array(6) {
  'packet_len' => int(26)
  'header_len' => int(16)
  'protocol_version' => int(2)
  'opcode' => int(8)
  'magic_number' => int(1)
  'payload' => string(10) "{"code":0}"
}

opcode 为 8 表示是服务器发送的心跳包,payload 是一个 JSON 字符串,code 为 0 表示连接成功。

这一步完成之后就可以收到弹幕消息了,但是还差最后一步。

维持在线状态

弹幕服务器要求每隔 30 秒发送一次心跳包,以确定客户端还处于活跃状态。

心跳包没有数据,只需要发送 opcode 为 2 的数据包就可以了。

public static function getHeartBeatPacket()
{
    return Packet::pack(Opcode::CLIENT_HEARTBEAT);
}

考虑到网络传输的因素,心跳包间隔时间一般设置小于 30 秒,防止一些原因导致心跳包没有及时发送。

实现弹幕客户端

可以使用 Workerman、Swoole 甚至 PHP 原生 socket 来实现弹幕客户端,那为啥要用 Workerman 呢?

简单、方便,最重要的是写起来快,不用装扩展也没有原生 socket 那么繁杂,三两下就写完了。

一句话:就是通透

由于篇幅的原因,我会摘取重要的部分来讲,完整的代码可以去 GitHub 获取完整代码。

话不多说,干就完了。

连接弹幕服务器

Worker 进程启动后,通过 AsyncTcpConnection 创建异步 TCP 连接对象。

在 onConnect 回调中发送认证包、开启定时任务,每隔 20 秒发送一次心跳包。

$room_id = 22590309;
/* 获取直播间配置 */
$config = BilibiliBarrage::getChatConfig($room_id);

/* 创建异步 TCP 连接对象 */
$conn = new AsyncTcpConnection("tcp://{$config['host']}:{$config['port']}");

$conn->onConnect = function(TcpConnection $conn) use ($room_id, $config) {
    $packet = BilibiliBarrage::getAuthenticatePacket($room_id, $config['token']);
    /* 发送认证包 */
    $result = $conn->send($packet, true);
    if (!$result) {
        Worker::safeEcho("发送认证包失败\n");
        return;
    }

    /* 开启定时任务 */
    Timer::add(BilibiliBarrage::HEART_BEAT_INTERVAL, function (TcpConnection $conn) {
        /* 发送心跳包 */
        $conn->send(BilibiliBarrage::getHeartBeatPacket(), true);
    }, [$conn]);
};

处理弹幕消息

在 onMessage 回调中,先 unpack 数据,通过 opcode 判断本次消息是做什么的,不同的消息做不同的处理。如果 opcode 为 CMD,需要通过 Packet::parsePayload 解析数据才能得到真正的消息内容。

$conn->onMessage = function($conn, $data) {
    $packet = Packet::unpack($data);
    /* 通过 opcode 判断消息类型 */
    switch ($packet['opcode']) {
        case Opcode::POPULARITY_VALUE:
            Worker::safeEcho(sprintf("人气值: %d\n", Packet::parsePayload($packet['opcode'], $packet['payload'])));
            break;
        case Opcode::CMD:
            /* 解析数据 */
            $payload = Packet::parsePayload($packet['opcode'], $packet['payload']);
            if (empty($payload)) {
                break;
            }

            switch ($payload['cmd']) {
                case 'INTERACT_WORD':
                    Worker::safeEcho("{$payload['data']['uname']} 进入直播间\n");
                    break;
                case 'DANMU_MSG':
                    Worker::safeEcho("{$payload['info'][2][1]}: {$payload['info'][1]}\n");
                    break;
                case 'SEND_GIFT':
                    Worker::safeEcho("{$payload['data']['uname']} {$payload['data']['action']} {$payload['data']['giftName']}\n");
                    break;
                case 'COMBO_SEND':
                    Worker::safeEcho("{$payload['data']['uname']} {$payload['data']['action']} {$payload['data']['gift_name']} [combo]\n");
                    break;
                /* 更多命令查看 \App\CMD.php 文件 */
            }
            break;
        case Opcode::SERVER_HEARTBEAT:
            Worker::safeEcho("加入房间成功\n");
            break;
        default:
            /* 未知的 opcode 可以打印 packet */
            // var_dump($packet);
            break;
    }
};

总结

最后附上一张运行图:

⚠️ 注意!!!本文及源码仅用于学习研究!请勿用于商业或非法目的,否则后果自负。

相关链接: