使用 Workerman 接入 Bilibili 直播彈幕協議


轉載請注明來源地址:她和她的貓

逛 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

已知的操作碼:

操作碼 常量 含義
2 Opcode::CLIENT_HEARTBEAT 客戶端發送的心跳包
3 Opcode::POPULARITY_VALUE 人氣值,數據是 4 字節整數
5 Opcode::CMD 命令,數據中['cmd']表示具體命令(見下表)
7 Opcode::AUTHENTICATION 認證並加入房間
8 Opcode::SERVER_HEARTBEAT 服務器發送的心跳包

已知的命令:

命令 常量 含義
INTERACT_WORD CMD::INTERACT_WORD 進入直播間
DANMU_MSG CMD::DANMU_MSG 彈幕消息
SEND_GIFT CMD::SEND_GIFT 送禮物
COMBO_SEND CMD::COMBO_SEND 連續送禮物
NOTICE_MSG CMD::NOTICE_MSG 通知消息
ONLINE_RANK_V2 CMD::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;
    }
};

總結

最后附上一張運行圖:

⚠️ 注意!!!本文及源碼僅用於學習研究!請勿用於商業或非法目的,否則后果自負。

相關鏈接:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM