- WebRTC 教程
- WebRTC - 首页
- WebRTC - 概览
- WebRTC - 架构
- WebRTC - 环境
- WebRTC - MediaStream APIs
- WebRTC - RTCPeerConnection APIs
- WebRTC - RTCDataChannel APIs
- WebRTC - 发送消息
- WebRTC - 信令
- WebRTC - 浏览器支持
- WebRTC - 移动设备支持
- WebRTC - 视频演示
- WebRTC - 语音演示
- WebRTC - 文本演示
- WebRTC - 安全性
- WebRTC 资源
- WebRTC 快速指南
- WebRTC - 有用资源
- WebRTC - 讨论
WebRTC 快速指南
WebRTC - 概览
随着WebRTC(Web 实时通信)的出现,Web 对于实时通信不再陌生。尽管它于 2011 年 5 月发布,但仍在不断发展,其标准也在不断变化。一组协议由http://tools.ietf.org/wg/rtcweb/的IETF(互联网工程任务组)中的WEB 浏览器实时通信工作组标准化,而新的一组 API 则由http://www.w3.org/2011/04/webrtc/的W3C(万维网联盟)中的Web 实时通信工作组标准化。WebRTC 的出现使得现代 Web 应用程序能够轻松地将音频和视频内容流式传输到数百万人。
基本方案
WebRTC 允许您快速轻松地建立与其他 Web 浏览器的对等连接。要从头开始构建这样的应用程序,您需要大量的框架和库来处理诸如数据丢失、连接断开和 NAT 穿越等典型问题。使用 WebRTC,所有这些都内置在浏览器中,开箱即用。这项技术不需要任何插件或第三方软件。它是开源的,其源代码可在http://www.webrtc.org/.免费获取。
WebRTC API 包括媒体捕获、音频和视频的编码和解码、传输层和会话管理。
媒体捕获
第一步是访问用户设备的摄像头和麦克风。我们检测可用设备的类型,获取用户访问这些设备的权限并管理流。
音频和视频的编码和解码
通过互联网发送音频和视频数据流并非易事。这就是编码和解码发挥作用的地方。这是将视频帧和音频波分解成更小的块并压缩它们的过程。此算法称为编解码器。有大量的不同编解码器,由不同的公司维护,并具有不同的业务目标。WebRTC 内部也有许多编解码器,例如 H.264、iSAC、Opus 和 VP8。当两个浏览器连接在一起时,它们会在两个用户之间选择最优的支持的编解码器。幸运的是,WebRTC 在后台完成了大部分编码工作。
传输层
传输层管理数据包的顺序,处理数据包丢失和连接到其他用户。同样,WebRTC API 使我们能够轻松访问告诉我们连接出现问题的事件。
会话管理
会话管理负责管理、打开和组织连接。这通常称为信令。如果您将音频和视频流传输到用户,那么传输辅助数据也是有意义的。这是通过RTCDataChannel API完成的。
来自 Google、Mozilla、Opera 等公司的工程师已经做了大量工作,将这种实时体验带到 Web 上。
浏览器兼容性
WebRTC 标准是 Web 上发展最快的标准之一,因此并不意味着每个浏览器都同时支持所有相同的特性。要检查您的浏览器是否支持 WebRTC,您可以访问https://caniuse.cn/#feat=rtcpeerconnection。在所有教程中,我建议您对所有示例使用 Chrome。
试用 WebRTC
让我们立即开始使用 WebRTC。
单击“加入”按钮。您应该会看到一个下拉通知。
单击“允许”按钮开始将您的视频和音频流式传输到网页。您应该会看到您自己的视频流。
现在在新的浏览器选项卡中打开您当前所在的 URL,然后单击“加入”。您应该会看到两个视频流 - 一个来自您的第一个客户端,另一个来自第二个客户端。
现在您应该理解为什么 WebRTC 是一款强大的工具。
用例
实时 Web 为各种全新的应用程序打开了大门,包括基于文本的聊天、屏幕和文件共享、游戏、视频聊天等等。除了通信之外,您还可以将 WebRTC 用于其他目的,例如 -
- 实时营销
- 实时广告
- 后台通信(CRM、ERP、SCM、FFM)
- 人力资源管理
- 社交网络
- 约会服务
- 在线医疗咨询
- 金融服务
- 监控
- 多人游戏
- 直播
- 电子学习
总结
现在您应该清楚地了解 WebRTC 这个术语。您还应该了解可以使用 WebRTC 构建哪种类型的应用程序,因为您已经在浏览器中尝试过它。总而言之,WebRTC 是一项非常有用的技术。
WebRTC - 架构
WebRTC 的整体架构具有很高的复杂性。
在这里您可以找到三个不同的层 -
Web 开发人员的 API - 此层包含 Web 开发人员所需的所有 API,包括 RTCPeerConnection、RTCDataChannel 和 MediaStrean 对象。
浏览器制造商的 API
可覆盖的 API,浏览器制造商可以挂钩。
传输组件允许跨各种类型的网络建立连接,而语音和视频引擎是负责将音频和视频流从声卡和摄像头传输到网络的框架。对于 Web 开发人员来说,最重要的部分是 WebRTC API。
如果我们从客户端-服务器端查看 WebRTC 架构,我们可以看到最常用的模型之一是受 SIP(会话发起协议)梯形启发。
在此模型中,两个设备都从不同的服务器运行 Web 应用程序。RTCPeerConnection 对象配置流,以便它们可以彼此对等连接。此信令是通过 HTTP 或 WebSockets 完成的。
但最常用的模型是三角形 -
在此模型中,两个设备都使用相同的 Web 应用程序。它在管理用户连接时为 Web 开发人员提供了更大的灵活性。
WebRTC API
它包含一些主要的 JavaScript 对象 -
- RTCPeerConnection
- MediaStream
- RTCDataChannel
RTCPeerConnection 对象
此对象是 WebRTC API 的主要入口点。它帮助我们连接到对等节点、初始化连接并附加媒体流。它还管理与另一个用户的 UDP 连接。
RTCPeerConnection 对象的主要任务是设置和创建对等连接。我们可以轻松地挂钩连接的关键点,因为此对象在出现时会触发一组事件。这些事件使您可以访问我们连接的配置 -
RTCPeerConnection 是一个简单的 JavaScript 对象,您可以通过以下方式创建它 -
[code] var conn = new RTCPeerConnection(conf); conn.onaddstream = function(stream) { // use stream here }; [/code]
RTCPeerConnection 对象接受一个conf参数,我们将在后面的教程中介绍。当远程用户向其对等连接添加视频或音频流时,会触发onaddstream事件。
MediaStream API
现代浏览器使开发人员能够访问getUserMedia API,也称为MediaStream API。它有三个关键功能点 -
它使开发人员能够访问表示视频和音频流的stream对象
它管理在用户设备上有多个摄像头或麦克风的情况下输入用户设备的选择
它提供了一个安全级别,每次用户想要获取流时都会询问用户
要测试此 API,让我们创建一个简单的 HTML 页面。它将显示一个单独的<video>元素,请求用户使用摄像头的权限,并在页面上显示来自摄像头的实时流。创建一个index.html文件并添加 -
[code] <html> <head> <meta charset = "utf-8"> </head> <body> <video autoplay></video> <script src = "client.js"></script> </body> </html> [/code]
然后添加一个client.js文件 -
[code] //checks if the browser supports WebRTC function hasUserMedia() { navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; return !!navigator.getUserMedia; } if (hasUserMedia()) { navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; //get both video and audio streams from user's camera navigator.getUserMedia({ video: true, audio: true }, function (stream) { var video = document.querySelector('video'); //insert stream into the video tag video.src = window.URL.createObjectURL(stream); }, function (err) {}); }else { alert("Error. WebRTC is not supported!"); } [/code]
现在打开index.html,您应该会看到显示您面部的视频流。
但请注意,因为 WebRTC 仅在服务器端有效。如果您只是用浏览器打开此页面,它将无法工作。您需要将这些文件托管在 Apache 或 Node 服务器上,或者您喜欢的任何服务器上。
RTCDataChannel 对象
除了在对等节点之间发送媒体流外,您还可以使用DataChannel API 发送其他数据。此 API 与 MediaStream API 一样简单。主要工作是从现有的 RTCPeerConnection 对象创建通道 -
[code] var peerConn = new RTCPeerConnection(); //establishing peer connection //... //end of establishing peer connection var dataChannel = peerConnection.createDataChannel("myChannel", dataChannelOptions); // here we can start sending direct messages to another peer [/code]
这就是您需要做的全部,只需两行代码。其他所有操作都在浏览器的内部层完成。您可以在任何对等连接上创建通道,直到RTCPeerConnectionobject关闭。
总结
现在您应该对 WebRTC 架构有了牢固的掌握。我们还介绍了 MediaStream、RTCPeerConnection 和 RTCDataChannel API。WebRTC API 是一个不断变化的目标,因此请始终关注最新的规范。
WebRTC - 环境
在我们开始构建 WebRTC 应用程序之前,我们应该设置我们的编码环境。首先,您应该拥有一个文本编辑器或 IDE,可以在其中编辑 HTML 和 Javascript。由于您正在阅读本教程,因此您可能已经选择了首选的编辑器。对我来说,我使用的是 WebStorm IDE。您可以在https://www.jetbrains.com/webstorm/下载其试用版。我还在使用 Linux Mint 作为我的首选操作系统。
常见 WebRTC 应用程序的另一个要求是拥有一个服务器来托管 HTML 和 Javascript 文件。代码不会仅仅通过双击文件就能工作,因为除非文件由实际服务器提供服务,否则浏览器不允许连接到摄像头和麦克风。这显然是为了安全问题而设计的。
有很多不同的 Web 服务器,但在本教程中,我们将使用带有 node-static 的 Node.js -
访问https://node.org.cn/en/并下载最新的 Node.js 版本。
将其解压缩到 /usr/local/nodejs 目录。
打开 /home/YOUR_USERNAME/.profile 文件,并在末尾添加以下行 - export PATH=$PATH:/usr/local/nodejs/bin
然后您可以重新启动计算机或运行 source /home/YOUR_USERNAME/.profile
现在node命令应该可以通过命令行使用。npm命令也可以使用。NMP 是 Node.js 的包管理器。您可以在https://npmjs.net.cn/了解更多信息。
打开终端并运行sudo npm install -g node-static。这将为 Node.js 安装静态 Web 服务器。
现在导航到包含 HTML 文件的任何目录,并在目录内运行static命令以启动您的 Web 服务器。
您可以导航到https://127.0.0.1:8080以查看您的文件。
还有另一种安装 nodejs 的方法。只需在终端窗口中运行sudo apt-get install nodejs即可。
要测试您的 Node.js 安装,请打开您的终端并运行node命令。键入一些命令以检查其工作方式 -
Node.js 运行 Javascript 文件以及在终端中键入的命令。创建一个index.js文件,内容如下 -
console.log(“Testing Node.js”);
然后运行node index命令。您将看到以下内容 -
在构建我们的信令服务器时,我们将使用 Node.js 的 WebSockets 库。要在终端中安装它,请运行npm install ws。
为了测试我们的信令服务器,我们将使用 wscat 实用程序。要安装它,请在您的终端窗口中运行npm install -g wscat。
序号 | 协议及描述 |
---|---|
1 | WebRTC 协议
WebRTC 应用程序使用 UDP(用户数据报协议)作为传输协议。如今,大多数 Web 应用程序都是使用 TCP(传输控制协议)构建的。 |
2 | 会话描述协议
SDP 是 WebRTC 的重要组成部分。它是一种旨在描述媒体通信会话的协议。 |
3 | 查找路由
为了连接到另一个用户,您应该找到自己网络和另一个用户网络周围的清晰路径。但是,您正在使用的网络可能存在多个级别的访问控制,以避免安全问题。 |
4 | 流控制传输协议
通过对等连接,我们可以快速发送视频和音频数据。在使用 RTCDataChannel 对象时,SCTP 协议如今用于在当前设置的对等连接之上发送 Blob 数据。 |
总结
在本章中,我们介绍了几种支持对等连接的技术,例如 UDP、TCP、STUN、TURN、ICE 和 SCTP。您现在应该对 SDP 的工作原理及其用例有一个初步的了解。
WebRTC - MediaStream APIs
MediaStream API 旨在轻松访问本地摄像头和麦克风的媒体流。getUserMedia() 方法是访问本地输入设备的主要方式。
该 API 有几个关键点:
实时媒体流以视频或音频形式的stream对象表示。
它通过用户权限提供安全级别,在 Web 应用程序开始获取流之前询问用户。
输入设备的选择由 MediaStream API 处理(例如,当有两个摄像头或麦克风连接到设备时)。
每个 MediaStream 对象包含多个 MediaStreamTrack 对象。它们代表来自不同输入设备的视频和音频。
每个 MediaStreamTrack 对象可能包含多个通道(左右音频通道)。这些是由 MediaStream API 定义的最小部分。
有两种方法可以输出 MediaStream 对象。首先,我们可以将输出呈现到视频或音频元素中。其次,我们可以将输出发送到 RTCPeerConnection 对象,然后将其发送到远程对等方。
使用 MediaStream API
让我们创建一个简单的 WebRTC 应用程序。它将在屏幕上显示一个视频元素,询问用户是否允许使用摄像头,并在浏览器中显示实时视频流。创建一个index.html文件:
<!DOCTYPE html> <html lang = "en"> <head> <meta charset = "utf-8" /> </head> <body> <video autoplay></video> <script src = "client.js"></script> </body> </html>
然后创建client.js 文件并添加以下内容:
function hasUserMedia() { //check if the browser supports the WebRTC return !!(navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia); } if (hasUserMedia()) { navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; //enabling video and audio channels navigator.getUserMedia({ video: true, audio: true }, function (stream) { var video = document.querySelector('video'); //inserting our stream to the video tag video.src = window.URL.createObjectURL(stream); }, function (err) {}); } else { alert("WebRTC is not supported"); }
在这里,我们创建了hasUserMedia()函数,该函数检查 WebRTC 是否受支持。然后我们访问getUserMedia函数,其中第二个参数是一个回调函数,它接受来自用户设备的流。然后我们使用window.URL.createObjectURL将我们的流加载到video元素中,该函数创建表示参数中给定对象的 URL。
现在刷新您的页面,点击允许,您应该会在屏幕上看到您的脸。
请记住,使用 Web 服务器运行所有脚本。我们已经在 WebRTC 环境教程中安装了一个。
MediaStream API
属性
MediaStream.active(只读) - 如果 MediaStream 处于活动状态,则返回 true,否则返回 false。
MediaStream.ended(只读,已弃用) - 如果已在对象上触发 ended 事件,则返回 true,这意味着流已完全读取,或者如果尚未到达流的末尾,则返回 false。
MediaStream.id(只读) - 对象的唯一标识符。
MediaStream.label(只读,已弃用) - 由用户代理分配的唯一标识符。
您可以在我的浏览器中看到上述属性的外观:
事件处理程序
MediaStream.onactive - 当 MediaStream 对象变为活动状态时触发的active事件的处理程序。
MediaStream.onaddtrack - 当添加新的MediaStreamTrack对象时触发的addtrack事件的处理程序。
MediaStream.onended(已弃用) - 当流终止时触发的ended事件的处理程序。
MediaStream.oninactive - 当MediaStream对象变为非活动状态时触发的inactive事件的处理程序。
MediaStream.onremovetrack - 当从MediaStreamTrack对象中删除MediaStreamTrack对象时触发的removetrack事件的处理程序。
方法
MediaStream.addTrack() - 将作为参数给出的MediaStreamTrack对象添加到 MediaStream。如果该轨道已添加,则不会发生任何事情。
MediaStream.clone() - 返回具有新 ID 的 MediaStream 对象的克隆。
MediaStream.getAudioTracks() - 返回MediaStream对象中的音频MediaStreamTrack对象的列表。
MediaStream.getTrackById() - 通过 ID 返回轨道。如果参数为空或未找到 ID,则返回 null。如果多个轨道具有相同的 ID,则返回第一个轨道。
MediaStream.getTracks() - 返回MediaStream对象中的所有MediaStreamTrack对象的列表。
MediaStream.getVideoTracks() - 返回MediaStream对象中的视频MediaStreamTrack对象的列表。
MediaStream.removeTrack() - 从 MediaStream 中删除作为参数给出的MediaStreamTrack对象。如果该轨道已删除,则不会发生任何事情。
要测试上述 API,请以下列方式更改index.html:
<!DOCTYPE html> <html lang = "en"> <head> <meta charset = "utf-8" /> </head> <body> <video autoplay></video> <div><button id = "btnGetAudioTracks">getAudioTracks() </button></div> <div><button id = "btnGetTrackById">getTrackById() </button></div> <div><button id = "btnGetTracks">getTracks()</button></div> <div><button id = "btnGetVideoTracks">getVideoTracks() </button></div> <div><button id = "btnRemoveAudioTrack">removeTrack() - audio </button></div> <div><button id = "btnRemoveVideoTrack">removeTrack() - video </button></div> <script src = "client.js"></script> </body> </html>
我们添加了一些按钮来试用几个 MediaStream API。然后我们应该为我们新创建的按钮添加事件处理程序。以这种方式修改client.js文件:
var stream; function hasUserMedia() { //check if the browser supports the WebRTC return !!(navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia); } if (hasUserMedia()) { navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; //enabling video and audio channels navigator.getUserMedia({ video: true, audio: true }, function (s) { stream = s; var video = document.querySelector('video'); //inserting our stream to the video tag video.src = window.URL.createObjectURL(stream); }, function (err) {}); } else { alert("WebRTC is not supported"); } btnGetAudioTracks.addEventListener("click", function(){ console.log("getAudioTracks"); console.log(stream.getAudioTracks()); }); btnGetTrackById.addEventListener("click", function(){ console.log("getTrackById"); console.log(stream.getTrackById(stream.getAudioTracks()[0].id)); }); btnGetTracks.addEventListener("click", function(){ console.log("getTracks()"); console.log(stream.getTracks()); }); btnGetVideoTracks.addEventListener("click", function(){ console.log("getVideoTracks()"); console.log(stream.getVideoTracks()); }); btnRemoveAudioTrack.addEventListener("click", function(){ console.log("removeAudioTrack()"); stream.removeTrack(stream.getAudioTracks()[0]); }); btnRemoveVideoTrack.addEventListener("click", function(){ console.log("removeVideoTrack()"); stream.removeTrack(stream.getVideoTracks()[0]); });
现在刷新您的页面。点击getAudioTracks()按钮,然后点击removeTrack() - audio按钮。音频轨道现在应该被删除。然后对视频轨道执行相同的操作。
如果您点击getTracks()按钮,您应该会看到所有MediaStreamTracks(所有连接的视频和音频输入)。然后点击getTrackById()以获取音频 MediaStreamTrack。
总结
在本章中,我们使用 MediaStream API 创建了一个简单的 WebRTC 应用程序。现在您应该清楚地了解使 WebRTC 工作的各种 MediaStream API。
WebRTC - RTCPeerConnection APIs
RTCPeerConnection API 是每个浏览器之间对等连接的核心。要创建 RTCPeerConnection 对象,只需编写
var pc = RTCPeerConnection(config);
其中config参数至少包含一个键 iceServers。它是一个 URL 对象数组,包含有关 STUN 和 TURN 服务器的信息,在查找 ICE 候选者期间使用。您可以在code.google.com找到可用公共 STUN 服务器的列表。
根据您是呼叫方还是被呼叫方,RTCPeerConnection 对象在连接的每一侧的使用方式略有不同。
这是一个用户流程示例:
注册onicecandidate处理程序。它在接收到 ICE 候选者时将其发送到另一个对等方。
注册onaddstream处理程序。它在从远程对等方接收到视频流后处理视频流的显示。
注册message处理程序。您的信令服务器也应该有一个处理程序来处理从另一个对等方接收到的消息。如果消息包含RTCSessionDescription对象,则应使用setRemoteDescription()方法将其添加到RTCPeerConnection对象中。如果消息包含RTCIceCandidate对象,则应使用addIceCandidate()方法将其添加到RTCPeerConnection对象中。
利用getUserMedia()设置您的本地媒体流,并使用addStream()方法将其添加到RTCPeerConnection对象中。
启动 offer/answer 协商过程。这是呼叫方的流程与被呼叫方的流程唯一不同的步骤。呼叫方使用createOffer()方法启动协商,并注册一个接收RTCSessionDescription对象的回调函数。然后,此回调函数应使用setLocalDescription()将此RTCSessionDescription对象添加到您的RTCPeerConnection对象中。最后,呼叫方应使用信令服务器将此RTCSessionDescription发送到远程对等方。另一方面,被呼叫方注册相同的回调函数,但在createAnswer()方法中。请注意,只有在从呼叫方接收到 offer 后,被呼叫方的流程才会启动。
RTCPeerConnection API
属性
RTCPeerConnection.iceConnectionState(只读) - 返回一个 RTCIceConnectionState 枚举,该枚举描述连接的状态。当此值更改时会触发 iceconnectionstatechange 事件。可能的值:
new - ICE 代理正在等待远程候选者或收集地址。
checking - ICE 代理有远程候选者,但尚未找到连接。
connected - ICE 代理已找到可用的连接,但仍在检查更多远程候选者以获得更好的连接。
completed - ICE 代理已找到可用的连接并停止测试远程候选者。
failed - ICE 代理已检查所有远程候选者,但至少没有一个组件找到匹配项。
disconnected - 至少有一个组件不再活动。
closed - ICE 代理已关闭。
RTCPeerConnection.iceGatheringState(只读) - 返回一个 RTCIceGatheringState 枚举,该枚举描述连接的 ICE 收集状态:
new - 对象刚刚创建。
gathering - ICE 代理正在收集候选者。
complete ICE 代理已完成收集。
RTCPeerConnection.localDescription(只读) - 返回描述本地会话的 RTCSessionDescription。如果尚未设置,它可能为 null。
RTCPeerConnection.peerIdentity(只读) - 返回一个 RTCIdentityAssertion。它由一个 idp(域名)和一个表示远程对等方身份的名称组成。
RTCPeerConnection.remoteDescription(只读) - 返回描述远程会话的 RTCSessionDescription。如果尚未设置,它可能为 null。
RTCPeerConnection.signalingState(只读) - 返回一个 RTCSignalingState 枚举,该枚举描述本地连接的信令状态。此状态描述 SDP offer。当此值更改时会触发 signalingstatechange 事件。可能的值:
stable - 初始状态。没有 SDP offer/answer 交换正在进行。
have-local-offer - 连接的本地端已在本地应用了 SDP offer。
have-remote-offer - 连接的远程端已在本地应用了 SDP offer。
have-local-pranswer - 已应用远程 SDP offer,并且已在本地应用 SDP pranswer。
have-remote-pranswer − 本地 SDP 已应用,并且远程应用了 SDP pranswer。
closed − 连接已关闭。
事件处理程序
序号 | 事件处理程序 & 描述 |
---|---|
1 | RTCPeerConnection.onaddstream 当触发 addstream 事件时调用此处理程序。当远程对等端向此连接添加 MediaStream 时发送此事件。 |
2 | RTCPeerConnection.ondatachannel 当触发 datachannel 事件时调用此处理程序。当向此连接添加 RTCDataChannel 时发送此事件。 |
3 | RTCPeerConnection.onicecandidate 当触发 icecandidate 事件时调用此处理程序。当向脚本添加 RTCIceCandidate 对象时发送此事件。 |
4 | RTCPeerConnection.oniceconnectionstatechange 当触发 iceconnectionstatechange 事件时调用此处理程序。当 iceConnectionState 的值发生变化时发送此事件。 |
5 | RTCPeerConnection.onidentityresult 当触发 identityresult 事件时调用此处理程序。在创建 offer 或 answer 的过程中(通过 getIdentityAssertion()),当生成身份断言时发送此事件。 |
6 | RTCPeerConnection.onidpassertionerror 当触发 idpassertionerror 事件时调用此处理程序。当 IdP(身份提供者)在生成身份断言时发现错误时发送此事件。 |
7 | RTCPeerConnection.onidpvalidation 当触发 idpvalidationerror 事件时调用此处理程序。当 IdP(身份提供者)在验证身份断言时发现错误时发送此事件。 |
8 | RTCPeerConnection.onnegotiationneeded 当触发 negotiationneeded 事件时调用此处理程序。浏览器发送此事件以通知将来某个时间点需要协商。 |
9 | RTCPeerConnection.onpeeridentity 当触发 peeridentity 事件时调用此处理程序。当在此连接上设置并验证了对等身份时发送此事件。 |
10 | RTCPeerConnection.onremovestream 当触发 signalingstatechange 事件时调用此处理程序。当 signalingState 的值发生变化时发送此事件。 |
11 | RTCPeerConnection.onsignalingstatechange 当触发 removestream 事件时调用此处理程序。当从此连接中删除 MediaStream 时发送此事件。 |
方法
序号 | 方法 & 描述 |
---|---|
1 | RTCPeerConnection() 返回一个新的 RTCPeerConnection 对象。 |
2 | RTCPeerConnection.createOffer() 创建 offer(请求)以查找远程对等端。此方法的前两个参数是成功和错误回调。可选的第三个参数是选项,例如启用音频或视频流。 |
3 | RTCPeerConnection.createAnswer() 在 offer/answer 协商过程中,对远程对等端收到的 offer 创建 answer。此方法的前两个参数是成功和错误回调。可选的第三个参数是创建 answer 的选项。 |
4 | RTCPeerConnection.setLocalDescription() 更改本地连接描述。描述定义了连接的属性。连接必须能够支持旧的和新的描述。该方法接受三个参数,RTCSessionDescription 对象,如果描述更改成功则回调,如果描述更改失败则回调。 |
5 | RTCPeerConnection.setRemoteDescription() 更改远程连接描述。描述定义了连接的属性。连接必须能够支持旧的和新的描述。该方法接受三个参数,RTCSessionDescription 对象,如果描述更改成功则回调,如果描述更改失败则回调。 |
6 | RTCPeerConnection.updateIce() 更新 ICE 代理进程,以 ping 远程候选者并收集本地候选者。 |
7 | RTCPeerConnection.addIceCandidate() 向 ICE 代理提供远程候选者。 |
8 | RTCPeerConnection.getConfiguration() 返回一个 RTCConfiguration 对象。它表示 RTCPeerConnection 对象的配置。 |
9 | RTCPeerConnection.getLocalStreams() 返回本地 MediaStream 连接的数组。 |
10 | RTCPeerConnection.getRemoteStreams() 返回远程 MediaStream 连接的数组。 |
11 | RTCPeerConnection.getStreamById() 根据给定的 ID 返回本地或远程 MediaStream。 |
12 | RTCPeerConnection.addStream() 添加 MediaStream 作为视频或音频的本地源。 |
13 | RTCPeerConnection.removeStream() 删除 MediaStream 作为视频或音频的本地源。 |
14 | RTCPeerConnection.close() 关闭连接。 |
15 | RTCPeerConnection.createDataChannel() 创建一个新的 RTCDataChannel。 |
16 | RTCPeerConnection.createDTMFSender() 创建一个新的 RTCDTMFSender,与特定的 MediaStreamTrack 关联。允许通过连接发送 DTMF(双音多频)电话信号。 |
17 | RTCPeerConnection.getStats() 创建一个新的 RTCStatsReport,其中包含有关连接的统计信息。 |
18 | RTCPeerConnection.setIdentityProvider() 设置 IdP。接受三个参数 - 名称、用于通信的协议和可选的用户名。 |
19 | RTCPeerConnection.getIdentityAssertion() 收集身份断言。应用程序不需要处理此方法。因此,您可能仅在预期需要时才显式调用它。 |
建立连接
现在让我们创建一个示例应用程序。首先,通过“node server”运行我们在“信令服务器”教程中创建的信令服务器。
页面上将有两个文本输入框,一个用于登录,一个用于我们想要连接到的用户名。创建一个 index.html 文件并添加以下代码 -
<html lang = "en"> <head> <meta charset = "utf-8" /> </head> <body> <div> <input type = "text" id = "loginInput" /> <button id = "loginBtn">Login</button> </div> <div> <input type = "text" id = "otherUsernameInput" /> <button id = "connectToOtherUsernameBtn">Establish connection</button> </div> <script src = "client2.js"></script> </body> </html>
您可以看到我们添加了登录的文本输入框、登录按钮、其他对等用户名文本输入框以及连接到他的按钮。现在创建一个 client.js 文件并添加以下代码 -
var connection = new WebSocket('ws://127.0.0.1:9090'); var name = ""; var loginInput = document.querySelector('#loginInput'); var loginBtn = document.querySelector('#loginBtn'); var otherUsernameInput = document.querySelector('#otherUsernameInput'); var connectToOtherUsernameBtn = document.querySelector('#connectToOtherUsernameBtn'); var connectedUser, myConnection; //when a user clicks the login button loginBtn.addEventListener("click", function(event){ name = loginInput.value; if(name.length > 0){ send({ type: "login", name: name }); } }); //handle messages from the server connection.onmessage = function (message) { console.log("Got message", message.data); var data = JSON.parse(message.data); switch(data.type) { case "login": onLogin(data.success); break; case "offer": onOffer(data.offer, data.name); break; case "answer": onAnswer(data.answer); break; case "candidate": onCandidate(data.candidate); break; default: break; } }; //when a user logs in function onLogin(success) { if (success === false) { alert("oops...try a different username"); } else { //creating our RTCPeerConnection object var configuration = { "iceServers": [{ "url": "stun:stun.1.google.com:19302" }] }; myConnection = new webkitRTCPeerConnection(configuration); console.log("RTCPeerConnection object was created"); console.log(myConnection); //setup ice handling //when the browser finds an ice candidate we send it to another peer myConnection.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; } }; connection.onopen = function () { console.log("Connected"); }; connection.onerror = function (err) { console.log("Got error", err); }; // Alias for sending messages in JSON format function send(message) { if (connectedUser) { message.name = connectedUser; } connection.send(JSON.stringify(message)); };
您可以看到我们建立了与信令服务器的套接字连接。当用户点击登录按钮时,应用程序会将其用户名发送到服务器。如果登录成功,应用程序将创建 RTCPeerConnection 对象并设置 onicecandidate 处理程序,该处理程序将所有找到的 icecandidate 发送到另一个对等端。现在打开页面并尝试登录。您应该看到以下控制台输出 -
下一步是向另一个对等端创建 offer。将以下代码添加到您的 client.js 文件中 -
//setup a peer connection with another user connectToOtherUsernameBtn.addEventListener("click", function () { var otherUsername = otherUsernameInput.value; connectedUser = otherUsername; if (otherUsername.length > 0) { //make an offer myConnection.createOffer(function (offer) { console.log(); send({ type: "offer", offer: offer }); myConnection.setLocalDescription(offer); }, function (error) { alert("An error has occurred."); }); } }); //when somebody wants to call us function onOffer(offer, name) { connectedUser = name; myConnection.setRemoteDescription(new RTCSessionDescription(offer)); myConnection.createAnswer(function (answer) { myConnection.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("oops...error"); }); } //when another user answers to our offer function onAnswer(answer) { myConnection.setRemoteDescription(new RTCSessionDescription(answer)); } //when we got ice candidate from another user function onCandidate(candidate) { myConnection.addIceCandidate(new RTCIceCandidate(candidate)); }
您可以看到,当用户点击“建立连接”按钮时,应用程序会向另一个对等端发出 SDP offer。我们还设置了 onAnswer 和 onCandidate 处理程序。重新加载您的页面,在两个选项卡中打开它,使用两个用户登录并尝试在它们之间建立连接。您应该看到以下控制台输出 -
现在对等连接已建立。在接下来的教程中,我们将添加视频和音频流以及文本聊天支持。
WebRTC - RTCDataChannel APIs
WebRTC 不仅擅长传输音频和视频流,还可以传输我们可能拥有的任何任意数据。这就是 RTCDataChannel 对象发挥作用的地方。
RTCDataChannel API
属性
RTCDataChannel.label (只读) − 返回包含数据通道名称的字符串。
RTCDataChannel.ordered (只读) − 如果保证消息的传递顺序则返回 true,否则返回 false。
RTCDataChannel.protocol (只读) − 返回包含此通道使用的子协议名称的字符串。
RTCDataChannel.id (只读) − 返回通道的唯一 ID,该 ID 在创建 RTCDataChannel 对象时设置。
RTCDataChannel.readyState (只读) − 返回表示连接状态的 RTCDataChannelState 枚举。可能的值 -
connecting − 表示连接尚未激活。这是初始状态。
open − 表示连接正在运行。
closing − 表示连接正在关闭过程中。缓存的消息正在发送或接收过程中,但没有新创建的任务正在接受。
closed − 表示连接无法建立或已被关闭。
RTCDataChannel.bufferedAmount (只读) − 返回已排队等待发送的字节数。这是尚未通过 RTCDataChannel.send() 发送的数据量。
RTCDataChannel.bufferedAmountLowThreshold − 返回 RTCDataChannel.bufferedAmount 被视为低的字节数。当 RTCDataChannel.bufferedAmount 减少到低于此阈值时,将触发 bufferedamountlow 事件。
RTCDataChannel.binaryType − 返回连接传输的二进制数据的类型。可以是“blob”或“arraybuffer”。
RTCDataChannel.maxPacketLifeType (只读) − 返回一个无符号短整型,指示消息以不可靠模式进行时窗口的长度(以毫秒为单位)。
RTCDataChannel.maxRetransmits (只读) − 返回一个无符号短整型,指示如果数据未交付,通道将重新传输数据的最大次数。
RTCDataChannel.negotiated (只读) − 返回一个布尔值,指示通道是由用户代理还是由应用程序协商的。
RTCDataChannel.reliable (只读) − 返回一个布尔值,指示连接是否可以以不可靠模式发送消息。
RTCDataChannel.stream (只读) − RTCDataChannel.id 的同义词
事件处理程序
RTCDataChannel.onopen − 当触发 open 事件时调用此事件处理程序。当数据连接已建立时发送此事件。
RTCDataChannel.onmessage − 当触发 message 事件时调用此事件处理程序。当数据通道上有消息可用时发送此事件。
RTCDataChannel.onbufferedamountlow − 当触发 bufferedamoutlow 事件时调用此事件处理程序。当 RTCDataChannel.bufferedAmount 减少到低于 RTCDataChannel.bufferedAmountLowThreshold 属性时发送此事件。
RTCDataChannel.onclose − 当触发 close 事件时调用此事件处理程序。当数据连接已关闭时发送此事件。
RTCDataChannel.onerror − 当触发 error 事件时调用此事件处理程序。当遇到错误时发送此事件。
方法
RTCDataChannel.close() − 关闭数据通道。
RTCDataChannel.send() − 通过通道发送参数中的数据。数据可以是 blob、字符串、ArrayBuffer 或 ArrayBufferView。
WebRTC - 发送消息
现在让我们创建一个简单的例子。首先,通过“node server”运行我们在“信令服务器”教程中创建的信令服务器。
页面上将有三个文本输入框,一个用于登录,一个用于用户名,还有一个用于我们想要发送给另一个对等节点的消息。创建一个index.html文件并添加以下代码 -
<html lang = "en"> <head> <meta charset = "utf-8" /> </head> <body> <div> <input type = "text" id = "loginInput" /> <button id = "loginBtn">Login</button> </div> <div> <input type = "text" id = "otherUsernameInput" /> <button id = "connectToOtherUsernameBtn">Establish connection</button> </div> <div> <input type = "text" id = "msgInput" /> <button id = "sendMsgBtn">Send text message</button> </div> <script src = "client.js"></script> </body> </html>
我们还添加了三个按钮,分别用于登录、建立连接和发送消息。现在创建一个client.js文件并添加以下代码 -
var connection = new WebSocket('ws://127.0.0.1:9090'); var name = ""; var loginInput = document.querySelector('#loginInput'); var loginBtn = document.querySelector('#loginBtn'); var otherUsernameInput = document.querySelector('#otherUsernameInput'); var connectToOtherUsernameBtn = document.querySelector('#connectToOtherUsernameBtn'); var msgInput = document.querySelector('#msgInput'); var sendMsgBtn = document.querySelector('#sendMsgBtn'); var connectedUser, myConnection, dataChannel; //when a user clicks the login button loginBtn.addEventListener("click", function(event) { name = loginInput.value; if(name.length > 0) { send({ type: "login", name: name }); } }); //handle messages from the server connection.onmessage = function (message) { console.log("Got message", message.data); var data = JSON.parse(message.data); switch(data.type) { case "login": onLogin(data.success); break; case "offer": onOffer(data.offer, data.name); break; case "answer": onAnswer(data.answer); break; case "candidate": onCandidate(data.candidate); break; default: break; } }; //when a user logs in function onLogin(success) { if (success === false) { alert("oops...try a different username"); } else { //creating our RTCPeerConnection object var configuration = { "iceServers": [{ "url": "stun:stun.1.google.com:19302" }] }; myConnection = new webkitRTCPeerConnection(configuration, { optional: [{RtpDataChannels: true}] }); console.log("RTCPeerConnection object was created"); console.log(myConnection); //setup ice handling //when the browser finds an ice candidate we send it to another peer myConnection.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; openDataChannel(); } }; connection.onopen = function () { console.log("Connected"); }; connection.onerror = function (err) { console.log("Got error", err); }; // Alias for sending messages in JSON format function send(message) { if (connectedUser) { message.name = connectedUser; } connection.send(JSON.stringify(message)); };
您可以看到我们建立了与信令服务器的套接字连接。当用户点击登录按钮时,应用程序会将他的用户名发送到服务器。如果登录成功,应用程序将创建RTCPeerConnection对象并设置onicecandidate处理程序,该处理程序将所有找到的icecandidates发送到另一个对等节点。它还会运行openDataChannel()函数,该函数创建一个dataChannel。请注意,在创建RTCPeerConnection对象时,构造函数中的第二个参数是可选的:[{RtpDataChannels: true}],如果您使用的是Chrome或Opera,则此参数是必需的。下一步是向另一个对等节点创建offer。将以下代码添加到您的client.js文件中 -
//setup a peer connection with another user connectToOtherUsernameBtn.addEventListener("click", function () { var otherUsername = otherUsernameInput.value; connectedUser = otherUsername; if (otherUsername.length > 0) { //make an offer myConnection.createOffer(function (offer) { console.log(); send({ type: "offer", offer: offer }); myConnection.setLocalDescription(offer); }, function (error) { alert("An error has occurred."); }); } }); //when somebody wants to call us function onOffer(offer, name) { connectedUser = name; myConnection.setRemoteDescription(new RTCSessionDescription(offer)); myConnection.createAnswer(function (answer) { myConnection.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("oops...error"); }); } //when another user answers to our offer function onAnswer(answer) { myConnection.setRemoteDescription(new RTCSessionDescription(answer)); } //when we got ice candidate from another user function onCandidate(candidate) { myConnection.addIceCandidate(new RTCIceCandidate(candidate)); }
您可以看到,当用户点击“建立连接”按钮时,应用程序会向另一个对等节点发出SDP offer。我们还设置了onAnswer和onCandidate处理程序。最后,让我们实现openDataChannel()函数,该函数创建我们的dataChannel。将以下代码添加到您的client.js文件中 -
//creating data channel function openDataChannel() { var dataChannelOptions = { reliable:true }; dataChannel = myConnection.createDataChannel("myDataChannel", dataChannelOptions); dataChannel.onerror = function (error) { console.log("Error:", error); }; dataChannel.onmessage = function (event) { console.log("Got message:", event.data); }; } //when a user clicks the send message button sendMsgBtn.addEventListener("click", function (event) { console.log("send message"); var val = msgInput.value; dataChannel.send(val); });
在这里,我们为我们的连接创建dataChannel,并为“发送消息”按钮添加事件处理程序。现在在两个标签页中打开此页面,使用两个用户登录,建立连接,并尝试发送消息。您应该在控制台输出中看到它们。请注意,以上示例在Opera中进行了测试。
现在您可能会看到RTCDataChannel是WebRTC API中极其强大的部分。此对象还有许多其他用例,例如点对点游戏或基于 torrent 的文件共享。
WebRTC - 信令
大多数WebRTC应用程序不仅仅能够通过视频和音频进行通信。它们需要许多其他功能。在本章中,我们将构建一个基本的信令服务器。
信令和协商
要连接到另一个用户,您应该知道他在网络上的位置。您的设备的IP地址允许启用Internet的设备彼此之间直接发送数据。RTCPeerConnection对象负责此操作。一旦设备知道如何在互联网上找到彼此,它们就开始交换有关每个设备支持哪些协议和编解码器的数据。
要与另一个用户通信,您只需交换联系信息,其余部分将由WebRTC完成。连接到另一个用户的过程也称为信令和协商。它包括以下几个步骤 -
创建对等连接的潜在候选者列表。
用户或应用程序选择一个用户进行连接。
信令层通知另一个用户有人想要连接到他。他可以接受或拒绝。
第一个用户收到offer被接受的通知。
第一个用户使用另一个用户启动RTCPeerConnection。
两个用户通过信令服务器交换软件和硬件信息。
两个用户交换位置信息。
连接成功或失败。
WebRTC规范不包含任何关于交换信息的标准。因此请记住,以上只是信令可能发生方式的一个示例。您可以使用任何您喜欢的协议或技术。
构建服务器
我们将要构建的服务器能够将两个不在同一台计算机上的用户连接在一起。我们将创建自己的信令机制。我们的信令服务器将允许一个用户呼叫另一个用户。一旦一个用户呼叫了另一个用户,服务器就会在它们之间传递offer、answer、ICE候选者,并建立WebRTC连接。
上图是使用信令服务器时用户之间的消息流。首先,每个用户都向服务器注册。在我们的例子中,这将是一个简单的字符串用户名。用户注册后,他们就可以互相呼叫了。用户1使用他想要呼叫的用户标识符发出offer。另一个用户应该进行answer。最后,ICE候选者在用户之间发送,直到他们能够建立连接。
为了创建WebRTC连接,客户端必须能够在不使用WebRTC对等连接的情况下传输消息。这就是我们将使用HTML5 WebSockets的地方——两个端点(Web服务器和Web浏览器)之间的双向套接字连接。现在让我们开始使用WebSocket库。创建server.js文件并插入以下代码 -
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //when a user connects to our sever wss.on('connection', function(connection) { console.log("user connected"); //when server gets a message from a connected user connection.on('message', function(message){ console.log("Got message from a user:", message); }); connection.send("Hello from server"); });
第一行需要我们已经安装的WebSocket库。然后我们在端口9090上创建一个socket服务器。接下来,我们监听connection事件。当用户与服务器建立WebSocket连接时,将执行此代码。然后我们监听用户发送的任何消息。最后,我们向已连接的用户发送一个“Hello from server”的响应。
现在运行node server,服务器应该开始监听套接字连接。
为了测试我们的服务器,我们将使用我们也已经安装的wscat实用程序。此工具有助于直接连接到WebSocket服务器并测试命令。在一个终端窗口中运行我们的服务器,然后打开另一个窗口并运行wscat -c ws://127.0.0.1:9090命令。您应该在客户端看到以下内容 -
服务器还应该记录已连接的用户 -
用户注册
在我们的信令服务器中,我们将为每个连接使用基于字符串的用户名,以便我们知道将消息发送到哪里。让我们稍微更改一下我们的connection处理程序 -
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } });
这样我们只接受JSON消息。接下来,我们需要将所有已连接的用户存储在某个地方。我们将为此使用一个简单的Javascript对象。更改我们文件的最顶部 -
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {};
我们将为来自客户端的每条消息添加一个type字段。例如,如果用户想要登录,他将发送login类型的消息。让我们定义它 -
connection.on('message', function(message){ var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged:", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; default: sendTo(connection, { type: "error", message: "Command no found: " + data.type }); break; } });
如果用户发送一条类型为login的消息,我们将 -
检查是否有人已经使用此用户名登录
如果是,则告诉用户他登录未成功
如果没有人使用此用户名,我们将用户名作为键添加到connection对象中。
如果未识别命令,我们将发送错误。
以下代码是用于向连接发送消息的辅助函数。将其添加到server.js文件中 -
function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
上述函数确保所有消息都以JSON格式发送。
当用户断开连接时,我们应该清理其连接。当close事件触发时,我们可以删除用户。将以下代码添加到connection处理程序中 -
connection.on("close", function() { if(connection.name) { delete users[connection.name]; } });
现在让我们使用登录命令测试我们的服务器。请记住,所有消息都必须以JSON格式编码。运行我们的服务器并尝试登录。您应该看到类似以下内容 -
拨打电话
成功登录后,用户想要呼叫另一个用户。他应该向另一个用户发出offer来实现它。添加offer处理程序 -
case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null){ //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break;
首先,我们获取我们尝试呼叫的用户connection。如果存在,我们将向他发送offer详细信息。我们还在connection对象中添加了otherName。这是为了方便以后查找。
回答
对响应的回答与我们在offer处理程序中使用的模式类似。我们的服务器只是将所有消息作为answer传递给另一个用户。在offer处理程序之后添加以下代码 -
case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break;
您可以看到这与offer处理程序如何相似。请注意,此代码遵循RTCPeerConnection对象上的createOffer和createAnswer函数。
现在我们可以测试我们的offer/answer机制了。同时连接两个客户端,并尝试发出offer和answer。您应该看到以下内容 -
在此示例中,offer和answer是简单的字符串,但在实际应用程序中,它们将填充SDP数据。
ICE候选者
最后一部分是在用户之间处理ICE候选者。我们使用相同的技术,只是在用户之间传递消息。主要区别在于,候选者消息可能每个用户多次发生,并且可以按任何顺序发生。添加candidate处理程序 -
case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break;
它应该与offer和answer处理程序类似地工作。
离开连接
为了允许我们的用户断开与另一个用户的连接,我们应该实现挂断功能。它还会告诉服务器删除所有用户引用。添加leave处理程序 -
case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break;
这也会向另一个用户发送leave事件,以便他可以相应地断开其对等连接。我们还应该处理用户从信令服务器断开连接的情况。让我们修改我们的close处理程序 -
connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } });
现在,如果连接终止,我们的用户将断开连接。当用户在offer、answer或candidate状态下关闭浏览器窗口时,将触发close事件。
完整的信令服务器
以下是信令服务器的完整代码 -
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {}; //when a user connects to our sever wss.on('connection', function(connection) { console.log("User connected"); //when server gets a message from a connected user connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null) { //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break; case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break; case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break; case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break; default: sendTo(connection, { type: "error", message: "Command not found: " + data.type }); break; } }); //when user exits, for example closes a browser window //this may help if we are still in "offer","answer" or "candidate" state connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } }); connection.send("Hello world"); }); function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
所以工作已经完成,我们的信令服务器已准备就绪。请记住,在建立WebRTC连接时,如果操作顺序错误可能会导致问题。
总结
在本章中,我们构建了一个简单而直接的信令服务器。我们介绍了信令过程、用户注册和offer/answer机制。我们还实现了在用户之间发送候选者。
WebRTC - 浏览器支持
网络发展迅速,并且不断改进。每天都会创建新的标准。浏览器允许在用户不知情的情况下安装更新,因此您应该了解网络和WebRTC领域正在发生的事情。以下是目前为止的概述。
浏览器支持
并非每个浏览器都同时拥有所有相同的WebRTC功能。不同的浏览器可能领先于曲线,这使得某些WebRTC功能在一个浏览器中有效而在另一个浏览器中无效。浏览器当前对WebRTC的支持如下面的图片所示。
您可以在https://caniuse.cn/#feat=rtcpeerconnection上查看最新的WebRTC支持状态。
Chrome、Firefox和Opera
主流PC操作系统(如Mac OS X、Windows和Linux)上的最新版Chrome、Firefox和Opera都开箱即用地支持WebRTC。最重要的是,来自Chrome和Firefox开发团队的工程师一直在合作修复问题,以便这两个浏览器可以轻松地相互通信。
Android操作系统
在Android操作系统上,Chrome和Firefox的WebRTC应用程序应该开箱即用。在Android冰淇淋三明治版本(4.0)之后,它们能够与其他浏览器一起工作。这是由于桌面版和移动版之间共享代码造成的。
苹果
Apple尚未宣布其在OS X上支持Safari中WebRTC的计划。混合原生iOS应用程序的一种可能的解决方法是将WebRTC代码直接嵌入到应用程序中,并将此应用程序加载到WebView中。
Internet Explorer
微软并不支持桌面端的 WebRTC。但他们已经正式确认将在未来版本的 IE(Edge)中实现 ORTC(对象实时通信)。他们不打算支持 WebRTC 1.0。他们将他们的 ORTC 标记为 WebRTC 1.1,尽管它只是一个社区增强功能,而不是官方标准。最近,他们已将 ORTC 支持添加到最新的 Microsoft Edge 版本中。您可以在 https://blogs.windows.com/msedgedev/2015/09/18/ortc-api-is-now-available-in-microsoftedge/. 了解更多信息。
总结
请注意,WebRTC 是一组 API 和协议的集合,而不是单个 API。这些 API 在不同的浏览器和操作系统上的支持程度不同。一个了解最新支持程度的好方法是通过 http://canisue.com. 它跟踪了多个浏览器中现代 API 的采用情况。您还可以在 http://www.webrtc.org, 上找到有关浏览器支持以及 WebRTC 演示的最新信息,该网站由 Mozilla、Google 和 Opera 支持。
WebRTC - 移动设备支持
在移动领域,WebRTC 的支持程度与桌面端不同。移动设备有自己的方式,因此 WebRTC 在移动平台上也略有不同。
在为桌面开发 WebRTC 应用程序时,我们考虑使用 Chrome、Firefox 或 Opera。它们都开箱即用地支持 WebRTC。通常,您只需要一个浏览器,而无需担心桌面的硬件。
在移动领域,WebRTC 目前有三种可能的模式:
- 原生应用程序
- 浏览器应用程序
- 原生浏览器
Android
2013 年,推出了支持 WebRTC 的 Firefox Android 版网络浏览器。现在,您可以使用 Firefox 移动浏览器在 Android 设备上进行视频通话。
它有三个主要的 WebRTC 组件:
PeerConnection - 支持浏览器之间的通话
getUserMedia - 提供对摄像头和麦克风的访问
DataChannels - 提供点对点数据传输
Android 版 Google Chrome 也提供 WebRTC 支持。正如您已经注意到的,最有趣的功能通常首先出现在 Chrome 中。
在过去的一年中,Opera 移动浏览器也出现了 WebRTC 支持。因此,对于 Android,您可以选择 Chrome、Firefox 和 Opera。其他浏览器不支持 WebRTC。
iOS
不幸的是,iOS 目前不支持 WebRTC。尽管在使用 Firefox、Opera 或 Chrome 的 Mac 上 WebRTC 工作良好,但它在 iOS 上不受支持。
如今,您的 WebRTC 应用程序无法在 Apple 移动设备上开箱即用。但是有一个浏览器 - Bowser。它是由爱立信开发的网络浏览器,并且开箱即用地支持 WebRTC。您可以在 http://www.openwebrtc.org/bowser/. 查看其主页。
如今,这是在 iOS 上支持您的 WebRTC 应用程序的唯一友好方式。另一种方法是自己开发一个原生应用程序。
Windows Phone
微软不支持移动平台上的 WebRTC。但他们已经正式确认将在未来版本的 IE 中实现 ORTC(对象实时通信)。他们不打算支持 WebRTC 1.0。他们将他们的 ORTC 标记为 WebRTC 1.1,尽管它只是一个社区增强功能,而不是官方标准。
因此,今天 Windows Phone 用户无法使用 WebRTC 应用程序,也没有办法解决这种情况。
Blackberry
WebRTC 应用程序在 Blackberry 上也不受支持,无论以何种方式。
使用 WebRTC 原生浏览器
用户利用 WebRTC 最方便、最舒适的情况是使用设备的原生浏览器。在这种情况下,设备已准备好进行任何其他配置。
目前只有 Android 4.0 及更高版本的设备提供此功能。Apple 仍然没有显示任何与 WebRTC 支持相关的活动。因此,Safari 用户无法使用 WebRTC 应用程序。微软也没有在 Windows Phone 8 中引入它。
通过浏览器应用程序使用 WebRTC
这意味着使用第三方应用程序(非原生网络浏览器)来提供 WebRTC 功能。目前,有两个这样的第三方应用程序。Bowser 是将 WebRTC 功能引入 iOS 设备的唯一方法,而 Opera 是 Android 平台的一个不错的替代方案。其余可用的移动浏览器都不支持 WebRTC。
原生移动应用程序
如您所见,WebRTC 在移动领域还没有得到广泛的支持。因此,可能的解决方案之一是开发利用 WebRTC API 的原生应用程序。但这并不是更好的选择,因为 WebRTC 的主要功能是跨平台解决方案。无论如何,在某些情况下这是唯一的方法,因为原生应用程序可以利用 HTML5 浏览器不支持的特定于设备的功能或特性。
限制移动设备和桌面设备的视频流
getUserMedia API 的第一个参数期望一个键值对对象,告诉浏览器如何处理流。您可以在 https://tools.ietf.org/html/draft-alvestrand-constraints-resolution-03. 检查完整的约束集。您可以设置视频纵横比、帧率和其他可选参数。
支持移动设备是一件非常头疼的事情,因为移动设备的屏幕空间和资源都比较有限。您可能希望移动设备仅捕获 480x320 或更小的视频流分辨率,以节省电量和带宽。在浏览器中使用用户代理字符串是测试用户是否在移动设备上的一种好方法。让我们看一个例子。创建 index.html 文件:
<!DOCTYPE html> <html lang = "en"> <head> <meta charset = "utf-8" /> </head> <body> <video autoplay></video> <script src = "client.js"></script> </body> </html>
然后创建以下 client.js 文件:
//constraints for desktop browser var desktopConstraints = { video: { mandatory: { maxWidth:800, maxHeight:600 } }, audio: true }; //constraints for mobile browser var mobileConstraints = { video: { mandatory: { maxWidth: 480, maxHeight: 320, } }, audio: true } //if a user is using a mobile browser if(/Android|iPhone|iPad/i.test(navigator.userAgent)) { var constraints = mobileConstraints; } else { var constraints = desktopConstraints; } function hasUserMedia() { //check if the browser supports the WebRTC return !!(navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia); } if (hasUserMedia()) { navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; //enabling video and audio channels navigator.getUserMedia(constraints, function (stream) { var video = document.querySelector('video'); //inserting our stream to the video tag video.src = window.URL.createObjectURL(stream); }, function (err) {}); } else { alert("WebRTC is not supported"); }
使用 static 命令运行 Web 服务器并打开页面。您应该看到它是 800x600。然后使用 Chrome 工具在移动视口中打开此页面并检查分辨率。它应该是 480x320。
约束是提高 WebRTC 应用程序性能最简单的方法。
总结
在本章中,我们了解了在为移动设备开发 WebRTC 应用程序时可能出现的难题。我们发现了在移动平台上支持 WebRTC API 的不同限制。我们还启动了一个演示应用程序,在其中为桌面和移动浏览器设置了不同的约束。
WebRTC - 视频演示
在本章中,我们将构建一个客户端应用程序,允许两个在不同设备上的用户使用 WebRTC 进行通信。我们的应用程序将有两个页面。一个用于登录,另一个用于呼叫其他用户。
这两个页面将是 div 标签。大多数输入都是通过简单的事件处理程序完成的。
信令服务器
要创建 WebRTC 连接,客户端必须能够在不使用 WebRTC 对等连接的情况下传输消息。这正是我们将使用 HTML5 WebSockets 的地方 - 两个端点(Web 服务器和 Web 浏览器)之间的双向套接字连接。现在让我们开始使用 WebSocket 库。创建 server.js 文件并插入以下代码:
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //when a user connects to our sever wss.on('connection', function(connection) { console.log("user connected"); //when server gets a message from a connected user connection.on('message', function(message) { console.log("Got message from a user:", message); }); connection.send("Hello from server"); });
第一行需要我们已经安装的WebSocket库。然后我们在端口9090上创建一个socket服务器。接下来,我们监听connection事件。当用户与服务器建立WebSocket连接时,将执行此代码。然后我们监听用户发送的任何消息。最后,我们向已连接的用户发送一个“Hello from server”的响应。
在我们的信令服务器中,我们将为每个连接使用基于字符串的用户名,以便我们知道将消息发送到哪里。让我们稍微更改一下我们的连接 handler:
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } });
这样我们只接受JSON消息。接下来,我们需要将所有已连接的用户存储在某个地方。我们将为此使用一个简单的Javascript对象。更改我们文件的最顶部 -
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {};
我们将为来自客户端的每条消息添加一个type字段。例如,如果用户想要登录,他将发送login类型的消息。让我们定义它 -
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged:", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; default: sendTo(connection, { type: "error", message: "Command no found: " + data.type }); break; } });
如果用户发送一条类型为login的消息,我们将 -
检查是否有人已经使用此用户名登录
如果是,则告诉用户他登录未成功
如果没有人使用此用户名,我们将用户名作为键添加到connection对象中。
如果未识别命令,我们将发送错误。
以下代码是用于向连接发送消息的辅助函数。将其添加到server.js文件中 -
function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
当用户断开连接时,我们应该清理其连接。当close事件触发时,我们可以删除用户。将以下代码添加到connection处理程序中 -
connection.on("close", function() { if(connection.name) { delete users[connection.name]; } });
成功登录后,用户想要呼叫另一个用户。他应该向另一个用户发出offer来实现它。添加offer处理程序 -
case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null) { //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break;
首先,我们获取我们尝试呼叫的用户connection。如果存在,我们将向他发送offer详细信息。我们还在connection对象中添加了otherName。这是为了方便以后查找。
对响应的回复具有与我们在 offer 处理程序中使用的类似模式。我们的服务器只是将所有消息作为 answer 传递给另一个用户。在 offer 处理程序之后添加以下代码:
case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break;
最后一部分是在用户之间处理ICE候选者。我们使用相同的技术,只是在用户之间传递消息。主要区别在于,候选者消息可能每个用户多次发生,并且可以按任何顺序发生。添加candidate处理程序 -
case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break;
为了允许我们的用户断开与另一个用户的连接,我们应该实现挂断功能。它还会告诉服务器删除所有用户引用。添加 leave 处理程序:
case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break;
这也会向另一个用户发送leave事件,以便他可以相应地断开其对等连接。我们还应该处理用户从信令服务器断开连接的情况。让我们修改我们的close处理程序 -
connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } });
以下是我们的信令服务器的完整代码:
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {}; //when a user connects to our sever wss.on('connection', function(connection) { console.log("User connected"); //when server gets a message from a connected user connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null) { //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break; case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break; case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break; case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break; default: sendTo(connection, { type: "error", message: "Command not found: " + data.type }); break; } }); //when user exits, for example closes a browser window //this may help if we are still in "offer","answer" or "candidate" state connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } }); connection.send("Hello world"); }); function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
客户端应用程序
测试此应用程序的一种方法是打开两个浏览器标签页并尝试互相呼叫。
首先,我们需要安装 bootstrap 库。Bootstrap 是一个用于开发 Web 应用程序的前端框架。您可以在 https://bootstrap.ac.cn/. 了解更多信息。创建一个名为“videochat”的文件夹。这将是我们的根应用程序文件夹。在此文件夹内创建一个 package.json 文件(它对于管理 npm 依赖项是必需的)并添加以下内容:
{ "name": "webrtc-videochat", "version": "0.1.0", "description": "webrtc-videochat", "author": "Author", "license": "BSD-2-Clause" }
然后运行 npm install bootstrap。这将在 videochat/node_modules 文件夹中安装 bootstrap 库。
现在我们需要创建一个基本的 HTML 页面。在根文件夹中创建一个 index.html 文件,其中包含以下代码:
<html> <head> <title>WebRTC Video Demo</title> <link rel = "stylesheet" href = "node_modules/bootstrap/dist/css/bootstrap.min.css"/> </head> <style> body { background: #eee; padding: 5% 0; } video { background: black; border: 1px solid gray; } .call-page { position: relative; display: block; margin: 0 auto; width: 500px; height: 500px; } #localVideo { width: 150px; height: 150px; position: absolute; top: 15px; right: 15px; } #remoteVideo { width: 500px; height: 500px; } </style> <body> <div id = "loginPage" class = "container text-center"> <div class = "row"> <div class = "col-md-4 col-md-offset-4"> <h2>WebRTC Video Demo. Please sign in</h2> <label for = "usernameInput" class = "sr-only">Login</label> <input type = "email" id = "usernameInput" c lass = "form-control formgroup" placeholder = "Login" required = "" autofocus = ""> <button id = "loginBtn" class = "btn btn-lg btn-primary btnblock"> Sign in</button> </div> </div> </div> <div id = "callPage" class = "call-page"> <video id = "localVideo" autoplay></video> <video id = "remoteVideo" autoplay></video> <div class = "row text-center"> <div class = "col-md-12"> <input id = "callToUsernameInput" type = "text" placeholder = "username to call" /> <button id = "callBtn" class = "btn-success btn">Call</button> <button id = "hangUpBtn" class = "btn-danger btn">Hang Up</button> </div> </div> </div> <script src = "client.js"></script> </body> </html>
此页面应该很熟悉。我们添加了 bootstrap css 文件。我们还定义了两个页面。最后,我们创建了几个文本字段和按钮来获取用户的信息。您应该看到用于本地和远程视频流的两个视频元素。请注意,我们添加了指向 client.js 文件的链接。
现在我们需要与我们的信令服务器建立连接。在根文件夹中创建 client.js 文件,其中包含以下代码:
//our username var name; var connectedUser; //connecting to our signaling server var conn = new WebSocket('ws://127.0.0.1:9090'); conn.onopen = function () { console.log("Connected to the signaling server"); }; //when we got a message from a signaling server conn.onmessage = function (msg) { console.log("Got message", msg.data); var data = JSON.parse(msg.data); switch(data.type) { case "login": handleLogin(data.success); break; //when somebody wants to call us case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //when a remote peer sends an ice candidate to us case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //alias for sending JSON encoded messages function send(message) { //attach the other peer username to our messages if (connectedUser) { message.name = connectedUser; } conn.send(JSON.stringify(message)); };
现在通过 node server 运行我们的信令服务器。然后,在根文件夹中运行 static 命令并在浏览器中打开页面。您应该看到以下控制台输出:
下一步是实现具有唯一用户名的用户登录。我们只需将用户名发送到服务器,服务器随后会告诉我们它是否已被占用。将以下代码添加到您的 client.js 文件中:
//****** //UI selectors block //****** var loginPage = document.querySelector('#loginPage'); var usernameInput = document.querySelector('#usernameInput'); var loginBtn = document.querySelector('#loginBtn'); var callPage = document.querySelector('#callPage'); var callToUsernameInput = document.querySelector('#callToUsernameInput'); var callBtn = document.querySelector('#callBtn'); var hangUpBtn = document.querySelector('#hangUpBtn'); //hide call page callPage.style.display = "none"; // Login when the user clicks the button loginBtn.addEventListener("click", function (event) { name = usernameInput.value; if (name.length > 0) { send({ type: "login", name: name }); } }); function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { //display the call page if login is successful loginPage.style.display = "none"; callPage.style.display = "block"; //start peer connection } };
首先,我们选择页面上一些元素的引用。然后,我们隐藏呼叫页面。然后,我们在登录按钮上添加一个事件监听器。当用户点击它时,我们将他的用户名发送到服务器。最后,我们实现了 handleLogin 回调。如果登录成功,我们将显示呼叫页面并开始设置对等连接。
要启动对等连接,我们需要:
- 从网络摄像头获取流。
- 创建 RTCPeerConnection 对象。
将以下代码添加到“UI 选择器块”中:
var localVideo = document.querySelector('#localVideo'); var remoteVideo = document.querySelector('#remoteVideo'); var yourConn; var stream;
修改 handleLogin 函数:
function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** //getting local video stream navigator.webkitGetUserMedia({ video: true, audio: true }, function (myStream) { stream = myStream; //displaying local video stream on the page localVideo.src = window.URL.createObjectURL(stream); //using Google public stun server var configuration = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; yourConn = new webkitRTCPeerConnection(configuration); // setup stream listening yourConn.addStream(stream); //when a remote user adds stream to the peer connection, we display it yourConn.onaddstream = function (e) { remoteVideo.src = window.URL.createObjectURL(e.stream); }; // Setup ice handling yourConn.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; }, function (error) { console.log(error); }); } };
现在,如果您运行代码,页面应该允许您登录并在页面上显示您的本地视频流。
现在我们准备发起呼叫。首先,我们向另一个用户发送一个 offer。一旦用户收到 offer,他就会创建一个 answer 并开始交换 ICE 候选者。将以下代码添加到 client.js 文件中:
//initiating a call callBtn.addEventListener("click", function () { var callToUsername = callToUsernameInput.value; if (callToUsername.length > 0) { connectedUser = callToUsername; // create an offer yourConn.createOffer(function (offer) { send({ type: "offer", offer: offer }); yourConn.setLocalDescription(offer); }, function (error) { alert("Error when creating an offer"); }); } }); //when somebody sends us an offer function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //create an answer to an offer yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //when we got an answer from a remote user function handleAnswer(answer) { yourConn.setRemoteDescription(new RTCSessionDescription(answer)); }; //when we got an ice candidate from a remote user function handleCandidate(candidate) { yourConn.addIceCandidate(new RTCIceCandidate(candidate)); };
我们在 Call 按钮上添加了一个 click 处理程序,它会发起一个 offer。然后,我们实现了 onmessage 处理程序期望的几个处理程序。它们将异步处理,直到两个用户都建立连接。
最后一步是实现挂断功能。这将停止数据传输并告诉另一个用户关闭呼叫。添加以下代码:
//hang up hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; remoteVideo.src = null; yourConn.close(); yourConn.onicecandidate = null; yourConn.onaddstream = null; };
当用户点击 Hang Up 按钮时:
- 它将向另一个用户发送“leave”消息
- 它将关闭 RTCPeerConnection 并本地销毁连接
现在运行代码。您应该能够使用两个浏览器标签页登录到服务器。然后,您可以呼叫该标签页并挂断呼叫。
以下是完整的 client.js 文件:
//our username var name; var connectedUser; //connecting to our signaling server var conn = new WebSocket('ws://127.0.0.1:9090'); conn.onopen = function () { console.log("Connected to the signaling server"); }; //when we got a message from a signaling server conn.onmessage = function (msg) { console.log("Got message", msg.data); var data = JSON.parse(msg.data); switch(data.type) { case "login": handleLogin(data.success); break; //when somebody wants to call us case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //when a remote peer sends an ice candidate to us case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //alias for sending JSON encoded messages function send(message) { //attach the other peer username to our messages if (connectedUser) { message.name = connectedUser; } conn.send(JSON.stringify(message)); }; //****** //UI selectors block //****** var loginPage = document.querySelector('#loginPage'); var usernameInput = document.querySelector('#usernameInput'); var loginBtn = document.querySelector('#loginBtn'); var callPage = document.querySelector('#callPage'); var callToUsernameInput = document.querySelector('#callToUsernameInput'); var callBtn = document.querySelector('#callBtn'); var hangUpBtn = document.querySelector('#hangUpBtn'); var localVideo = document.querySelector('#localVideo'); var remoteVideo = document.querySelector('#remoteVideo'); var yourConn; var stream; callPage.style.display = "none"; // Login when the user clicks the button loginBtn.addEventListener("click", function (event) { name = usernameInput.value; if (name.length > 0) { send({ type: "login", name: name }); } }); function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** //getting local video stream navigator.webkitGetUserMedia({ video: true, audio: true }, function (myStream) { stream = myStream; //displaying local video stream on the page localVideo.src = window.URL.createObjectURL(stream); //using Google public stun server var configuration = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; yourConn = new webkitRTCPeerConnection(configuration); // setup stream listening yourConn.addStream(stream); //when a remote user adds stream to the peer connection, we display it yourConn.onaddstream = function (e) { remoteVideo.src = window.URL.createObjectURL(e.stream); }; // Setup ice handling yourConn.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; }, function (error) { console.log(error); }); } }; //initiating a call callBtn.addEventListener("click", function () { var callToUsername = callToUsernameInput.value; if (callToUsername.length > 0) { connectedUser = callToUsername; // create an offer yourConn.createOffer(function (offer) { send({ type: "offer", offer: offer }); yourConn.setLocalDescription(offer); }, function (error) { alert("Error when creating an offer"); }); } }); //when somebody sends us an offer function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //create an answer to an offer yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //when we got an answer from a remote user function handleAnswer(answer) { yourConn.setRemoteDescription(new RTCSessionDescription(answer)); }; //when we got an ice candidate from a remote user function handleCandidate(candidate) { yourConn.addIceCandidate(new RTCIceCandidate(candidate)); }; //hang up hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; remoteVideo.src = null; yourConn.close(); yourConn.onicecandidate = null; yourConn.onaddstream = null; };
总结
此演示提供了每个 WebRTC 应用程序所需的基线功能。要改进此演示,您可以通过 Facebook 或 Google 等平台添加用户身份识别,处理用户输入无效数据。此外,WebRTC 连接可能会由于多种原因而失败,例如不支持该技术或无法穿越防火墙。为了使任何 WebRTC 应用程序稳定,已经付出了很多努力。
WebRTC - 语音演示
在本章中,我们将构建一个客户端应用程序,允许两个在不同设备上的用户使用 WebRTC 音频流进行通信。我们的应用程序将有两个页面。一个用于登录,另一个用于向另一个用户发起音频呼叫。
这两个页面将是 div 标签。大多数输入都是通过简单的事件处理程序完成的。
信令服务器
要创建 WebRTC 连接,客户端必须能够在不使用 WebRTC 对等连接的情况下传输消息。这正是我们将使用 HTML5 WebSockets 的地方 - 两个端点(Web 服务器和 Web 浏览器)之间的双向套接字连接。现在让我们开始使用 WebSocket 库。创建 server.js 文件并插入以下代码:
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //when a user connects to our sever wss.on('connection', function(connection) { console.log("user connected"); //when server gets a message from a connected user connection.on('message', function(message) { console.log("Got message from a user:", message); }); connection.send("Hello from server"); });
第一行需要我们已经安装的WebSocket库。然后我们在端口9090上创建一个socket服务器。接下来,我们监听connection事件。当用户与服务器建立WebSocket连接时,将执行此代码。然后我们监听用户发送的任何消息。最后,我们向已连接的用户发送一个“Hello from server”的响应。
在我们的信令服务器中,我们将为每个连接使用基于字符串的用户名,以便我们知道将消息发送到哪里。让我们稍微更改一下我们的connection处理程序 -
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } });
这样我们只接受JSON消息。接下来,我们需要将所有已连接的用户存储在某个地方。我们将为此使用一个简单的Javascript对象。更改我们文件的最顶部 -
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {};
我们将为来自客户端的每条消息添加一个type字段。例如,如果用户想要登录,他将发送login类型的消息。让我们定义它 -
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged:", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; default: sendTo(connection, { type: "error", message: "Command no found: " + data.type }); break; } });
如果用户发送一条类型为login的消息,我们将 -
- 检查是否有人已使用此用户名登录。
- 如果是,则告诉用户他登录未成功。
- 如果没有人使用此用户名,我们将用户名作为键添加到connection对象中。
- 如果未识别命令,我们将发送错误。
以下代码是用于向连接发送消息的辅助函数。将其添加到server.js文件中 -
function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
当用户断开连接时,我们应该清理其连接。当 close 事件触发时,我们可以删除用户。将以下代码添加到 connection 处理程序中:
connection.on("close", function() { if(connection.name) { delete users[connection.name]; } });
成功登录后,用户想要呼叫另一个用户。他应该向另一个用户发出offer来实现它。添加offer处理程序 -
case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null) { //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break;
首先,我们获取我们尝试呼叫的用户connection。如果存在,我们将向他发送offer详细信息。我们还在connection对象中添加了otherName。这是为了方便以后查找。
对响应的回复具有与我们在 offer 处理程序中使用的类似模式。我们的服务器只是将所有消息作为 answer 传递给另一个用户。在 offer 处理程序之后添加以下代码:
case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break;
最后一部分是在用户之间处理ICE候选者。我们使用相同的技术,只是在用户之间传递消息。主要区别在于,候选者消息可能每个用户多次发生,并且可以按任何顺序发生。添加candidate处理程序 -
case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break;
为了允许我们的用户断开与另一个用户的连接,我们应该实现挂断功能。它还会告诉服务器删除所有用户引用。添加 leave 处理程序:
case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break;
这也会向另一个用户发送leave事件,以便他可以相应地断开其对等连接。我们还应该处理用户从信令服务器断开连接的情况。让我们修改我们的close处理程序 -
connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } });
以下是我们的信令服务器的完整代码:
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {}; //when a user connects to our sever wss.on('connection', function(connection) { console.log("User connected"); //when server gets a message from a connected user connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null) { //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break; case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break; case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break; case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break; default: sendTo(connection, { type: "error", message: "Command not found: " + data.type }); break; } }); //when user exits, for example closes a browser window //this may help if we are still in "offer","answer" or "candidate" state connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } }); connection.send("Hello world"); }); function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
客户端应用程序
测试此应用程序的一种方法是打开两个浏览器选项卡,并尝试相互进行音频通话。
首先,我们需要安装bootstrap库。Bootstrap是一个用于开发 Web 应用程序的前端框架。您可以在https://bootstrap.ac.cn/.了解更多信息。创建一个文件夹,例如“audiochat”。这将是我们的根应用程序文件夹。在此文件夹内创建一个文件package.json(它对于管理 npm 依赖项是必要的)并添加以下内容:
{ "name": "webrtc-audiochat", "version": "0.1.0", "description": "webrtc-audiochat", "author": "Author", "license": "BSD-2-Clause" }
然后运行npm install bootstrap。这将在audiochat/node_modules文件夹中安装bootstrap库。
现在我们需要创建一个基本的 HTML 页面。在根文件夹中创建一个 index.html 文件,其中包含以下代码:
<html> <head> <title>WebRTC Voice Demo</title> <link rel = "stylesheet" href = "node_modules/bootstrap/dist/css/bootstrap.min.css"/> </head> <style> body { background: #eee; padding: 5% 0; } </style> <body> <div id = "loginPage" class = "container text-center"> <div class = "row"> <div class = "col-md-4 col-md-offset-4"> <h2>WebRTC Voice Demo. Please sign in</h2> <label for = "usernameInput" class = "sr-only">Login</label> <input type = "email" id = "usernameInput" class = "form-control formgroup" placeholder = "Login" required = "" autofocus = ""> <button id = "loginBtn" class = "btn btn-lg btn-primary btnblock"> Sign in</button> </div> </div> </div> <div id = "callPage" class = "call-page"> <div class = "row"> <div class = "col-md-6 text-right"> Local audio: <audio id = "localAudio" controls autoplay></audio> </div> <div class = "col-md-6 text-left"> Remote audio: <audio id = "remoteAudio" controls autoplay></audio> </div> </div> <div class = "row text-center"> <div class = "col-md-12"> <input id = "callToUsernameInput" type = "text" placeholder = "username to call" /> <button id = "callBtn" class = "btn-success btn">Call</button> <button id = "hangUpBtn" class = "btn-danger btn">Hang Up</button> </div> </div> </div> <script src = "client.js"></script> </body> </html>
此页面应该很熟悉。我们添加了bootstrap css 文件。我们还定义了两个页面。最后,我们创建了几个文本字段和按钮来获取用户的信息。您应该看到用于本地和远程音频流的两个音频元素。请注意,我们添加了到client.js文件的链接。
现在我们需要与我们的信令服务器建立连接。在根文件夹中创建 client.js 文件,其中包含以下代码:
//our username var name; var connectedUser; //connecting to our signaling server var conn = new WebSocket('ws://127.0.0.1:9090'); conn.onopen = function () { console.log("Connected to the signaling server"); }; //when we got a message from a signaling server conn.onmessage = function (msg) { console.log("Got message", msg.data); var data = JSON.parse(msg.data); switch(data.type) { case "login": handleLogin(data.success); break; //when somebody wants to call us case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //when a remote peer sends an ice candidate to us case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //alias for sending JSON encoded messages function send(message) { //attach the other peer username to our messages if (connectedUser) { message.name = connectedUser; } conn.send(JSON.stringify(message)); };
现在通过 node server 运行我们的信令服务器。然后,在根文件夹中运行 static 命令并在浏览器中打开页面。您应该看到以下控制台输出:
下一步是实现具有唯一用户名的用户登录。我们只需将用户名发送到服务器,服务器随后会告诉我们它是否已被占用。将以下代码添加到您的 client.js 文件中:
//****** //UI selectors block //****** var loginPage = document.querySelector('#loginPage'); var usernameInput = document.querySelector('#usernameInput'); var loginBtn = document.querySelector('#loginBtn'); var callPage = document.querySelector('#callPage'); var callToUsernameInput = document.querySelector('#callToUsernameInput'); var callBtn = document.querySelector('#callBtn'); var hangUpBtn = document.querySelector('#hangUpBtn'); callPage.style.display = "none"; // Login when the user clicks the button loginBtn.addEventListener("click", function (event) { name = usernameInput.value; if (name.length > 0) { send({ type: "login", name: name }); } }); function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** } };
首先,我们选择页面上一些元素的引用。然后,我们隐藏呼叫页面。然后,我们在登录按钮上添加一个事件监听器。当用户点击它时,我们将他的用户名发送到服务器。最后,我们实现了 handleLogin 回调。如果登录成功,我们将显示呼叫页面并开始设置对等连接。
要启动对等连接,我们需要:
- 从麦克风获取音频流
- 创建RTCPeerConnection对象
将以下代码添加到“UI 选择器块”中:
var localAudio = document.querySelector('#localAudio'); var remoteAudio = document.querySelector('#remoteAudio'); var yourConn; var stream;
修改 handleLogin 函数:
function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** //getting local audio stream navigator.webkitGetUserMedia({ video: false, audio: true }, function (myStream) { stream = myStream; //displaying local audio stream on the page localAudio.src = window.URL.createObjectURL(stream); //using Google public stun server var configuration = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; yourConn = new webkitRTCPeerConnection(configuration); // setup stream listening yourConn.addStream(stream); //when a remote user adds stream to the peer connection, we display it yourConn.onaddstream = function (e) { remoteAudio.src = window.URL.createObjectURL(e.stream); }; // Setup ice handling yourConn.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", }); } }; }, function (error) { console.log(error); }); } };
现在,如果您运行代码,该页面应该允许您登录并在页面上显示您的本地音频流。
现在我们准备发起呼叫。首先,我们向另一个用户发送一个 offer。一旦用户收到 offer,他就会创建一个 answer 并开始交换 ICE 候选者。将以下代码添加到 client.js 文件中:
//initiating a call callBtn.addEventListener("click", function () { var callToUsername = callToUsernameInput.value; if (callToUsername.length > 0) { connectedUser = callToUsername; // create an offer yourConn.createOffer(function (offer) { send({ type: "offer", offer: offer }); yourConn.setLocalDescription(offer); }, function (error) { alert("Error when creating an offer"); }); } }); //when somebody sends us an offer function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //create an answer to an offer yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //when we got an answer from a remote user function handleAnswer(answer) { yourConn.setRemoteDescription(new RTCSessionDescription(answer)); }; //when we got an ice candidate from a remote user function handleCandidate(candidate) { yourConn.addIceCandidate(new RTCIceCandidate(candidate)); };
我们在 Call 按钮上添加了一个 click 处理程序,它会发起一个 offer。然后,我们实现了 onmessage 处理程序期望的几个处理程序。它们将异步处理,直到两个用户都建立连接。
最后一步是实现挂断功能。这将停止数据传输并告诉另一个用户关闭呼叫。添加以下代码:
//hang up hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; remoteAudio.src = null; yourConn.close(); yourConn.onicecandidate = null; yourConn.onaddstream = null; };
当用户点击 Hang Up 按钮时:
- 它将向另一个用户发送“leave”消息
- 它将关闭 RTCPeerConnection 并本地销毁连接
现在运行代码。您应该能够使用两个浏览器选项卡登录服务器。然后,您可以向选项卡进行音频呼叫并挂断呼叫。
以下是完整的 client.js 文件:
//our username var name; var connectedUser; //connecting to our signaling server var conn = new WebSocket('ws://127.0.0.1:9090'); conn.onopen = function () { console.log("Connected to the signaling server"); }; //when we got a message from a signaling server conn.onmessage = function (msg) { console.log("Got message", msg.data); var data = JSON.parse(msg.data); switch(data.type) { case "login": handleLogin(data.success); break; //when somebody wants to call us case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //when a remote peer sends an ice candidate to us case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //alias for sending JSON encoded messages function send(message) { //attach the other peer username to our messages if (connectedUser) { message.name = connectedUser; } conn.send(JSON.stringify(message)); }; //****** //UI selectors block //****** var loginPage = document.querySelector('#loginPage'); var usernameInput = document.querySelector('#usernameInput'); var loginBtn = document.querySelector('#loginBtn'); var callPage = document.querySelector('#callPage'); var callToUsernameInput = document.querySelector('#callToUsernameInput'); var callBtn = document.querySelector('#callBtn'); var hangUpBtn = document.querySelector('#hangUpBtn'); var localAudio = document.querySelector('#localAudio'); var remoteAudio = document.querySelector('#remoteAudio'); var yourConn; var stream; callPage.style.display = "none"; // Login when the user clicks the button loginBtn.addEventListener("click", function (event) { name = usernameInput.value; if (name.length > 0) { send({ type: "login", name: name }); } }); function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** //getting local audio stream navigator.webkitGetUserMedia({ video: false, audio: true }, function (myStream) { stream = myStream; //displaying local audio stream on the page localAudio.src = window.URL.createObjectURL(stream); //using Google public stun server var configuration = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; yourConn = new webkitRTCPeerConnection(configuration); // setup stream listening yourConn.addStream(stream); //when a remote user adds stream to the peer connection, we display it yourConn.onaddstream = function (e) { remoteAudio.src = window.URL.createObjectURL(e.stream); }; // Setup ice handling yourConn.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; }, function (error) { console.log(error); }); } }; //initiating a call callBtn.addEventListener("click", function () { var callToUsername = callToUsernameInput.value; if (callToUsername.length > 0) { connectedUser = callToUsername; // create an offer yourConn.createOffer(function (offer) { send({ type: "offer", offer: offer }); yourConn.setLocalDescription(offer); }, function (error) { alert("Error when creating an offer"); }); } }); //when somebody sends us an offer function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //create an answer to an offer yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //when we got an answer from a remote user function handleAnswer(answer) { yourConn.setRemoteDescription(new RTCSessionDescription(answer)); }; //when we got an ice candidate from a remote user function handleCandidate(candidate) { yourConn.addIceCandidate(new RTCIceCandidate(candidate)); }; //hang up hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; remoteAudio.src = null; yourConn.close(); yourConn.onicecandidate = null; yourConn.onaddstream = null; };
WebRTC - 文本演示
在本章中,我们将构建一个客户端应用程序,允许两个在不同设备上的用户使用WebRTC相互发送消息。我们的应用程序将有两个页面。一个用于登录,另一个用于向另一个用户发送消息。
这两个页面将是 div 标签。大多数输入都是通过简单的事件处理程序完成的。
信令服务器
要创建 WebRTC 连接,客户端必须能够在不使用 WebRTC 对等连接的情况下传输消息。这正是我们将使用 HTML5 WebSockets 的地方 - 两个端点(Web 服务器和 Web 浏览器)之间的双向套接字连接。现在让我们开始使用 WebSocket 库。创建 server.js 文件并插入以下代码:
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //when a user connects to our sever wss.on('connection', function(connection) { console.log("user connected"); //when server gets a message from a connected user connection.on('message', function(message) { console.log("Got message from a user:", message); }); connection.send("Hello from server"); });
第一行需要我们已经安装的WebSocket库。然后我们在端口9090上创建一个socket服务器。接下来,我们监听connection事件。当用户与服务器建立WebSocket连接时,将执行此代码。然后我们监听用户发送的任何消息。最后,我们向已连接的用户发送一个“Hello from server”的响应。
在我们的信令服务器中,我们将为每个连接使用基于字符串的用户名,以便我们知道将消息发送到哪里。让我们稍微更改一下我们的connection处理程序 -
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } });
这样我们只接受JSON消息。接下来,我们需要将所有已连接的用户存储在某个地方。我们将为此使用一个简单的Javascript对象。更改我们文件的最顶部 -
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {};
我们将为来自客户端的每条消息添加一个type字段。例如,如果用户想要登录,他将发送login类型的消息。让我们定义它 -
connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged:", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; default: sendTo(connection, { type: "error", message: "Command no found: " + data.type }); break; } });
如果用户发送一条类型为login的消息,我们将 -
- 检查是否有人已使用此用户名登录。
- 如果是,则告诉用户他登录未成功。
- 如果没有人使用此用户名,我们将用户名作为键添加到connection对象中。
- 如果未识别命令,我们将发送错误。
以下代码是用于向连接发送消息的辅助函数。将其添加到server.js文件中 -
function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
当用户断开连接时,我们应该清理其连接。当close事件触发时,我们可以删除用户。将以下代码添加到connection处理程序中 -
connection.on("close", function() { if(connection.name) { delete users[connection.name]; } });
成功登录后,用户想要呼叫另一个用户。他应该向另一个用户发出offer来实现它。添加offer处理程序 -
case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null){ //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); break;
首先,我们获取我们尝试呼叫的用户connection。如果存在,我们将向他发送offer详细信息。我们还在connection对象中添加了otherName。这是为了方便以后查找。
对响应的回复具有与我们在 offer 处理程序中使用的类似模式。我们的服务器只是将所有消息作为 answer 传递给另一个用户。在 offer 处理程序之后添加以下代码:
case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break;
最后一部分是在用户之间处理ICE候选者。我们使用相同的技术,只是在用户之间传递消息。主要区别在于,候选者消息可能每个用户多次发生,并且可以按任何顺序发生。添加candidate处理程序 -
case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break;
为了允许我们的用户断开与另一个用户的连接,我们应该实现挂断功能。它还会告诉服务器删除所有用户引用。添加 leave 处理程序:
case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break;
这也会向另一个用户发送leave事件,以便他可以相应地断开其对等连接。我们还应该处理用户从信令服务器断开连接的情况。让我们修改我们的close处理程序 -
connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } });
以下是我们的信令服务器的完整代码:
//require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({port: 9090}); //all connected to the server users var users = {}; //when a user connects to our sever wss.on('connection', function(connection) { console.log("User connected"); //when server gets a message from a connected user connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged", data.name); //if anyone is logged in with this username then refuse if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null) { //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break; case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break; case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break; case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break; default: sendTo(connection, { type: "error", message: "Command not found: " + data.type }); break; } }); //when user exits, for example closes a browser window //this may help if we are still in "offer","answer" or "candidate" state connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } }); connection.send("Hello world"); }); function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
客户端应用程序
测试此应用程序的一种方法是打开两个浏览器选项卡,并尝试相互发送消息。
首先,我们需要安装bootstrap库。Bootstrap是一个用于开发 Web 应用程序的前端框架。您可以在https://bootstrap.ac.cn/.了解更多信息。创建一个文件夹,例如“textchat”。这将是我们的根应用程序文件夹。在此文件夹内创建一个文件package.json(它对于管理 npm 依赖项是必要的)并添加以下内容:
{ "name": "webrtc-textochat", "version": "0.1.0", "description": "webrtc-textchat", "author": "Author", "license": "BSD-2-Clause" }
然后运行npm install bootstrap。这将在textchat/node_modules文件夹中安装bootstrap库。
现在我们需要创建一个基本的 HTML 页面。在根文件夹中创建一个 index.html 文件,其中包含以下代码:
<html> <head> <title>WebRTC Text Demo</title> <link rel = "stylesheet" href = "node_modules/bootstrap/dist/css/bootstrap.min.css"/> </head> <style> body { background: #eee; padding: 5% 0; } </style> <body> <div id = "loginPage" class = "container text-center"> <div class = "row"> <div class = "col-md-4 col-md-offset-4"> <h2>WebRTC Text Demo. Please sign in</h2> <label for = "usernameInput" class = "sr-only">Login</label> <input type = "email" id = "usernameInput" class = "form-control formgroup" placeholder = "Login" required = "" autofocus = ""> <button id = "loginBtn" class = "btn btn-lg btn-primary btnblock"> Sign in</button> </div> </div> </div> <div id = "callPage" class = "call-page container"> <div class = "row"> <div class = "col-md-4 col-md-offset-4 text-center"> <div class = "panel panel-primary"> <div class = "panel-heading">Text chat</div> <div id = "chatarea" class = "panel-body text-left"></div> </div> </div> </div> <div class = "row text-center form-group"> <div class = "col-md-12"> <input id = "callToUsernameInput" type = "text" placeholder = "username to call" /> <button id = "callBtn" class = "btn-success btn">Call</button> <button id = "hangUpBtn" class = "btn-danger btn">Hang Up</button> </div> </div> <div class = "row text-center"> <div class = "col-md-12"> <input id = "msgInput" type = "text" placeholder = "message" /> <button id = "sendMsgBtn" class = "btn-success btn">Send</button> </div> </div> </div> <script src = "client.js"></script> </body> </html>
此页面应该很熟悉。我们添加了bootstrap css 文件。我们还定义了两个页面。最后,我们创建了几个文本字段和按钮来获取用户的信息。在“聊天”页面上,您应该看到带有“chatarea”ID的div标签,所有我们的消息都将显示在其中。请注意,我们添加了到client.js文件的链接。
现在我们需要与我们的信令服务器建立连接。在根文件夹中创建 client.js 文件,其中包含以下代码:
//our username var name; var connectedUser; //connecting to our signaling server var conn = new WebSocket('ws://127.0.0.1:9090'); conn.onopen = function () { console.log("Connected to the signaling server"); }; //when we got a message from a signaling server conn.onmessage = function (msg) { console.log("Got message", msg.data); var data = JSON.parse(msg.data); switch(data.type) { case "login": handleLogin(data.success); break; //when somebody wants to call us case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //when a remote peer sends an ice candidate to us case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //alias for sending JSON encoded messages function send(message) { //attach the other peer username to our messages if (connectedUser) { message.name = connectedUser; } conn.send(JSON.stringify(message)); };
现在通过 node server 运行我们的信令服务器。然后,在根文件夹中运行 static 命令并在浏览器中打开页面。您应该看到以下控制台输出:
下一步是实现具有唯一用户名的用户登录。我们只需将用户名发送到服务器,服务器随后会告诉我们它是否已被占用。将以下代码添加到您的 client.js 文件中:
//****** //UI selectors block //****** var loginPage = document.querySelector('#loginPage'); var usernameInput = document.querySelector('#usernameInput'); var loginBtn = document.querySelector('#loginBtn'); var callPage = document.querySelector('#callPage'); var callToUsernameInput = document.querySelector('#callToUsernameInput'); var callBtn = document.querySelector('#callBtn'); var hangUpBtn = document.querySelector('#hangUpBtn'); callPage.style.display = "none"; // Login when the user clicks the button loginBtn.addEventListener("click", function (event) { name = usernameInput.value; if (name.length > 0) { send({ type: "login", name: name }); } }); function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** } };
首先,我们选择页面上一些元素的引用。然后,我们隐藏呼叫页面。然后,我们在登录按钮上添加一个事件监听器。当用户点击它时,我们将他的用户名发送到服务器。最后,我们实现了handleLogin回调。如果登录成功,我们将显示呼叫页面,设置对等连接,并创建一个数据通道。
要使用数据通道启动对等连接,我们需要:
- 创建RTCPeerConnection对象
- 在我们的RTCPeerConnection对象中创建一个数据通道
将以下代码添加到“UI 选择器块”中:
var msgInput = document.querySelector('#msgInput'); var sendMsgBtn = document.querySelector('#sendMsgBtn'); var chatArea = document.querySelector('#chatarea'); var yourConn; var dataChannel;
修改 handleLogin 函数:
function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** //using Google public stun server var configuration = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; yourConn = new webkitRTCPeerConnection(configuration, {optional: [{RtpDataChannels: true}]}); // Setup ice handling yourConn.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; //creating data channel dataChannel = yourConn.createDataChannel("channel1", {reliable:true}); dataChannel.onerror = function (error) { console.log("Ooops...error:", error); }; //when we receive a message from the other peer, display it on the screen dataChannel.onmessage = function (event) { chatArea.innerHTML += connectedUser + ": " + event.data + "<br />"; }; dataChannel.onclose = function () { console.log("data channel is closed"); }; } };
如果登录成功,应用程序将创建RTCPeerConnection对象并设置onicecandidate处理程序,该处理程序将所有找到的icecandidate发送到另一个对等方。它还创建一个dataChannel。请注意,在创建RTCPeerConnection对象时,构造函数中的第二个参数是可选的:[{RtpDataChannels: true}] 如果您使用的是Chrome或Opera,则该参数是必需的。下一步是向另一个对等方创建要约。一旦用户收到要约,他就会创建一个答案并开始交换ICE候选。将以下代码添加到client.js文件中:
//initiating a call callBtn.addEventListener("click", function () { var callToUsername = callToUsernameInput.value; if (callToUsername.length > 0) { connectedUser = callToUsername; // create an offer yourConn.createOffer(function (offer) { send({ type: "offer", offer: offer }); yourConn.setLocalDescription(offer); }, function (error) { alert("Error when creating an offer"); }); } }); //when somebody sends us an offer function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //create an answer to an offer yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //when we got an answer from a remote user function handleAnswer(answer) { yourConn.setRemoteDescription(new RTCSessionDescription(answer)); }; //when we got an ice candidate from a remote user function handleCandidate(candidate) { yourConn.addIceCandidate(new RTCIceCandidate(candidate)); };
我们在 Call 按钮上添加了一个 click 处理程序,它会发起一个 offer。然后,我们实现了 onmessage 处理程序期望的几个处理程序。它们将异步处理,直到两个用户都建立连接。
下一步是实现挂断功能。这将停止数据传输并告诉另一个用户关闭数据通道。添加以下代码:
//hang up hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; yourConn.close(); yourConn.onicecandidate = null; };
当用户点击 Hang Up 按钮时:
- 它将向另一个用户发送“离开”消息。
- 它将关闭RTCPeerConnection以及数据通道。
最后一步是向另一个对等方发送消息。将“点击”处理程序添加到“发送消息”按钮:
//when user clicks the "send message" button sendMsgBtn.addEventListener("click", function (event) { var val = msgInput.value; chatArea.innerHTML += name + ": " + val + "<br />"; //sending a message to a connected peer dataChannel.send(val); msgInput.value = ""; });
现在运行代码。您应该能够使用两个浏览器选项卡登录服务器。然后,您可以向另一个用户设置对等连接并向他发送消息,以及通过点击“挂断”按钮关闭数据通道。
以下是完整的 client.js 文件:
//our username var name; var connectedUser; //connecting to our signaling server var conn = new WebSocket('ws://127.0.0.1:9090'); conn.onopen = function () { console.log("Connected to the signaling server"); }; //when we got a message from a signaling server conn.onmessage = function (msg) { console.log("Got message", msg.data); var data = JSON.parse(msg.data); switch(data.type) { case "login": handleLogin(data.success); break; //when somebody wants to call us case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //when a remote peer sends an ice candidate to us case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //alias for sending JSON encoded messages function send(message) { //attach the other peer username to our messages if (connectedUser) { message.name = connectedUser; } conn.send(JSON.stringify(message)); }; //****** //UI selectors block //****** var loginPage = document.querySelector('#loginPage'); var usernameInput = document.querySelector('#usernameInput'); var loginBtn = document.querySelector('#loginBtn'); var callPage = document.querySelector('#callPage'); var callToUsernameInput = document.querySelector('#callToUsernameInput'); var callBtn = document.querySelector('#callBtn'); var hangUpBtn = document.querySelector('#hangUpBtn'); var msgInput = document.querySelector('#msgInput'); var sendMsgBtn = document.querySelector('#sendMsgBtn'); var chatArea = document.querySelector('#chatarea'); var yourConn; var dataChannel; callPage.style.display = "none"; // Login when the user clicks the button loginBtn.addEventListener("click", function (event) { name = usernameInput.value; if (name.length > 0) { send({ type: "login", name: name }); } }); function handleLogin(success) { if (success === false) { alert("Ooops...try a different username"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** //using Google public stun server var configuration = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; yourConn = new webkitRTCPeerConnection(configuration, {optional: [{RtpDataChannels: true}]}); // Setup ice handling yourConn.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; //creating data channel dataChannel = yourConn.createDataChannel("channel1", {reliable:true}); dataChannel.onerror = function (error) { console.log("Ooops...error:", error); }; //when we receive a message from the other peer, display it on the screen dataChannel.onmessage = function (event) { chatArea.innerHTML += connectedUser + ": " + event.data + "<br />"; }; dataChannel.onclose = function () { console.log("data channel is closed"); }; } }; //initiating a call callBtn.addEventListener("click", function () { var callToUsername = callToUsernameInput.value; if (callToUsername.length > 0) { connectedUser = callToUsername; // create an offer yourConn.createOffer(function (offer) { send({ type: "offer", offer: offer }); yourConn.setLocalDescription(offer); }, function (error) { alert("Error when creating an offer"); }); } }); //when somebody sends us an offer function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //create an answer to an offer yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //when we got an answer from a remote user function handleAnswer(answer) { yourConn.setRemoteDescription(new RTCSessionDescription(answer)); }; //when we got an ice candidate from a remote user function handleCandidate(candidate) { yourConn.addIceCandidate(new RTCIceCandidate(candidate)); }; //hang up hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; yourConn.close(); yourConn.onicecandidate = null; }; //when user clicks the "send message" button sendMsgBtn.addEventListener("click", function (event) { var val = msgInput.value; chatArea.innerHTML += name + ": " + val + "<br />"; //sending a message to a connected peer dataChannel.send(val); msgInput.value = ""; });
WebRTC - 安全性
在本章中,我们将向我们在“WebRTC信令”章节中创建的信令服务器添加安全功能。将有两个增强功能:
- 使用Redis数据库进行用户身份验证
- 启用安全套接字连接
首先,您应该安装Redis。
从https://redis.ac.cn/download下载最新的稳定版本(在我的情况下为3.05)
解压缩它
在下载的文件夹内运行sudo make install
安装完成后,运行make test以检查一切是否正常工作。
Redis有两个可执行命令:
redis-cli - Redis的命令行界面(客户端部分)
redis-server - Redis数据存储
要运行Redis服务器,请在终端控制台中键入redis-server。您应该看到以下内容:
现在打开一个新的终端窗口并运行redis-cli以打开客户端应用程序。
基本上,Redis是一个键值数据库。要使用字符串值创建键,您应该使用SET命令。要读取键值,您应该使用GET命令。让我们为他们添加两个用户和密码。键将是用户名,这些键的值将是相应的密码。
现在我们应该修改我们的信令服务器以添加用户身份验证。将以下代码添加到server.js文件的顶部:
//require the redis library in Node.js var redis = require("redis"); //creating the redis client object var redisClient = redis.createClient();
在上面的代码中,我们要求Node.js使用Redis库并为我们的服务器创建Redis客户端。
要添加身份验证,请修改连接对象上的message处理程序:
//when a user connects to our sever wss.on('connection', function(connection) { console.log("user connected"); //when server gets a message from a connected user connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //check whether a user is authenticated if(data.type != "login") { //if user is not authenticated if(!connection.isAuth) { sendTo(connection, { type: "error", message: "You are not authenticated" }); return; } } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged:", data.name); //get password for this username from redis database redisClient.get(data.name, function(err, reply) { //check if password matches with the one stored in redis var loginSuccess = reply === data.password; //if anyone is logged in with this username or incorrect password then refuse if(users[data.name] || !loginSuccess) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; connection.isAuth = true; sendTo(connection, { type: "login", success: true }); } }); break; } }); } //... //*****other handlers*******
在上面的代码中,如果用户尝试登录,我们从Redis获取他的密码,检查它是否与存储的密码匹配,如果成功,我们将他的用户名存储在服务器上。我们还将isAuth标志添加到连接中以检查用户是否已通过身份验证。请注意此代码:
//check whether a user is authenticated if(data.type != "login") { //if user is not authenticated if(!connection.isAuth) { sendTo(connection, { type: "error", message: "You are not authenticated" }); return; } }
如果未经身份验证的用户尝试发送要约或离开连接,我们只需发送错误消息。
下一步是启用安全套接字连接。强烈建议用于WebRTC应用程序。PKI(公钥基础设施)是CA(证书颁发机构)的数字签名。然后,用户检查用于签署证书的私钥是否与CA证书的公钥匹配。出于开发目的,我们将使用自签名安全证书。
我们将使用openssl。它是一个开源工具,实现了SSL(安全套接字层)和TLS(传输层安全)协议。它通常在Unix系统上默认安装。运行openssl version -a以检查它是否已安装。
要生成公钥和私钥安全证书,您应该按照以下步骤操作:
生成一个临时的服务器密码密钥
openssl genrsa -des3 -passout pass:x -out server.pass.key 2048
生成服务器私钥
openssl rsa -passin pass:12345 -in server.pass.key -out server.key
生成签名请求。系统会询问您有关您公司的一些其他问题。一直按“Enter”键即可。
openssl req -new -key server.key -out server.csr
生成证书
openssl x509 -req -days 1095 -in server.csr -signkey server.key -out server.crt
现在您有两个文件,证书(server.crt)和私钥(server.key)。将它们复制到信令服务器根文件夹中。
要启用安全套接字连接,请修改我们的信令服务器。
//require file system module var fs = require('fs'); var httpServ = require('https'); //https://github.com/visionmedia/superagent/issues/205 process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; //out secure server will bind to the port 9090 var cfg = { port: 9090, ssl_key: 'server.key', ssl_cert: 'server.crt' }; //in case of http request just send back "OK" var processRequest = function(req, res) { res.writeHead(200); res.end("OK"); }; //create our server with SSL enabled var app = httpServ.createServer({ key: fs.readFileSync(cfg.ssl_key), cert: fs.readFileSync(cfg.ssl_cert) }, processRequest).listen(cfg.port); //require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({server: app}); //all connected to the server users var users = {}; //require the redis library in Node.js var redis = require("redis"); //creating the redis client object var redisClient = redis.createClient(); //when a user connects to our sever wss.on('connection', function(connection){ //...other code
在上面的代码中,我们要求fs库读取私钥和证书,使用绑定端口和私钥和证书路径创建cfg对象。然后,我们使用我们的密钥以及端口9090上的WebSocket服务器创建一个HTTPS服务器。
现在在Opera中打开https://127.0.0.1:9090。您应该看到以下内容:
点击“继续”按钮。您应该看到“确定”消息。
要测试我们的安全信令服务器,我们将修改我们在“WebRTC文本演示”教程中创建的聊天应用程序。我们只需要添加一个密码字段。以下是完整的index.html文件:
<html> <head> <title>WebRTC Text Demo</title> <link rel = "stylesheet" href = "node_modules/bootstrap/dist/css/bootstrap.min.css"/> </head> <style> body { background: #eee; padding: 5% 0; } </style> <body> <div id = "loginPage" class = "container text-center"> <div class = "row"> <div class = "col-md-4 col-md-offset-4"> <h2>WebRTC Text Demo. Please sign in</h2> <label for = "usernameInput" class = "sr-only">Login</label> <input type = "email" id = "usernameInput" class = "form-control formgroup" placeholder = "Login" required = "" autofocus = ""> <input type = "text" id = "passwordInput" class = "form-control form-group" placeholder = "Password" required = "" autofocus = ""> <button id = "loginBtn" class = "btn btn-lg btn-primary btnblock" >Sign in</button> </div> </div> </div> <div id = "callPage" class = "call-page container"> <div class = "row"> <div class = "col-md-4 col-md-offset-4 text-center"> <div class = "panel panel-primary"> <div class = "panel-heading">Text chat</div> <div id = "chatarea" class = "panel-body text-left"></div> </div> </div> </div> <div class = "row text-center form-group"> <div class = "col-md-12"> <input id = "callToUsernameInput" type = "text" placeholder = "username to call" /> <button id = "callBtn" class = "btn-success btn">Call</button> <button id = "hangUpBtn" class = "btn-danger btn">Hang Up</button> </div> </div> <div class = "row text-center"> <div class = "col-md-12"> <input id = "msgInput" type = "text" placeholder = "message" /> <button id = "sendMsgBtn" class = "btn-success btn">Send</button> </div> </div> </div> <script src = "client.js"></script> </body> </html>
我们还需要通过此行var conn = new WebSocket('wss://127.0.0.1:9090');在client.js文件中启用安全套接字连接。请注意wss协议。然后,必须修改登录按钮处理程序以与用户名一起发送密码:
loginBtn.addEventListener("click", function (event) { name = usernameInput.value; var pwd = passwordInput.value; if (name.length > 0) { send({ type: "login", name: name, password: pwd }); } });
以下是完整的 client.js 文件:
//our username var name; var connectedUser; //connecting to our signaling server var conn = new WebSocket('wss://127.0.0.1:9090'); conn.onopen = function () { console.log("Connected to the signaling server"); }; //when we got a message from a signaling server conn.onmessage = function (msg) { console.log("Got message", msg.data); var data = JSON.parse(msg.data); switch(data.type) { case "login": handleLogin(data.success); break; //when somebody wants to call us case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //when a remote peer sends an ice candidate to us case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //alias for sending JSON encoded messages function send(message) { //attach the other peer username to our messages if (connectedUser) { message.name = connectedUser; } conn.send(JSON.stringify(message)); }; //****** //UI selectors block //****** var loginPage = document.querySelector('#loginPage'); var usernameInput = document.querySelector('#usernameInput'); var passwordInput = document.querySelector('#passwordInput'); var loginBtn = document.querySelector('#loginBtn'); var callPage = document.querySelector('#callPage'); var callToUsernameInput = document.querySelector('#callToUsernameInput'); var callBtn = document.querySelector('#callBtn'); var hangUpBtn = document.querySelector('#hangUpBtn'); var msgInput = document.querySelector('#msgInput'); var sendMsgBtn = document.querySelector('#sendMsgBtn'); var chatArea = document.querySelector('#chatarea'); var yourConn; var dataChannel; callPage.style.display = "none"; // Login when the user clicks the button loginBtn.addEventListener("click", function (event) { name = usernameInput.value; var pwd = passwordInput.value; if (name.length > 0) { send({ type: "login", name: name, password: pwd }); } }); function handleLogin(success) { if (success === false) { alert("Ooops...incorrect username or password"); } else { loginPage.style.display = "none"; callPage.style.display = "block"; //********************** //Starting a peer connection //********************** //using Google public stun server var configuration = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; yourConn = new webkitRTCPeerConnection(configuration, {optional: [{RtpDataChannels: true}]}); // Setup ice handling yourConn.onicecandidate = function (event) { if (event.candidate) { send({ type: "candidate", candidate: event.candidate }); } }; //creating data channel dataChannel = yourConn.createDataChannel("channel1", {reliable:true}); dataChannel.onerror = function (error) { console.log("Ooops...error:", error); }; //when we receive a message from the other peer, display it on the screen dataChannel.onmessage = function (event) { chatArea.innerHTML += connectedUser + ": " + event.data + "<br />"; }; dataChannel.onclose = function () { console.log("data channel is closed"); }; } }; //initiating a call callBtn.addEventListener("click", function () { var callToUsername = callToUsernameInput.value; if (callToUsername.length > 0) { connectedUser = callToUsername; // create an offer yourConn.createOffer(function (offer) { send({ type: "offer", offer: offer }); yourConn.setLocalDescription(offer); }, function (error) { alert("Error when creating an offer"); }); } }); //when somebody sends us an offer function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //create an answer to an offer yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //when we got an answer from a remote user function handleAnswer(answer) { yourConn.setRemoteDescription(new RTCSessionDescription(answer)); }; //when we got an ice candidate from a remote user function handleCandidate(candidate) { yourConn.addIceCandidate(new RTCIceCandidate(candidate)); }; //hang up hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; yourConn.close(); yourConn.onicecandidate = null; }; //when user clicks the "send message" button sendMsgBtn.addEventListener("click", function (event) { var val = msgInput.value; chatArea.innerHTML += name + ": " + val + "<br />"; //sending a message to a connected peer dataChannel.send(val); msgInput.value = ""; });
现在通过node server运行我们的安全信令服务器。在修改后的聊天演示文件夹中运行node static。在两个浏览器选项卡中打开localhost:8080。尝试登录。请记住,只有“user1”和“password1”以及“user2”和“password2”才能登录。然后建立RTCPeerConnection(呼叫另一个用户)并尝试发送消息。
以下是我们安全信令服务器的完整代码:
//require file system module var fs = require('fs'); var httpServ = require('https'); //https://github.com/visionmedia/superagent/issues/205 process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; //out secure server will bind to the port 9090 var cfg = { port: 9090, ssl_key: 'server.key', ssl_cert: 'server.crt' }; //in case of http request just send back "OK" var processRequest = function(req, res){ res.writeHead(200); res.end("OK"); }; //create our server with SSL enabled var app = httpServ.createServer({ key: fs.readFileSync(cfg.ssl_key), cert: fs.readFileSync(cfg.ssl_cert) }, processRequest).listen(cfg.port); //require our websocket library var WebSocketServer = require('ws').Server; //creating a websocket server at port 9090 var wss = new WebSocketServer({server: app}); //all connected to the server users var users = {}; //require the redis library in Node.js var redis = require("redis"); //creating the redis client object var redisClient = redis.createClient(); //when a user connects to our sever wss.on('connection', function(connection) { console.log("user connected"); //when server gets a message from a connected user connection.on('message', function(message) { var data; //accepting only JSON messages try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //check whether a user is authenticated if(data.type != "login") { //if user is not authenticated if(!connection.isAuth) { sendTo(connection, { type: "error", message: "You are not authenticated" }); return; } } //switching type of the user message switch (data.type) { //when a user tries to login case "login": console.log("User logged:", data.name); //get password for this username from redis database redisClient.get(data.name, function(err, reply) { //check if password matches with the one stored in redis var loginSuccess = reply === data.password; //if anyone is logged in with this username or incorrect password then refuse if(users[data.name] || !loginSuccess) { sendTo(connection, { type: "login", success: false }); } else { //save user connection on the server users[data.name] = connection; connection.name = data.name; connection.isAuth = true; sendTo(connection, { type: "login", success: true }); } }); break; case "offer": //for ex. UserA wants to call UserB console.log("Sending offer to: ", data.name); //if UserB exists then send him offer details var conn = users[data.name]; if(conn != null) { //setting that UserA connected with UserB connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); } break; case "answer": console.log("Sending answer to: ", data.name); //for ex. UserB answers UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break; case "candidate": console.log("Sending candidate to:",data.name); var conn = users[data.name]; if(conn != null) { sendTo(conn, { type: "candidate", candidate: data.candidate }); } break; case "leave": console.log("Disconnecting from", data.name); var conn = users[data.name]; conn.otherName = null; //notify the other user so he can disconnect his peer connection if(conn != null) { sendTo(conn, { type: "leave" }); } break; connection.on("close", function() { if(connection.name) { delete users[connection.name]; if(connection.otherName) { console.log("Disconnecting from ", connection.otherName); var conn = users[connection.otherName]; conn.otherName = null; if(conn != null) { sendTo(conn, { type: "leave" }); } } } }); default: sendTo(connection, { type: "error", message: "Command no found: " + data.type }); break; } }); //when user exits, for example closes a browser window //this may help if we are still in "offer","answer" or "candidate" state connection.on("close", function() { if(connection.name) { delete users[connection.name]; } }); connection.send("Hello from server"); }); function sendTo(connection, message) { connection.send(JSON.stringify(message)); }
总结
在本章中,我们向我们的信令服务器添加了用户身份验证。我们还学习了如何在WebRTC应用程序的范围内创建自签名SSL证书并在其中使用它们。