基于websocket和goahead实现前端RTSP流视频控制

最近在开发嵌入式设备视频监控的前端程序,前端使用vue框架实现基本逻辑,后端是C++,用webpack编译器编译成asp过程,服务器是goahead,用goahead解析asp过程,通过websocket调用Ajax通信,进而调用底层C++接口实现整体功能。

goahead是一种嵌入式服务器,经常用于搭载小型Linux的终端设备中,这种服务器小巧简洁,但功能单一,受限于硬件设备,作用是将C++获取到的底层数据转化成http数据报发给前端,且只支持asp过程。

视频编码主要分为存储型编码和压制型编码:

  • 压制型编码是将画面中的音频,视频、字幕等多个轨道合并到一起,然后再进行传输的编码,这种编码的好处是可以有效解决传输过程中产生的音画不同步问题,典型的编码方案有mp4、mkv、rmvb等方式
  • 存储型编码主要是为视频生成一系列关键帧,其他帧只保留与关键帧有差异的地方,没有差异的部分全部删掉以节省存储空间,在播放时通过实时计算恢复出整个视频的全貌,所以这种编码也被称作残片编码。主流残片存储编码方案:H265(较新,未来不可限量,但目前编码时间过长),VP8(成熟,但码率较高,对带宽有一定要求),H264(久经考验,成熟,画质高,码率低,当下最优解), 我们项目中使用的是H264编码存储方案

由于要在浏览器中获取视频流,而浏览器不支持H264解码,所以必须进行转码,我调研了目前主流的方案:

  • 第一种是海康、大华以及众多二次集成商,他们使用的是基于微软ActiveX插件实现实时显示相机画面的方案,好处是人员需求少,方案成熟,只需要前端就可以搞定,但问题在于兼容性差,只支持IE浏览器,其他品牌浏览器均不支持ActiveX,且该插件具有系统级权限,有极高的安全风险,在安装时浏览器会弹出红色预警提示,大概率会使用户对产品安全性产生质疑。且目前主流浏览器的最新几个版本都不支持插件方案,在未来,插件方案极大概率会被浏览器厂商彻底抛弃。
  • 第二种是建立一个中转服务器,就后台拉取的RTSP流转发给前端的websocket,Websocket在2011年成为国际标准,2011年之后的浏览器全部支持,能复用http协议,可在tcp上面快速建立长连接,性能高且能发送二进制数据,是解决视频直播、点播问题的有效方案,这种方案需要稳定的网络传输环境,并不适合嵌入式设备。所以我们应用在了云平台上,详见<使用node + ffmpeg实现RTSP流视频控制>。
  • 第三种就是后端将H264转成jpeg和amr码流推送给前端websocket,这可以有效解决嵌入式设备中网络不稳定的问题,下面讲一讲该方案在实现过程中我遇到的典型问题:

一、异常重连机制

首先各种浏览器内核对websocket的网页刷新处理是不一样的,比如在类Chrome浏览器,对websocket进程管理使用的是单例模式,每次刷新会使用一个新的进程代替原有的进程,原有的进程会被挂起。
谷歌websock.png

而在类IE浏览器中每次刷新会新建一个进程,一个websocket连接最多能并发连接五个进程,超过的话则失去响应。
IEwebsock.png

由于设备硬件资源有限,不稳定,偶尔会出现异常重启,所以我区分webkit内核(断线刷新)和非webkit内核(断线不刷新尝试重连)进行了重连设计,核心都是onMessage和onClose函数中设计一个状态变量wsIsOpened,当断线时,将该状态置为0,然后尝试重连

  • 注意,在重连时,websocket首先进行TCP握手连接,成功之后此时websocket的open状态为true,但是由于websocket还要发送一次通信协议升级协议,因此此时send函数不能调用,强行调用会报错,应当与后端商定,添加一条升级成功的消息

我在这里设计的成功消息,自定义了一个2001状态码,同时在该消息中还发送了一个后端生成的用户消息签名,用于生成密钥,伪代码如下:

const onClose = function(evt) {
  wsIsOpened = 0
  
  /* 
  * TODO 重连代码
  */
}

const onMessage = function(evt) {
  if (evt && evt.data) {
    // 判断是否为秘钥协商命令
        if (+myCmd === 2001) {
            const auth_asw = {
              'ContentType': 'json',
              'MasterType': 1,
              'Cmd': 2001,
              'EncryptMode': 0,
              'SYN':0
              'ACK':1
            }
        }
        const asw_json = JSON.stringify(auth_asw)
        send(asw_json)
        wsIsOpened = 1 // 秘钥协商响应包发送完毕,则认为链接已建立,此时可进行其它业务数据传输
    }
}

