随着5G技术的推广,可以预见在不久的将来网速将得到极大提升,实时音视频互动这类对网络传输质量要求较高的应用将是最直接的受益者。而且伴随着webrtc技术的成熟,该领域可能将成为下一个技术热点,但是传统的webrtc应用开发存在一定的复杂性,本文将介绍如何利用peerjs这一开源框架来简化webrtc开发。
一、webrtc回顾
WebRTC(Web Real-Time Communication)即:网页即时通信。 简单点讲,它可以实现浏览器网页与网页之间的音视频实时通信(或传输其它任何数据),目前主流浏览器都支持该API,WebRTC现在已经纳入W3C标准。
1.1 媒体协商
通信的主要目的之一是彼此交换信息。打个比方:“张三”跟“李四”打了一通电话(语音通讯),整个过程中“张三”说的话被“李四”听到了,“李四”说的话被“张三”听到了,双方交换了语音信息。类似的,一个浏览器要与另一个浏览器发起实时音视频通信,需要交换哪些信息呢? 除了音视频信息外,至少还有2个关键信息要交换:媒体信息和网络信息。
如上图:通常某个浏览器所在的电脑,都会连接具体的多媒体设备(比如:麦克风、摄像头)。如果A电脑上的摄像头只支持VP8,H264格式,而另一台电脑上的摄像头只支持H264、MPEG-4格式,它俩要能正常播放彼此的视频,肯定会选择双方都能识别的H264格式。这就好比:2个不同国籍的人要相互交流,A会说英语、中文;而B只会说英语,毫无悬念,他俩肯定会用双方都能听懂的“英语”来沟通。
网络情况也是类似的,二个浏览器所在的电脑可能在不同的网络环境中,假如A机器具备公网+192内网网段,而B机器只有192+198内网网段,二台电脑要能相互连接,很容易想到,使用双方都能连通的公共192内网网段通信最为方便。
在webrtc中,有一个特定的协议用于描述媒体信息、网络信息和其它一些关键信息,称为SDP(Session Description Protocol-会话描述协议)。而上述介绍的交换媒体信息、网络信息的过程,也被称为媒体协商,即:交换SDP.
这是一张媒体协商过程的经典图例, Amy要跟Bob通信, 要先发一个Offer(即: 描述Amy自己会话的SDP), Bob收到后,做出Answer回应(即:描述Bob自己会话的SDP), 双方完成SDP交换后, 根据前面的分析,取出二份SDP的交集, 即完成了媒体协商.
1.2 主要处理过程
这是mozilla开发者官网上的一张图, 大致描述了webrtc的处理过程:
- A通过STUN服务器,收集自己的网络信息
- A创建Offer SDP,通过Signal Channel(信令服务器)给到B
- B做出回应生成Answer SDP,通过Signal Channel给到A
- B通过STUN收集自己的网络信息,通过Signal Channel给到A
注:如果A,B之间无法直接穿透(即:无法建立点对点的P2P直连),将通过TURN服务器中转。
二、peerjs介绍
从上面的回顾可以看出,要创建一个真正的webrtc应用还是有些小复杂的,特别是SDP交换(createOffer及createAnswer)、网络候选信息收集(ICE candidate),这些都需要开发人员对webrtc的机制有足够的了解,对webrtc初学者来讲有一定的开发门槛。
而peerjs开源项目简化了webrtc的开发过程,把SDP交换、ICE candidate这些偏底层的细节都做了封装,开发人员只需要关注应用本身就行了。
peerjs的核心对象Peer,它有几个常用方法:
- peer.connect 创建点对点的连接
- peer.call 向另1个peer端发起音视频实时通信
- peer.on 对各种事件的监控回调
- peer.disconnect 断开连接
- peer.reconnect 重新连接
- peer.destroy 销毁对象
另外还有二个重要对象DataConnection、MediaConnection,其中:
- DataConnection用于收发数据(对应于webrtc中的DataChannel),它的所有方法中有一个重要的send方法,用于向另一个peer端发送数据;
- MediaConnection用于处理媒体流,它有一个重要的stream属性,表示关联的媒体流。
更多细节可查阅peerjs的api在线文档 (注:peerjs的所有api只有一页,估计15分钟左右就全部看一圈)
peerjs的服务端(即信令服务器)很简单,只需要下面这段nodejs代码即可:
var fs = require('fs'); var PeerServer = require('peer').PeerServer; var options = { //webrtc要求SSL安全传输,所以要设置证书 key: fs.readFileSync('key/server.key'), cert: fs.readFileSync('key/server.crt') } var server = PeerServer({ port: 9000, ssl: options, path:"/" });
本地启用成功后,浏览https://localhost:9000 可以看到
三、实战练习
下面选几个常用的场景,利用peerjs实战一番(文末最后有示例源码链接) - 注:建议使用chrome谷歌浏览器运行下面的示例。
3.1 文本聊天
运行效果如下(假设有Jack、Rose二个用户在各自的浏览器页面上相互聊天)
主要流程:
- Jack和Rose先连接到PeerJs服务器
- Rose指定要建立p2p连接的对方名称(即:Jack),然后发送消息
- Jack在自己的页面上,可以实时收到Rose发送过来的文字,并回复
客户端的js代码如下:(不到100行)
var txtSelfId = document.querySelector("input#txtSelfId"); var txtTargetId = document.querySelector("input#txtTargetId"); var txtMsg = document.querySelector("input#txtMsg"); var tdBox = document.querySelector("td#tdBox"); var btnRegister = document.querySelector("button#btnRegister"); var btnSend = document.querySelector("button#btnSend"); let peer = null; let conn = null; //peer连接时,id不允许有中文,所以转换成hashcode数字 hashCode = function (str) { var hash = 0; if (str.length == 0) return hash; for (i = 0; i < str.length; i++) { char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return hash; } sendMessage = function (message) { conn.send(JSON.stringify(message)); console.log(message); tdBox.innerHTML = tdBox.innerHTML += "<div class='align_left'>" + message.from + " : " + message.body + "</div>"; } window.onload = function () { //peerserver的连接选项(debug:3表示打开调试,将在浏览器的console输出详细日志) let connOption = { host: 'localhost', port: 9000, path: '/', debug: 3 }; //register处理 btnRegister.onclick = function () { if (!peer) { if (txtSelfId.value.length == 0) { alert("please input your name"); txtSelfId.focus(); return; } //创建peer实例 peer = new Peer(hashCode(txtSelfId.value), connOption); //register成功的回调 peer.on('open', function (id) { tdBox.innerHTML = tdBox.innerHTML += "<div class='align_right'>system : register success " + id + "</div>"; }); peer.on('connection', (conn) => { //收到对方消息的回调 conn.on('data', (data) => { var msg = JSON.parse(data); tdBox.innerHTML = tdBox.innerHTML += "<div class='align_right'>" + msg.from + " : " + msg.body + "</div>"; if (txtTargetId.value.length == 0) { txtTargetId.value = msg.from; } }); }); } } //发送消息处理 btnSend.onclick = function () { //消息体 var message = { "from": txtSelfId.value, "to": txtTargetId.value, "body": txtMsg.value }; if (!conn) { if (txtTargetId.value.length == 0) { alert("please input target name"); txtTargetId.focus(); return; } if (txtMsg.value.length == 0) { alert("please input message"); txtMsg.focus(); return; } //创建到对方的连接 conn = peer.connect(hashCode(txtTargetId.value)); conn.on('open', () => { //首次发送消息 sendMessage(message); }); } //发送消息 if (conn.open) { sendMessage(message); } } }
有几点说明一下:
- 89行首次发送消息,这时conn还没有准备好(open状态为false),此时send不会成功,参考下面的调试截图
要在conn.on('open',{...})事件回调里完成首次消息的发送,这时候open状态是true,send才能成功
- 从浏览器的console控制台日志可以清楚的看到peerjs,已经把createOffer、createAnswer,以及ICE candidate这些细节都内部消化掉了。
这是Rose端的日志
这是Jack端的日志
从日志可以看到,刚开始Rose→Create Offer->Jack,然后Jack→Create Answer→ Rose,Rose→Jack的连接建立好了; Jack收到第一句话"how are you"后,回复"fine, thank you"时, 过程反过来 Jack → Create Offer → Rose,然后Rose → Create Answer → Jack, Jack→Rose的连接也建好了,后面再聊天,就可以直接相互send文字消息了。另外ICE candidate 、set localDescription、set remoteDescription这些peerjs也一并帮我们做掉了,对普通开发人员而言,不再需要关心这些细节。强烈建议大家将这2份日志与“第1部分Amy与Bob交换SDP"那张图对照体会一下。
另外,虽然这个示例是在本机运行的,但是原理跟2台不同的电脑之间(或不同的网络环境,比如Rose在美国、Jack在中国)端对端通信是完全相同的,只不过如果二端的浏览器如果不在一个网段,需要配置stun或turn服务器,参考下面的配置:
var peer = new Peer({ config: {'iceServers': [ { url: 'stun:stun.l.google.com:19302' }, { url: 'turn:homeo@turn.bistri.com:80', credential: 'homeo' } ]} /* Sample servers, please use appropriate ones */ });
注:关于stun或turn的细节,建议阅读本文最后的参考文章。
3.2 视频通话
运行效果如下(视频转成gif文件尺寸太大,这里就只截了几张运行中的关键图片)
注:为了模拟2个人分别在不同的页面实时视频通话, 我在本机插了2个USB摄像头(1个横着放,1个竖着放),打开2个浏览器页面并启用摄像头后,1个页面选择摄像头1,另1个页面选择摄像头2(通过下图中摄像头下拉框切换)。
如上图,在1个页面上输入”张三“并点击register,同时允许使用摄像头,然后在另1个页面输入”李四“,也点击register,并允许使用摄像头,然后把摄像头切换到另1个,这样2个页面看到的本地视频就不一样了(相当于2个端各自的视频流)。然后在"李四"的页面上,target name这里输入"张三",并点击call按钮发起视频通话,此时"张三"的页面上会马上收到邀请确认(如下图)
”张三“选择Accept同意后,二端就相互建立连接,开始实时视频通话。
注:首次运行时,浏览器会弹出类似下图的提示框询问是否同意启用摄像头/麦克风(出于安全隐私考虑),如果手一抖选择了不允许,就算刷新页面,也不会再弹出提示框。
对于chrome浏览器,可在"设置→ 高级→ 内容设置→ 摄像头/麦克风" 手动重新设置。
从上面这一系列的运行截图可以看到,“李四”与“张三”在发起视频通话过程中涉及到一些交互(即:“李四”发起,“张三”可以选择同意或拒绝),这些交互的指令(也称为"信令")可以通过上一个场景"文字聊天"中的聊天消息Message作为载体,简单起见,message可以用一个json格式来表示:
{ "from": "李四", "to": "张三", "action": "call" }
action代表具体的指令动作类型,在这个场景中有3个:call(发起视频通话),accept(对方同意视频通话),accept-ok(发起方通知对方接收媒体流)-注:指令类型的名字可以随便起,不一定非得叫call/accept/accept-ok,容易理解即可。
关键的几处代码如下:call按钮的处理逻辑
btnCall.onclick = function () { if (txtTargetId.value.length == 0) { alert("please input target name"); txtTargetId.focus(); return; } sendMessage(txtSelfId.value, txtTargetId.value, "call"); }
其中sendMessage即发送消息
function sendMessage(from, to, action) { var message = { "from": from, "to": to, "action": action }; if (!localConn) { localConn = peer.connect(hashCode(to)); localConn.on('open', () => { localConn.send(JSON.stringify(message)); console.log(message); }); } if (localConn.open){ localConn.send(JSON.stringify(message)); console.log(message); } }
register按钮处理逻辑:
//register处理 btnRegister.onclick = function () { if (!peer) { if (txtSelfId.value.length == 0) { alert("please input your name"); txtSelfId.focus(); return; } peer = new Peer(hashCode(txtSelfId.value), connOption); peer.on('open', function (id) { console.log("register success. " + id); }); peer.on('call', function (call) { call.answer(localStream); }); peer.on('connection', (conn) => { conn.on('data', (data) => { var msg = JSON.parse(data); console.log(msg); //“接收方“收到邀请时,弹出询问对话框 if (msg.action === "call") { lblFrom.innerText = msg.from; txtTargetId.value = msg.from; $("#dialog-confirm").dialog({ resizable: false, height: "auto", 400, modal: true, buttons: { "Accept": function () { $(this).dialog("close"); sendMessage(msg.to, msg.from, "accept"); }, Cancel: function () { $(this).dialog("close"); } } }); } //“发起方“发起视频call,并绑定媒体流 if (msg.action === "accept") { console.log("accept call => " + JSON.stringify(msg)); var call = peer.call(hashCode(msg.from), localStream); call.on('stream', function (stream) { console.log('received remote stream'); remoteVideo.srcObject = stream; sendMessage(msg.to, msg.from, "accept-ok"); }); } //"接收方"发起视频call,并绑定媒体流 if (msg.action === "accept-ok") { console.log("accept-ok call => " + JSON.stringify(msg)); var call = peer.call(hashCode(msg.from), localStream); call.on('stream', function (stream) { console.log('received remote stream'); remoteVideo.srcObject = stream; }); } }); }); } }
3.3 白板共享
运行效果如下:在2个页面上,仍然模拟2个用户“张三”与“李四”,都register到peerjs服务器后,输入对方的名称,然后点击share,就可以在canvas上共享白板一起涂鸦了。
关键点:send方法不仅仅可以用来发送文字消息,同样也可以发送其它内容,每次在canvas上的的涂鸦,本质上就是调用canvas的api在一系列的坐标点上连续画线。只要把1个页面上画线经过的坐标点发送到另1个页面上,再还原出来就可以了。
核心代码:
window.onload = function () { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { console.log('webrtc is not supported!'); alert("webrtc is not supported!"); return; } let connOption = { host: 'localhost', port: 9000, path: '/', debug: 3 }; context = demoCanvas.getContext('2d'); //canvas鼠标按下的处理 demoCanvas.onmousedown = function (e) { e.preventDefault(); context.strokeStyle='#00f'; context.beginPath(); started = true; buffer.push({ "x": e.offsetX, "y": e.offsetY }); } //canvas鼠标移动的处理 demoCanvas.onmousemove = function (e) { if (started) { context.lineTo(e.offsetX, e.offsetY); context.stroke(); buffer.push({ "x": e.offsetX, "y": e.offsetY }); } } //canvas鼠标抬起的处理 demoCanvas.onmouseup = function (e) { if (started) { started = false; //鼠标抬起时,发送坐标数据 sendData(txtSelfId.value, txtTargetId.value, buffer); buffer = []; } } //register按钮处理 btnRegister.onclick = function () { if (!peer) { if (txtSelfId.value.length == 0) { alert("please input your name"); txtSelfId.focus(); return; } peer = new Peer(hashCode(txtSelfId.value), connOption); peer.on('open', function (id) { console.log("register success. " + id); }); peer.on('connection', (conn) => { conn.on('data', (data) => { let msg = JSON.parse(data); console.log(msg); txtTargetId.value = msg.from; //还原canvas context.strokeStyle='#f00'; context.beginPath(); context.moveTo(msg.data[0].x,msg.data[0].y); for (const pos in msg.data) { context.lineTo(msg.data[pos].x,msg.data[pos].y); } context.stroke(); }); }); } } //share按钮处理 btnShare.onclick = function () { if (txtTargetId.value.length == 0) { alert("please input target name"); txtTargetId.focus(); return; } } start(); }
其中sendData方法如下:
function sendData(from, to, data) { if (from.length == 0 || to.length == 0 || data.length == 0) { return; } let message = { "from": from, "to": to, "data": data }; if (!localConn) { localConn = peer.connect(hashCode(to)); localConn.on('open', () => { localConn.send(JSON.stringify(message)); console.log(message); }); } if (localConn.open) { localConn.send(JSON.stringify(message)); console.log(message); } }
说明一下:这里我们用一个buffer数组来保存每次画线的坐数,然后在画线结束时,再调用sendData发送到对方。
3.4 图片传输
运行效果:在2个浏览器页面上,分别register2个用户,然后在其中1个页面上,输入对方的名字,然后选择一张图片,另1个页面将会收到传过来的图片。
核心仍然利用的是DataConnection的send方法,只不过发送的内容里包含了图片对应的blob对象,核心代码如下:
btnRegister.onclick = function () { if (!peer) { if (txtSelfId.value.length == 0) { alert("please input your name"); txtSelfId.focus(); return; } peer = new Peer(hashCode(txtSelfId.value), connOption); peer.on('open', function (id) { console.log("register success. " + id); lblStatus.innerHTML = "scoket open" }); peer.on('connection', (conn) => { conn.on('data', (data) => { console.log("receive remote data"); lblStatus.innerHTML = "receive data from " + data.from; txtTargetId.value = data.from if (data.filetype.includes('image')) { lblStatus.innerHTML = data.filename + "(" + data.filetype + ") from:" + data.from const bytes = new Uint8Array(data.file) //用base64编码,还原图片 img.src = 'data:image/png;base64,' + encode(bytes) } }); }); } } //文件变化时,触发sendFile inputFile.onchange = function (event) { if (txtTargetId.value.length == 0) { alert("please input target name"); txtTargetId.focus(); return; } const file = event.target.files[0] //构造图片对应的blob对象 const blob = new Blob(event.target.files, { type: file.type }); img.src = window.URL.createObjectURL(file); sendFile(txtSelfId.value, txtTargetId.value, blob, file.name, file.type); }
sendFile方法如下:
function sendFile(from, to, blob, fileName, fileType) { var message = { "from": from, "to": to, "file": blob, "filename": fileName, "filetype": fileType }; if (!localConn) { localConn = peer.connect(hashCode(to)); localConn.on('open', () => { localConn.send(message); console.log('onopen sendfile'); }); } localConn.send(message); console.log('send file'); }
上述示例的源码已上传至github,地址:https://github.com/yjmyzz/peerjs-sample
参考文章: