最近在开发嵌入式设备视频监控的前端程序,前端使用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,这可以有效解决嵌入式设备中网络不稳定的问题,下面讲一讲该方案在实现过程中我遇到的典型问题:
一、异常重连机制
由于设备硬件资源有限,不稳定,偶尔会出现异常重启,所以我区分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'))
}
}