美文网首页网络相关开源代码Java学习之路
基于WebSocket的在线聊天室(二)

基于WebSocket的在线聊天室(二)

作者: anyesu | 来源:发表于2016-05-05 11:12 被阅读5153次
效果预览

前言


在上一篇文章中已经对websocket的做了一定的介绍,并给出了一个文本聊天室的例子,本文将继续对其进行功能扩展,加上语音和视频的功能(感觉瞬间高大上了有木有 *_*)

相关技术


在做功能之前也是找了不少资料的,发现网上对于视频通话的实现基本都是采用了WebRTC这么个东东

WebRTC,名称源自网页实时通信(Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的技术,是谷歌2010年以6820万美元收购Global IP Solutions公司而获得的一项技术。2011年5月开放了工程的源代码,在行业内得到了广泛的支持和应用,成为下一代视频通话的标准。

看别人的例子用起来貌似挺简单的样子,但因为是谷歌的东西,还要连STUN服务器什么的,就放弃了深入研究,有兴趣的自行百度吧。本文中是根据websocket和html5的一些特性进行开发的。

主要步骤


1. 调用摄像头
2. 画面捕捉
3. 图片传输
4. 图片接收和绘制

注:上面步骤以视频传输为例,音频部分类似,但也有区别,后面再讲

调用摄像头


jAlert = function(msg, title, callback) {
    alert(msg);
    callback && callback();
};
//媒体请求成功后调用的回调函数
sucCallBack = function(stream) {
    //doSomething
};
//媒体请求失败后调用的回调函数
errCallBack = function(error) {
    if (error.PERMISSION_DENIED) {
        jAlert('您拒绝了浏览器请求媒体的权限', '提示');
    } else if (error.NOT_SUPPORTED_ERROR) {
        jAlert('对不起,您的浏览器不支持摄像头/麦克风的API,请使用其他浏览器', '提示');
    } else if (error.MANDATORY_UNSATISFIED_ERROR) {
        jAlert('指定的媒体类型未接收到媒体流', '提示');
    } else {
        jAlert('相关硬件正在被其他程序使用中', '提示');
    }
};
//媒体请求的参数,video:true表示调用摄像头,audio:true表示调用麦克风
option = {video:true}
//兼容各个浏览器
navigator.getUserMedia = (navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia);

var userAgent = navigator.userAgent,
    msgTitle = '提示',
    notSupport = '对不起,您的浏览器不支持摄像头/麦克风的API,请使用其他浏览器';
try {
    if (navigator.getUserMedia) {
        if (userAgent.indexOf('MQQBrowser') > -1) {
            errCallBack({
                NOT_SUPPORTED_ERROR: 1
            });
            return false;
        }
        navigator.getUserMedia(option, sucCallBack, errCallBack);
    } else {
        /*
        if (userAgent.indexOf("Safari") > -1 && userAgent.indexOf("Oupeng") == -1 && userAgent.indexOf("360 Aphone") == -1) {
            //由于没有Safari浏览器不能对已有方法进行测试,有需要可以自行度之
        } //判断是否Safari浏览器
        */
        errCallBack({
            NOT_SUPPORTED_ERROR: 1
        });
        return false;
    }
} catch (err) {
    errCallBack({
        NOT_SUPPORTED_ERROR: 1
    });
    return false;
}

兼容性测试:

IE系列:摄像头/麦克风都不支持

Edge:摄像头/麦克风都支持,不过有个小BUG,请求摄像头的时候会提示“是否允许xxx使用你的麦克风”,影响不大。

Chrome系列(包括支持极速模式的国产山寨浏览器):摄像头基本都支持,语音功能不一定都支持(和chromium的版本以及电脑的声卡驱动有关系),今天更新了qq浏览器,貌似最新的chromium(47.0.2526.80)不能直接访问媒体API了

getUserMedia() no longer works on insecure origins. To use this feature, you should consider switching your application to a secure origin, such as HTTPS. See https://goo.gl/rStTGz for more details.

解决办法:

用HTTPS或者在chrome运行参数里加--able-web-security

Firefox(45.0.2):摄像头/麦克风都支持

* 硬件设备同时仅能被一个浏览器访问,不过同一个浏览器可以打开多个标签页来多次调用
* 文章最后会给出的项目里面有几个页面是用来测试媒体API的,自己去寻找吧

画面捕捉


从上面一步我们已经取得了媒体流(sucCallBack中的stream),这一步就要对它进行处理。

首先,将其作为video控件的视频源

sucCallBack = function(stream) { 
    var video = document.getElementById("myVideo");
    video.src = window.URL.createObjectURL(stream);
    video.play();
}

接着,将video中的画面绘制到canvas上

var video = document.getElementById("myVideo");
var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");
video.addEventListener("play", drawCanvas);
function drawCanvas() {
    if (video.paused || video.ended) {
        return;
     }
    context.drawImage(video, 0, 0, 640, 360);//将video当前画面绘制到画布上
    setTimeout(drawCanvas, 30);
}

图片传输


从canvas获得图片

var canvas = document.getElementById("canvas");
var img = new Image();
//形如"data:image/png;base64,iVBORw0KG..."逗号前内容为文件类型,格式,编码类型,逗号之后为base64编码内容
var url = canvas.toDataURL("image/png");
img.src = url;
document.body.appendChild(img);

通过websocket对图片进行传输,java代码和上文中基本差不多,就多了一个@OnMessage注释的方法,

@OnMessage(maxMessageSize = 10000000)
public void OnMessage(ByteBuffer message) {
    //TODO
}

注意maxMessageSize参数,由于一张图片比较大,如果该值设置过小的话,服务端无法接收而导致连接直接断开

定时发送数据

function sendFrame(){
    //TODO
    setTimeout(sendFrame, 300)
}
//或
setTimeout(function() {
    requestAnimationFrame(sendFrame)
}, 300)

requestAnimationFrame的区别在于如果此页面不是浏览器当前窗口的当前标签(即此标签页被挂起),那么其中的回调函数(sendFrame)会被挂起,直到此标签页被激活后再执行,而只使用setTimeout在标签页被挂起的时候还会继续执行。所以requestAnimationFrame可以用来实现 挂起页面达到暂停视频通讯 的效果

客户端有两种发送方式,一种是发送Blob对象

function getWebSocket(host) {
    var socket;
    if ('WebSocket' in window) {
        socket = new WebSocket(host)
    } else if ('MozWebSocket' in window) {
        socket = new MozWebSocket(host)
    }
    return socket
};
// 将base64编码的二进制数据转为Blob对象
function dataURLtoBlob(dataurl) {
    var arr = dataurl.split(','),
        mime = arr[0].match(/:(.*?);/)[1],
        bstr = atob(arr[1]),
        n = bstr.length,
        u8arr = new Uint8Array(n);
    while (n--) {
        u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], {
        type: mime
    });
};
function sendFrame(){
    // socket = getWebSocket ("ws://" + window.location.host + "/websocket/chat")
    socket.send(dataURLtoBlob(canvas.toDataURL("image/png")));//使用已建立的socket对象发送数据
    setTimeout(sendFrame, 300);
}

