HEXA娱乐开发日志技术点001——上位机成功获取弹幕

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部分,然后用正则表达式找出弹幕内容就行了。

后续

下位机测试


下一篇 HEXA娱乐开发日志技术点002——下位机成功获取弹幕

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,001评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,210评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,874评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,001评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,022评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,005评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,929评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,742评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,193评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,427评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,583评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,305评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,911评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,564评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,731评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,581评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,478评论 2 352

推荐阅读更多精彩内容