HEXA开发日志目录
上一篇 HEXA娱乐开发日志技术点000——整理&第一个Skill
折腾一天多,终于能在上位机获取B站弹幕了,源码(本文基准版本5831e4078529380fae4d52eb1729ef956aabc5a6)已经放到了github上。有很多时间花在摆平开发环境上,而环境问题的根源在于GO是谷歌的,谷歌是被墙的,不过最后发现,装完了GO之后,其他环境可以不搞。这让我想起了以前做VR开发时,Oculus驱动下载因为脸书被墙而躺枪的情形。类似场景很多,所以科学上网成了很多国内开发者的必备技能。
我对Web相关技术不熟悉,原理都是从读弹幕姬的源码了解的。对于弹幕姬,我也算不上移植,只是参考而已,因为其大部分内容和我需要的业务逻辑没有关系,我只需要和弹幕服务器保持连接而已,顺便发现B站的API好像都是大家试出来的,没找到公开的官方文档,这些第三方开发者与B站是啥关系我还闹不清楚。
原理
言归正传,B站的获取弹幕机制很简单。B站有一个专门的弹幕服务器,我需要从房间信息获取这个服务器的地址,端口,然后用TCP协议与服务器连接和握手,握手成功后,这个房间收到的弹幕就会发送给我。信息依赖关系:
房间号+房间信息API==>房间信息
房间信息==>服务器地址+服务器端口
服务器地址+服务器端口+TCP+B站弹幕协议==>连接弹幕服务器
连接服务器之后有3件事要做
1.加入房间
把房间号和用户id发送给弹幕服务器。其中,这个用户id是一个特定算法生成的随机数。
2.发送心跳包
3.接收数据
目前知道的数据有3种,分别是加入成功、观众人数和播放命令(包含弹幕),目前看到的命令有三种,分别是弹幕、系统礼物和系统消息,当然,我目前只关心弹幕。
弹幕协议
一个弹幕数据包包含header和body两部分,有些包不含body。
header(16Bytes)
字段 | 长度(Byte) | 备注 |
---|---|---|
packet size | 4 | 整个包的Byte大小 |
magic | 2 | 固定16 |
version | 2 | 协议版本,有些1,有些是0 |
action | 4 | 决定包的含义,目前已知 2=心跳 3=加入成功 5=播放命令 7=加入房间 8=加入房间成功 |
parameter | 4 | 目前不知道有什么用 |
body
action | 长度(Byte) | 备注 |
---|---|---|
2 | 0 | —— |
3 | 4 | 观众人数 |
5 | 不定 | json格式的命令,我看到的cmd字段 DANMU_MSG=弹幕 SYS_GIFT=系统礼物 SYS_MSG=系统消息 |
7 | 不定 | json格式的加入房间的信息,有2个字段 roomid=房间号 uid=用户id |
8 | 0 | —— |
GO编程实现
实现过程用到了以下几项内容:
- http client请求发起与响应处理
- 正则表达式
- TCP client的读写操作
- 解决粘包
以下是各步骤具体实现
获取弹幕服务器信息
这一步的目的是调用B站API获得响应网页,再从网页中拿到服务器信息,做法也相当简单,直接看下面代码的注释吧。
func getDmAddr(roomId string) (string, error) {
//发起http请求,这个地址就是一个B站API
resp, err := http.Get("http://live.bilibili.com/api/player?id=cid:" + roomId)
if err != nil {
log.Println(err)
return "", errors.New("request bilibili api/player fail with id=" + roomId)
}
defer resp.Body.Close()
//读取服务器返回的网页
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Println(err)
return "", errors.New("read bilibili api/player fail with id=" + roomId)
}
//用正则表达式获取网页中的服务器地址
dmAddr := ""
reg := regexp.MustCompile(dmServerLabel + `(.*)` + dmServerLabel)
result := reg.FindString(string(body)) // find danmu server
if len(result) > len(dmServerLabel+">"+"</"+dmServerLabel) {
dmAddr = result[len(dmServerLabel+">"):len(result)-len("</"+dmServerLabel)] + ":"
} else {
return "", errors.New("search danmu server fail with id=" + roomId)
}
//用正则表达式获取网页中的服务器端口
reg = regexp.MustCompile(dmPortLabel + `(.*)` + dmPortLabel)
result = reg.FindString(string(body)) // find danmu port
if len(result) > len(dmPortLabel+">"+"</ "+dmPortLabel) {
dmAddr = dmAddr + result[len(dmPortLabel+">"):len(result)-len("</"+dmPortLabel)]
} else {
return "", errors.New("search danmu port fail with id=" + roomId)
}
return dmAddr, nil
}
有一点问题是,不确定GO的正则表达式是否支持零宽断言,总之没有成功,好在GO的切片操作很灵活,没有造成很大麻烦。
与服务器建立TCP连接
以下是TCP连接的基本套路,具体细节还得看源码。
//获取弹幕服务器地址的TCP地址
tcpaddr, err := net.ResolveTCPAddr("tcp4", dmAddr)
//建立TCP连接
conn, err := net.DialTCP("tcp", nil, tcpaddr)
checkErr(err)
defer conn.Close()
......
//发送数据
conn.Write(data)
......
//接收数据
conn.Read(buffer)
......
构造数据包
下面这个函数用来构造发送数据包,目前只有2个数据包需要构造,分别是加入房间的信息包和心跳包
func generatePacket(packetlength int, action int, param int, body string) ([]byte, error) {
if packetlength == 0 || packetlength < protocolHeaderSize {
packetlength = len(body) + protocolHeaderSize
}
var buffer [sendBufferSize]byte
buffer[0] = byte((packetlength >> 24) & 0xFF)
buffer[1] = byte((packetlength >> 16) & 0xFF)
buffer[2] = byte((packetlength >> 8) & 0xFF)
buffer[3] = byte(packetlength & 0xFF)
buffer[4] = byte((magic >> 8) & 0xFF) // magic
buffer[5] = byte(magic & 0xFF)
buffer[6] = byte((protocolVer >> 8) & 0xFF) // ver
buffer[7] = byte(protocolVer & 0xFF)
buffer[8] = byte((action >> 24) & 0xFF) // action
buffer[9] = byte((action >> 16) & 0xFF)
buffer[10] = byte((action >> 8) & 0xFF)
buffer[11] = byte(action & 0xFF)
buffer[12] = byte((param >> 24) & 0xFF) // param
buffer[13] = byte((param >> 16) & 0xFF)
buffer[14] = byte((param >> 8) & 0xFF)
buffer[15] = byte(param & 0xFF)
if packetlength > protocolHeaderSize && packetlength < sendBufferSize {
playload := []byte(body)
length := packetlength - protocolHeaderSize
//这个拷备应该可以优化一下
for i := 0; i < length; i++ {
buffer[i+protocolHeaderSize] = playload[i]
}
} else if packetlength >= sendBufferSize {
log.Println("body"+body+", please limit to ", sendBufferSize-protocolHeaderSize)
return nil, errors.New("body is too large")
}
return buffer[:packetlength], nil
}
接收数据包
接收数据包有一点小麻烦在于粘包问题。问题产生的根源在于,我们使用固定buffer接收网络上的stream,我们不方便直接处理stream,只能处理固定buffer的数据,如此一来,这个固定buffer的内容就有若干种可能了,可以是不完整的一个包,也可以是完整的几个包,还可以是不完整的2个包等情形,这对于不熟悉GO语言的我是个大难题。
不过,只要知道这个问题的名字叫“粘包”,在网上一搜“GO 粘包
”,非常容易找到示例代码的,下面是我参考网上写的。
go func() {
waitgroup.Add(1)
tmpBuffer := make([]byte, 0)
//声明一个管道用于接收解包的数据
playerCmdChannel := make(chan []byte, receiveBufferSize)
go parserPlayerCmd(playerCmdChannel)
buffer := make([]byte, receiveBufferSize)
for working {
n, err := conn.Read(buffer)
if err != nil {
log.Println(conn.RemoteAddr().String(), " connection error: ", err)
return
}
log.Println("n=", n)
if n > 0 {
var needMore bool
//解包,为了逻辑简单,Unpack保证tmpBuffer的开头总是一个Header
tmpBuffer, needMore = Unpack(append(tmpBuffer, buffer[:n]...), playerCmdChannel)
for !needMore {
//如果发现当前buffer中还有一个完整的包,就再解包一次
tmpBuffer, needMore = Unpack(tmpBuffer, playerCmdChannel)
}
} else {
time.Sleep(time.Millisecond * 100)
}
}
log.Println("receive exit.")
waitgroup.Done()
}()
数据包的解析就很简单了,根据协议抓出body部分,然后用正则表达式找出弹幕内容就行了。
后续
下位机测试
网友评论