另一种是先用对象包装,再转为字符串发送

msg = {
    type: 3,
    msg: canvas.toDataURL("image/png").substring("data:image/png;base64,".length),// 截掉内容头
};
// 对数据进行base64解码,减少要发送的数据量
msg.msg = window.atob(msg.msg);
// 序列化成json串
socket.send(JSON.stringify(msg))

说说这里遇到的坑吧

  1. 在第一种方式中,先是获取图片的Blob文件,但为了让其他用户知道是谁发送的,需要发送发送者的username(或者id),本来试过先用string发送username,再紧跟着发送一个Blob,虽然并未出错但是感觉数据多了会错乱,而且接收的时候处理起来比较复杂,于是就想着把username并入Blob。不过貌似没有string和Blob直接合并的方法,于是我先把字符串生成一个Blob("text/plain"),然后把两个Blob合并起来
    new Blob([textBlob, imageBlob])

  2. 由于第一种方式发送的时候会生成一个Blob对象,再加上是通过setTimeout这种定时递归得到方式发送的,内存占用(在chrome任务管理器中查看)蹭蹭蹭暴涨,没多久就占用几百兆内存,虽然在几处地方手动置为null来释放引用有点改善,但效果也不是很明显,个人感觉是频率太高(100ms间隔即1秒10帧左右的图片)导致GC来不及释放。相较之下,第二种方式虽然内存也会涨,不过基本会稳定在50M左右。这个问题以后再细究,如果哪位看官有想法欢迎评论指教
    在项目代码中设置videoClient.sendType = 1;切换到第一种方式

  3. 正常两个人(窗口)视频通讯的时候,第一种方式毫无压力,不过再增加用户之后就爆了,每个窗口都在不断的断开重连,从接收到的数据看应该是数据包间的内容错乱了。因为我一个Blob是用户名和图片数据组合在一起的,形如zhangsaniVBORw0KG.....,其中'zhangsan'为用户名,后面部分为图片数据,数据错乱了之后就变成了#%#sanVBORw0KG.....之类的,这样正常解析就会出错导致连接断开。所以,人多了就只能采用第二种方式发送了。