二、图片传输码流格式
传输码流格式,websocket中常用的码流格式有两种,一种是二进制,数据头是:10001010(130); 还有一种是字符串数据头:10001001(129),图片,zip等大文件传输要使用二进制,二进制不需要编码,这样传输节约带宽。而一般的消息报文应当使用字符串模式,这样报文容易解析,代码量小

区分二进制和字符串接收,前端发送时可以这么写

 if (isArraybuffer) {
     socket.binaryType = 'arraybuffer'
     socket.onsend(Data)
 }else{
     socket.binaryType = 'blob'
     socket.onsend(Data)
 }
     

前端接收时只需要在onmessage方法中做一下类型判断就可以了,比如接收图片流时,只需要定义一个图片缓存数组picBuffer,如果发现是二进制码流,直接将其转换成base64编码,push进数组队尾,当前端调用图片流时再shift出队头的图片即可,伪代码类似这样:

const picBuffer=[]
...

 if (typeof evt.data !== 'string') {
     //将接收到的二进制码转成base64编码
    picBuffer.push(arrayBufferToBase64(evt.data))
    return
  }else{
    //将接收到的码按字符串处理
}

...
const getPicStream = function() {
  if (picBuffer.length > 0) {
    return picBuffer.shift()
  } else {
    return null
  }
}
const cleanPicStream = function() {
  if (picBuffer.length > 0) {
    picBuffer.length = 0
  }
}
const arrayBufferToBase64 = function(buffer) {
  let binary = ''
  const bytes = new Uint8Array(buffer)
  const len = bytes.byteLength
  for (let i = 0; i < len; i++) {
    binary += String.fromCharCode(bytes[i])
  }
  const pic = 'data:image/jpeg;base64,' + window.btoa(binary)
  return pic
}

export {
    cleanPicStream,
    getPicStream
}

三、并发传输消息接收错乱问题

当一次性发送多条消息后,由于消息返回的先后不同,接收时会有消息错乱的情况,我的方法是新建一个map,当接收到消息时在onmessage函数中将消息和状态码全部缓存起来,当前端获取状态时,通过状态码返回消息,同时将该状态码删掉,这样就可以避免并发处理消息时产生的消息错乱问题

websocket 代码

const responseArray = new Map()

const haveResponse = function(Cmd) {
    return responseArray.has(+Cmd)
}

const getResponse = function(Cmd) {
  const strResponse = responseArray.get(+Cmd)
  responseArray.delete(+Cmd)
  return strResponse
}

const websocket = new Socket(IP)
websocket.onmessage = function(evt) {
   responseArray.set(evt.Cmd, evt.resJson)
}
export {
  websocket,
  haveResponse,
  getResponse,
}

调用代码

onsocketRes(CMDCode, cb){
    if (haveResponse(CMDCode)) {
       // 返回消息分发给前端框架
      const msgStr = getResponse(CMDCode)
      cb(msgStr)
    }
}
  • 注意,在并发发送消息后,由于返回的消息的时延不可控制,以及async、promise等方法的队列优先级高于setInterval,所以在检测网络超时的逻辑中不能使用,必须使用回调函数cb的方式。

websocket文件

const Socket = window.MozWebSocket || window.WebSocket
let websocket = null // 防止内存泄露
let connCb = null // 成功连接后回调
const responseArray = new Map()
const picBuffer = []
let wsIP = null
const concurrency = 5 // websocket的并发数
let wsIsOpened = 0 // websocket的链接状态,0,未链接,1,已链接
const onSend = function(msg) {
  websocket.send(msg)
}

const onOpen = function(e, isNew) {
  if (e && e.type === 'open') {
    return new Promise((resovle, rej) => {
      resovle(true)
    })
  }
}

const haveResponse = function(Cmd) {
    return responseArray.has(+Cmd)
}

const getResponse = function(Cmd) {
  const strResponse = responseArray.get(+Cmd)
  responseArray.delete(+Cmd)
  return strResponse
}

const getPicStream = function() {
  if (picBuffer.length > 0) {
    return picBuffer.shift()
  } else {
    return null
  }
}
const cleanPicStream = function() {
  if (picBuffer.length > 0) {
    picBuffer.length = 0
  }
}
const onClose = function(evt) {
  websocket = null
  wsIsOpened = 0
  // 三秒后进行重新链接
  setTimeout(() => {
    websocket = new webSocket()
  }, 3000)
}

