逛B站的时候,突然想到可以用PHP接入直播弹幕,然后在命令行显示弹幕消息。
弹幕协议由头部和数据组成,头部的长度是固定的16字节,数据的长度=数据包总长度-头部的长度。
协议的字节序均为大端模式。高字节在低地址,低字节在高地址,比如0x1234,在大端模式下存储是0x120x34,在小端模式下是0x340x12。
下面是弹幕协议的格式。
字段对照表:
常量列是对应的值在代码中的常量名。
/***头部长度*/constHEADER_LEN=16;/***协议版本*/constPROTOCOL_VERSION=2;/***魔法数字,设置为1即可*/constMAGIC_NUMBER=1;打包协议先来看看打包弹幕协议的逻辑,先计算出数据包的总长度,然后将头部信息及数据打包成二进制数据。
publicstaticfunctionpack($opcode,$payload=''){$packetLen=static::HEADER_LEN;if(!empty($payload)){$packetLen+=strlen($payload);}returnpack('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可变参数的方式进行打包,也可以将每个数据单独打包最后再拼在一起,效果也是一样的。
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就可以了。
publicstaticfunctionunpack($data){if(empty($data)){return[];}returnunpack('Npacket_len/nheader_len/nprotocol_version/Nopcode/Nmagic_number/a*payload',$data);}a表示字符串,*表示任意长度,更严谨一点应该将*改为数据的长度(数据包总长度-头部长度)
constPACKET_HEADER_LEN=16;constPACKET_PROTOCOL_VERSION=2;constPACKET_MAGIC_NUMBER=1;classPacket{staticpack(opcode,payload=''){letpacket_len=PACKET_HEADER_LEN;if(payload.length>0){packet_len+=payload.length;}letbuffer=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);}returnbuffer;}staticunpack(data){letbuffer=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。
{"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就可以对弹幕服务器发起连接,连接建立后需要发送认证包加入房间。
认证包的内容:
publicstaticfunctiongetAuthenticatePacket($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,]);returnPacket::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的数据包就可以了。
可以使用Workerman、Swoole甚至PHP原生socket来实现弹幕客户端,那为啥要用Workerman呢?
简单、方便,最重要的是写起来快,不用装扩展也没有原生socket那么繁杂,三两下就写完了。
由于篇幅的原因,我会摘取重要的部分来讲,完整的代码可以去GitHub获取完整代码。
话不多说,干就完了。
Worker进程启动后,通过AsyncTcpConnection创建异步TCP连接对象。
在onConnect回调中发送认证包、开启定时任务,每隔20秒发送一次心跳包。
$room_id=22590309;/*获取直播间配置*/$config=BilibiliBarrage::getChatConfig($room_id);/*创建异步TCP连接对象*/$conn=newAsyncTcpConnection("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解析数据才能得到真正的消息内容。
注意!!!本文及源码仅用于学习研究!请勿用于商业或非法目的,否则后果自负。