图片接收和绘制


对应上一步的两种方式,接收图片也有两种方式

在onmessage方法中先这么处理

if (typeof(message.data) == "string") {
    //TODO
} else if (message.data instanceof Blob) {
    //TODO
}

第一种接收方式(Blob)

function renderFrame(_host, blob) {
    readBlobAsDataURL(_host, function(host) {
        _host = null;
        host = DataURLtoString(host);
        var canvas = document.getElementById("canvas"),
            url = URL.createObjectURL(blob),
            img = new Image();
        img.onload = function() {
            window.URL.revokeObjectURL(url);
            var context = canvas.getContext("2d");
            context.drawImage(img, 0, 0, canvas.width, canvas.height);
            img = null;
            blob = null;
        };
        img.src = url;
    })
};
// 通过FileReader来读取Blob中封装的字符串内容
function readBlobAsDataURL(blob, callback) {
    var a = new FileReader();
    a.onload = function(e) {
        callback(e.target.result);
    };
    a.readAsDataURL(blob);
}
// 进行base64编码解码
function DataURLtoString(dataurl) {
    return window.atob(dataurl.substring("data:text/plain;base64,".length))
}
var offset = 14;//我采用14位长度的时间戳字符串作为username
renderFrame(new Blob([msg.data.slice(0, offset)], {
    type: "text/plain"
}), new Blob([msg.data.slice(offset)], {
    type: "image/png"
}))

第二种方式

renderFrame2 = function(host, data) {
    var canvas = document.getElementById("canvas"),
        img = new Image();
    img.onload = function() {
        window.URL.revokeObjectURL(url);
        var context = canvas.getContext("2d");
        context.drawImage(img, 0, 0, canvas.width, canvas.height);
        img = null;
    };
    // 对数据重新进行base64编码后作为图片的源
    img.src = "data:image/png;base64," + window.btoa(data)
};
// 将socket.onmessage方法中接收到的数据转为msg对象
var msg = JSON.parse(message.data);
renderFrame2(msg.host, msg.msg)

至此视频部分的内容都结束了,接下来讲下音频部分的差异

调用麦克风


同摄像头调用的第一步,设置 option = {audio:true} 得到麦克风的流,但是不能向摄像头一样直接给video控件的src赋值就好了,还需要下面一系列的操作。

1. 创建“录音机对象”

audioContext = window.AudioContext || window.webkitAudioContext;
context = new audioContext();
config = {
    inputSampleRate: context.sampleRate,//输入采样率,取决于平台
    inputSampleBits: 16,//输入采样数位 8, 16
    outputSampleRate: 44100 / 6,//输出采样率
    oututSampleBits: 8,//输出采样数位 8, 16
    channelCount: 2,//声道数
    cycle: 500,//更新周期,单位ms
    volume: _config.volume || 1 //音量
};
var bufferSize = 4096;//缓存大小
//创建“录音机对象”
recorder = context.createScriptProcessor(bufferSize, config.channelCount, config.channelCount); // 第二个和第三个参数指的是输入和输出的声道数
buffer = [];//音频数据缓冲区
bufferLength = 0;//音频数据缓冲区长度

