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——下位機成功獲取彈幕

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容