随着 5G 技术的推广, 可以预见在不久的将来网速将得到极大提升, 实时音视频互动这类对网络传输质量要求较高的应用将是最直接的受益者. 而且伴随着 webrtc 技术的成熟, 该领域可能将成为下一个技术热点, 但是传统的 webrtc 应用开发存在一定的复杂性, 本文将介绍如何利用 peerjs 这一开源框架来简化 webrtc 开发.
一, webrtc 回顾
WebRTC https://webrtc.org/ (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 的服务端 (即信令服务器) 很简单, 只需要下面这段 Node.JS 代码即可:
- 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>";
- }
- Windows.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 个页面上, 再还原出来就可以了.
核心代码:
- Windows.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 = Windows.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
参考文章:
- https://peerjs.com/docs.html#api
- https://www.cnblogs.com/yjmyzz/p/how-to-install-coturn-on-ubuntu.html
- https://webrtc.github.io/samples/
来源: https://www.cnblogs.com/yjmyzz/p/peerjs-tutorial.html