2. 将音频输入和“录音机对象”关联

//通过音频流创建输入音频对象
audioInput = context.createMediaStreamSource(stream);//stream即getUserMedia成功后得到的流
//设置录音机录音处理事件,每次缓存(上一步中)满了执行回调函数,
recorder.onaudioprocess = function(e) {
    var inputbuffer = e.inputBuffer,
        channelCount = inputbuffer.numberOfChannels,
        length = inputbuffer.length;
    channel = new Float32Array(channelCount * length);
    for (var i = 0; i < length; i++) {
        for (var j = 0; j < channelCount; j++) {
            channel[i * channelCount + j] = inputbuffer.getChannelData(j)[i];
        }
    }
    buffer.push(channel);//缓存数据存入音频缓冲区
    bufferLength += channel.length;
};
// 创建 '音量对象',作为 '音频输入' 和 '录音机对象' 连接的桥梁
volume = context.createGain();
audioInput.connect(volume);
volume.connect(recorder);
// 当然也可以不通过 '音量对象' 直连 '录音机',但不能同时使用两种方式
// audioInput.connect(this.recorder);
recorder.connect(context.destination);//context.destination为音频输出
//连接完就开始执行onaudioprocess方法
//recorder.disconnect()可以停止“录音”
updateSource(callback);

3. 获取“音频流”

麦克风的输入都已经连接至“录音机对象”,“录音机对象”再将数据不断存入缓冲区,所以要得到稳定的“音频流”(不是真正意义上的流)可以以一定时间间隔(1秒)提取以此缓冲区的数据转为一秒时长的音频文件,将之通过audio控件播放即可。注意,这样处理会导致声音有1秒的延迟,可以减少这个时间间隔接近“实时”的效果。

第一部分:数据压缩合并

function compress() { //合并压缩
    //合并
    var buffer = this.buffer,
        bufferLength = this.bufferLength;
    this.buffer = []; //将缓冲区清空
    this.bufferLength = 0;
    var data = new Float32Array(bufferLength);
    for (var i = 0, offset = 0; i < buffer.length; i++) {
        data.set(buffer[i], offset);
        offset += buffer[i].length;
    }
    //根据采样频率进行压缩
    var config = this.config,
        compression = parseInt(config.inputSampleRate / config.outputSampleRate),
        //计算压缩率
        length = parseInt(data.length / compression),
        result = new Float32Array(length);
    index = 0;
    while (index < length) {
        result[index] = data[index++ * compression];
    }
    return result;//合并压缩后的数据
}

第二部分:将上一步的数据编码成WAV格式的文件