const checkConnectStatus = function() {
  // savedSocket = new webSocket();
  // 三秒时间来检测秘钥协商是否已经完成,wsIsOpened是否为1
  // 如果秘钥协商少于三秒完成,则立即返回
  let reconTimer = null
  let count = 0

  return new Promise((resolve, reject) => {
    reconTimer = setInterval(() => {
      if (wsIsOpened) {
        clearInterval(reconTimer)
        reconTimer = null
        resolve(true)
      } else if (count > 30) {
        clearInterval(reconTimer)
        reconTimer = null
        resolve(false)
      }
      count++
    }, 100)
  })
}

const arrayBufferToBase64 = function(buffer) {
  let binary = ''
  const bytes = new Uint8Array(buffer)
  const len = bytes.byteLength
  for (let i = 0; i < len; i++) {
    binary += String.fromCharCode(bytes[i])
  }
  const pic = 'data:image/jpeg;base64,' + window.btoa(binary)
  return pic
}

const onMessage = function(evt) {
  if (typeof evt.data !== 'string') {
    picBuffer.push(arrayBufferToBase64(evt.data))
    return
  }
  if (evt && evt.data) {
    // 判断是否为秘钥协商命令
    const recvData = evt.data
    if (+evt.Cmd === 2001) {
        // 应答websocket升级协议,同时交换密钥
        const asw_json = JSON.stringify(auth_asw)
        send(asw_json)
        wsIsOpened = 1 // 秘钥协商响应包发送完毕,则认为链接已建立,此时可进行其它业务数据传输
    } else {
      const resJson = evt.data
      responseArray.set(+myCmd, resJson)
    }
  } else {
    console.log('链接错误,数据包不能解析')
  }
}
const onError = function(evt) {
  console.log('Error occured: ', evt)
}

const webSocket = function() {
  try {
    if (!websocket) {
      websocket = new Socket(settingWsIP)
    }
  } catch (evt) {
    websocket = null
    connCb('-1', 'connect error!')
  }
  websocket.onsend = onSend
  websocket.onopen = onOpen
  websocket.onclose = onClose
  websocket.onmessage = onMessage
  websocket.onerror = onError
  webSocket.isOnBinaryStream = false
  webSocket.binaryType = null
  return websocket
}

export {
  concurrency,
  haveResponse,
  getResponse,
  getPicStream,
  cleanPicStream,
  checkConnectStatus,
  webSocket,
  wsIsOpened
}

调用方法

    if (wsIsOpened && savedSocket) {
      const socket = savedSocket
      // 如果直播协议切换成功,切换成二进制传输协议
      if (CMDKey === 'REQUEST_STREAM_START') {
        socket.binaryType = 'arraybuffer'
        socket.isOnBinaryStream = true
        return request({
          url: '/reqBinaryStream',
          method: 'websocket',
          socket
        })
      }
      //非直播协议,按照是否加密区分发送的是消息还是大文件
      if (EncryptMode === 0) {
        // 是zip文件,直接按二进制码流推送, 数据头:10001010; ascll码数据头:10001001
        socket.binaryType = 'arraybuffer'
        socket.onsend(ZipFileData)
      } else {
        // 消息加密
        socket.binaryType = 'blob'
        const CMDJsonStr = JSON.stringify(CMDJson)
        const AESCMDJson = AESEncrypt(SHA224ModeKey, CMDJsonStr)
        socket.onsend(AESCMDJson)
      }

      let resultJson = null
      let _WATCH_DOG = null
      let count = 0
      const timeOut = getTimeOut(CMDKey, myfaceCount)
      // 启动看门狗,检测超时
      _WATCH_DOG = setInterval(() => {
        if (haveResponse(CMDCode)) {
           // 返回消息分发给前端框架
          const msgStr = getResponse(CMDCode)
          resultJson = JSON.parse(msgStr)
          clearInterval(_WATCH_DOG)
          _WATCH_DOG = null
          cb(resultJson)
        } else if (count > timeOut) {
          // 超时报错
          clearInterval(_WATCH_DOG)
          _WATCH_DOG = null
          cb(new Error('recv response timeout'))
        }
        count++
      }, 100)
    } else {
      // 按F5时刷新页面,重新建立websocket链接,并延时检测链接是否重新建立完成
      savedSocket = new webSocket()
      const reconFlag = await checkConnectStatus()
      // 如果重连成功,则重新触发一次获取响应数据
      if (reconFlag) {
         // 恢复通信现场,重新发送消息
        tanslateJson(CMDKey, listQuery, savedSocket, cb, Prams)
      } else {
        // 网络错误
        cb(new Error('socket connect error'))
      }
    }

你可能感兴趣的:(基于websocket和goahead实现前端RTSP流视频控制)