Workerman을 사용하여 Bilibili 라이브 댓글 프로토콜 연동

Bilibili를 탐색하던 중 PHP를 사용해 라이브 댓글 프로토콜에 연결하고 명령줄에서 댓글 메시지를 표시할 수 있겠다는 생각이 들었습니다.

검색을 통해 Bilibili 라이브 댓글 프로토콜에 대한 설명 문서를 발견했으며(링크는 글의 끝부분 참조), 이 문서를 통해 댓글 프로토콜의 형식과 대략적인 절차를 알게 되었습니다. 개발 과정에서 발생한 몇 가지 문제는 댓글 클라이언트의 해결 방식을 참고했습니다.

본 글의 GitHub 주소: https://github.com/her-cat/bilibili-barrage

재배포 시 출처 주소를 명시해 주세요: https://her-cat.com/posts/2021/04/01/workerman-to-access-bilibili-barrage-protocol/

댓글 프로토콜 소개

댓글 프로토콜은 헤더와 데이터로 구성되며, 헤더의 길이는 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 형식으로 패킹했기 때문에, 해제할 때도 동일한 형식으로 해제하지만 각 형식 오른쪽에 해당 데이터의 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를 사용하여 댓글 서버의 주소와 포트 번호, 그리고 인증에 필요한 토큰을 가져와야 합니다.

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'];
}

API 반환 내용(관련 없는 내용 생략):

{
    "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": "API 반환 토큰"
}

인증 패킷 내용은 댓글 프로토콜에서 전송되는 데이터입니다.

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;
    }
};

태그: Workerman Bilibili PHP 실시간 통신 라이브 댓글

5월 26일 08:42에 게시됨