function encodeWAV(bytes) {
    var config = this.config,
        sampleRate = Math.min(config.inputSampleRate, config.outputSampleRate),
        sampleBits = Math.min(config.inputSampleBits, config.oututSampleBits),
        dataLength = bytes.length * (sampleBits / 8),
        buffer = new ArrayBuffer(44 + dataLength),
        view = new DataView(buffer),
        channelCount = config.channelCount,
        offset = 0,
        volume = config.volume;

    writeUTFBytes = function(str) {
        for (var i = 0; i < str.length; i++) {
            view.setUint8(offset + i, str.charCodeAt(i));
        }
    };
    // 资源交换文件标识符 
    writeUTFBytes('RIFF');
    offset += 4;
    // 下个地址开始到文件尾总字节数,即文件大小-8 
    view.setUint32(offset, 44 + dataLength, true);
    offset += 4;
    // WAV文件标志
    writeUTFBytes('WAVE');
    offset += 4;
    // 波形格式标志 
    writeUTFBytes('fmt ');
    offset += 4;
    // 过滤字节,一般为 0x10 = 16 
    view.setUint32(offset, 16, true);
    offset += 4;
    // 格式类别 (PCM形式采样数据) 
    view.setUint16(offset, 1, true);
    offset += 2;
    // 通道数 
    view.setUint16(offset, channelCount, true);
    offset += 2;
    // 采样率,每秒样本数,表示每个通道的播放速度 
    view.setUint32(offset, sampleRate, true);
    offset += 4;
    // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8 
    view.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true);
    offset += 4;
    // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8 
    view.setUint16(offset, channelCount * (sampleBits / 8), true);
    offset += 2;
    // 每样本数据位数 
    view.setUint16(offset, sampleBits, true);
    offset += 2;
    // 数据标识符 
    writeUTFBytes('data');
    offset += 4;
    // 采样数据总数,即数据总大小-44 
    view.setUint32(offset, dataLength, true);
    offset += 4;
    // 写入采样数据 
    if (sampleBits === 8) {
        for (var i = 0; i < bytes.length; i++, offset++) {
            var val = bytes[i] * (0x7FFF * volume);
            val = parseInt(255 / (65535 / (val + 32768)));
            view.setInt8(offset, val, true);
        }
    } else if (sampleBits === 16) {
        for (var i = 0; i < bytes.length; i++, offset += 2) {
            var val = bytes[i] * (0x7FFF * volume);
            view.setInt16(offset, val, true);
        }
    }
    return new Blob([view], {
        type: 'audio/wav'
    });
}

第三部:将上步得到的音频文件作为audio控件的声音源

function updateSource(callback) {
    var blob = encodeWAV(this.compress());
    var audio= document.getElementById("audio");
    //对上一秒播放的音频源进行释放
    var url = audio.src;
    url && window.URL.revokeObjectURL(url);
    if (blob.size > 44) { //size为44的时候,数据部分为空
        audio.src = window.URL.createObjectURL(blob);
    }
    setTimeout(function() {
        updateSource(callback);
    }, config.cycle);
}

麦克风的捕捉比起摄像头真的是麻烦很多,不能直接获取音频流,要进行数据压缩(采样频率,声道数,样本位数),还要转为wav编码(其他音频格式的编码还没去查过)。不过得到音频的Blob文件之后就可以和“视频传输"一样通过Blob或string两种数据类型收发。

总结


  • 对于上面提到的“内存泄漏”的情况还有待去研究。
  • 音频传输的话,本来音频文件就小,再加上压缩处理,传输的数据很小,但是图片传输就不一样了,我的项目中传输的图片用的100*100规格,我测试了下需要500k/s左右的上传和下载速度,在本机或者局域网中,再大点的尺寸或者用户再多点都毫无压力,但是部署到我的阿里云老爷机(1G内存,单核CPU)上,网速(上传速度最快300k/s)和后台处理性能两方面原因导致了延迟有一两分钟(>_<|||),所以后续还要继续研究图片压缩的问题。
  • 终于吃力地把语音视频功能实现了,感觉还是得找机会研究下WebRTC,享受下别人造好的轮子。

参考文章:


彩蛋


整个项目的代码已经上传至github

项目中的几个页面:
  • main.html:聊天室入口页面
  • cameraTest.html:测试摄像头功能
  • microphoneTest*.html:测试麦克风功能

本文中的代码都是从项目中抽取出来的,并不完整,详细用法还是看项目代码吧。另外,摄像头和麦克风调用我分别封装成Camera类和MicroPhone类,以后简单调用即可,调用方式参见测试页面。

小小的吐槽


简书的风格看着很舒服,写文章排版也很省事,就是滚动条样式没设置,这么丑的滚动条太不和谐了。

相关文章

