随着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",
width: 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

参考文章:

利用peerjs轻松玩转webrtc的更多相关文章

  1. 完整版的CAD技巧!3天轻松玩转CAD,零基础也能学会

    最近有很多小伙伴反应,CAD图纸学起来有点小困难,也许你还没能掌握技巧,CAD大神带你3天轻松玩转CAD,零基础也能快速学会. 一.看懂图纸是关键 CAD制图首先得让自己知道要绘制什么,如果心中对图纸 ...

  2. 教你从头到尾利用DQN自动玩flappy bird(全程命令提示,GPU+CPU版)【转】

    转自:http://blog.csdn.net/v_JULY_v/article/details/52810219?locationNum=3&fps=1 目录(?)[-] 教你从头到尾利用D ...

  3. 利用Nginx轻松实现Ajax的跨域请求(前后端分离开发调试必备神技)

    利用Nginx轻松实现浏览器中Ajax的跨域请求(前后端分离开发调试必备神技) 前言 为什么会出现跨域? 造成跨域问题的原因是因为浏览器受到同源策略的限制,也就是说js只能访问和操作自己域下的资源,不 ...

  4. 2021 .NET Conf China 主题分享之-轻松玩转.NET大规模版本升级

    去年.NET Conf China 技术大会上,我给大家分享了主题<轻松玩转.NET大规模版本升级>,今天把具体分享的内容整理成一篇博客,供大家研究参考学习. 一.先说一下技术挑战和业务背 ...

  5. CAP带你轻松玩转Asp.Net Core消息队列

    CAP是什么? CAP是由我们园子里的杨晓东大神开发出来的一套分布式事务的决绝方案,是.Net Core Community中的第一个千星项目(目前已经1656 Star),具有轻量级.易使用.高性能 ...

  6. 干货: 可视化项目实战经验分享,轻松玩转 Bokeh (建议收藏)

    作者 | Will Koehrsen 翻译 | Lemon 译文出品 | Python数据之道 (ID:PyDataRoad) 本文通过一个项目案例,详细的介绍了如何从 Bokeh 基础到构建 Bok ...

  7. CAP带你轻松玩转ASP.NETCore消息队列

    CAP是什么? CAP是由我们园子里的杨晓东大神开发出来的一套分布式事务的决绝方案,是.Net Core Community中的第一个千星项目(目前已经1656 Start),具有轻量级.易使用.高性 ...

  8. 在KCloud上轻松“玩转”Docker

    继CoreOS和Atomic镜像上线之后,刻通云紧跟Docker技术发展脚步,近期又推出了Ubuntu Core镜像,成为国内首家支持Ubuntu Core镜像的基础云服务商,同时也是国内唯一一家同时 ...

  9. 利用 UDF 轻松迁移

    位运算.布尔运算和逐位运算的乐趣 如果您正从支持位运算.布尔运算以及一些函数的数据库迁移到 IBM® DB2® Universal Database™(UDB),那么您也许会对如何在 DB2 中处理这 ...

随机推荐

  1. 一文了解有趣的位运算(&、|、^、~、>>、<<)

    1.位运算概述 从现代计算机中所有的数据二进制的形式存储在设备中.即0.1两种状态,计算机对二进制数据进行的运算(+.-.*./)都是叫位运算,即将符号位共同参与运算的运算. 口说无凭,举一个简单的例 ...

  2. Linux系统安装MySQL——.rpm版

    0.环境 本文操作系统: CentOS 7.2.1511 x86_64MySQL 版本: 5.7.13 1.下载 MySQL 官方的 Yum Repository 从 MySQL 官网选取合适的 My ...

  3. windows 下搭建安装 sass

    众所周知,sass 解析需要有 ruby 的支撑,所以, 第一步:点我下载 ruby: 第二步:安装 ruby: 在安装 ruby 过程中需要注意的一点:把 ruby 执行文件添加到 path,勾选这 ...

  4. HTTP_2_HTTP协议概要

    http协议概要 HTTP 通信对象 通信方式 通信状态 定位资源 节省通信量 超文本传输协议 客户端与服务器端 请求和响应 不保存状态(借助cookie) 请求URI keep-alive/pipe ...

  5. 【iOS】手动抛出异常

    之前没遇到过需要手动抛出异常的时候,这次见到了,记录一下.示例代码如下: /** 如果调用 [[BNRItemStore alloc] init],就提示应该使用 [BNRItemStore shar ...

  6. Ping、Traceroute工作原理

    在工作开发过程中,我们经常会使用到ping和traceroute.在这里,我们将细述其工作原理,让你在会用的基础之上理解其内部工作过程. ICMP应用实例--Ping Ping 是 ICMP 的一个重 ...

  7. 【精选】Markdown 语法汇总

    博客园也能Markdown?美滋滋,Markdown真的是好用QAQ. 本文档按照Markdown各种常用语法类别,以文字描述+演示的方式来展现markdown语法的使用.Markdown 的目标是实 ...

  8. 初识JavaScript和面向对象

    1.javascript基本数据类型: number: 数值类型 string: 字符串类型 boolean: 布尔类型 null: 空类型 undefault:未定义类型 object: 基本数据类 ...

  9. 洛谷 P5367 【模板】康托展开(数论,树状数组)

    题目链接 https://www.luogu.org/problem/P5367 什么是康托展开 百度百科上是这样说的:   “康托展开是一个全排列到一个自然数的双射,常用于构建哈希表时的空间压缩. ...

  10. 【java提高】(17)---Java 位运算符

    Java 位运算符 &.|.^.~.<<.>> 以前学过有关java的运算符,不过开发了这么久也很少用过这个.现在由于开发需要,所以现在再来回顾整理下有关java的运算 ...