网友评论

  • 黎明尚佳:真的非常感谢分享!
    这边下载源码运行tomcat那个项目还是有点问题,我这边服务器用的是Myeclipse tomcat8.5,但是总是显示,websocket断开
    谷歌开发者工具这边显示的是这样的报错
    WSClient.js:17 WebSocket connection to 'ws://localhost:8080/websocket/chat' failed: Error during WebSocket handshake: Unexpected response code: 404
    请问有什么解决方法么?
    黎明尚佳:@anyesu 好了!我后来使用IDEA导入项目时按照你说的方法可以完美解决!!谢谢分享!!
    黎明尚佳:@anyesu 好的,那这边我把websocket-api和sevlet-api移出Refernced Librairies之后编译通不过,这个应该怎么办?
    anyesu:@天域流水 参考 https://www.jianshu.com/p/62790429acef#comment-9471595 。应该是重复引入了websocket-api,这个包只在编译时需要,不能打包到项目里
  • 90ba6c6018e4:此案例基于node.js的实现版本版主是否有研究呢?
    anyesu:@Miss不温柔 发文件?文章最后有给源码,nodejs版本的先跑跑看。发文件和发消息类似的,前端录制+数据封装+发送,后端转发(也可以加上业务处理和存储)
    90ba6c6018e4:@anyesu 就是我使用node.js+express+websocket想实现发送音视频的功能应该怎么实现?本人是刚入门的咸鱼一小只,虚心求教:yum:
    anyesu:@Miss不温柔 能具体点吗?
  • e74fd48832a0:您好大兄弟,我想问下你的语音聊天是长时间还是 那种长按发送语音。我想实现长安发送语音能给点思路吗,我卡在语音发送了。我想知道三点:1.客户端要怎么处理发送给服务端
    2.服务端用什么去接收,然后怎么处理
    3.客户端接受到后怎么处理才可以播放。
    项目急用,还是实习生。用的是原生websocket-api 没有用spring的websocket
    anyesu:@汇_ 1.文中给的demo用的就是原生api,没用spring。
    2.要实现微信那种效果分解为两个步骤,第一步点击按钮开始录音,第二步释放按钮结束录音并发送数据。
    3.通讯用websocket就行了,当然用http请求也行,至于后台可以存数据库,发消息队列或直接转发给其他websocket客户端
    e74fd48832a0:方便的话 私聊给个联系方式我可以吗。谢谢博主
  • 王先森QAQ:兄弟,我又来了,问一下,发送文件使用ws。java后端的实现与音频视频的实现一样吗?在你的demo基础上,只需要修改js吗
    anyesu:@wangdyqxx 目前服务端只是做简单的转发,你要这么做也是可以的。不过我建议继承AbstractWSController实现一个新的控制器,我的思路大概是这样的:1.发送端切片发送文件(文件比较大的情况)给服务端;2.服务端收到数据缓存到服务器上;3.服务端收到完整的文件后发送一条通知(文件下载路径)给接收端;4.接收端按需下载文件
  • 13793d7e6890:朋友,如何实现在直播聊天时,同时可以输入文字聊天,我发现开启视频时如何在输入文字聊天会出现闪屏问题,您知道如何解决吗?
    anyesu:文字、语音、视频是三个独立的websocket连接。开视频数据量有点大,可能和网络和处理速度都有关系
  • c7f46155467a:你的nodejs版本的用不了,并没有解决chrome对于非安全连接的禁止视频问题
    anyesu:ws模块要用这个版本的,ws@1.1.0
  • 6790930242e5:大兄弟,问个问题,看你挺熟悉这个websocket,咨询问题,多个聊天室
    6790930242e5:QQ有没
  • cb69ce4fe477:源码有木有 版主 需要怎么配置 希望能指导下
    anyesu:@慧空居士 eclipse中import项目后,只需在build path的Libraries中添加tomcat/lib目录下的websocket-api.jar。之后在server中照常添加项目,path要设置为 "/"
    13793d7e6890:@anyesu java 版的导入到eclipse 运行在tomcat上打开main.html 后报错Info: WebSocket,这个怎么搞
    anyesu:@爱上一 源码在https://github.com/anyesu/websocket,nodejs版的nodejs安装好就能直接跑了,java版的导入到ide里就能跑了,端口配置好就行了

本文标题:基于WebSocket的在线聊天室(二)

本文链接:https://www.haomeiwen.com/subject/coxdrttx.html