WebRTC - 文本演示
在本章中,我们将构建一个客户端应用程序,允许两个使用不同设备的用户使用 WebRTC 互相发送消息。我们的应用程序将有两个页面。一个用于登录,另一个用于向另一个用户发送消息。
这两个页面将是 div 标签。大多数输入都是通过简单的事件处理程序完成的。
信令服务器
要创建 WebRTC 连接,客户端必须能够在不使用 WebRTC 对等连接的情况下传输消息。在这里我们将使用 HTML5 WebSockets − 两个端点之间的双向套接字连接 − Web 服务器和 Web 浏览器。现在让我们开始使用 WebSocket 库。创建 server.js 文件并插入以下代码 −
//需要我们的 websocket 库 var WebSocketServer = require('ws').Server; //在端口 9090 处创建一个 websocket 服务器 var wss = new WebSocketServer({port: 9090}); //当用户连接到我们的服务器时 wss.on('connection', function(connection) { console.log("user connected"); //当服务器收到来自已连接用户的消息时 connection.on('message', function(message) { console.log("Got message from a user:", message); }); connection.send("Hello from server"); });
第一行需要我们已经安装的 WebSocket 库。然后我们在端口 9090 上创建一个套接字服务器。接下来,我们监听 connection 事件。当用户与服务器建立 WebSocket 连接时,将执行此代码。然后,我们监听用户发送的任何消息。最后,我们向已连接的用户发送响应,说"来自服务器的问候"。
在我们的信令服务器中,我们将为每个连接使用基于字符串的用户名,以便我们知道将消息发送到哪里。让我们稍微更改一下我们的 connection 处理程序 −
connection.on('message', function(message) { var data; //仅接受 JSON 消息 try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } });
这样我们只接受 JSON 消息。接下来,我们需要将所有连接的用户存储在某个地方。我们将使用一个简单的 Javascript 对象。更改文件顶部的 −
//需要我们的 websocket 库 var WebSocketServer = require('ws').Server; //在端口 9090 处创建一个 websocket 服务器 var wss = new WebSocketServer({port: 9090}); //所有连接到服务器的用户 var users = {};
我们将为来自客户端的每条消息添加一个 type 字段。例如,如果用户想要登录,他会发送 login 类型的消息。让我们定义它 −
connection.on('message', function(message) { var data; //仅接受 JSON 消息 try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //切换用户消息类型 switch (data.type) { //当用户尝试登录时 case "login": console.log("User logged:", data.name); //如果有人用这个用户名登录则拒绝 if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //在服务器上保存用户连接 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 类型的消息,我们 −
- 检查是否有人已经使用此用户名登录。
- 如果是,则告诉用户他尚未成功登录。
- 如果没有人使用此用户名,我们将用户名作为键添加到连接对象。
- 如果无法识别命令,我们会发送错误。
以下代码是用于向连接发送消息的辅助函数。将其添加到 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": //例如,用户 A 想要呼叫用户 B console.log("Sending offer to: ", data.name); //如果 UserB 存在则向他发送优惠详情 var conn = users[data.name]; if(conn != null){ //设置UserA与UserB连接 connection.otherName = data.name; sendTo(conn, { type: "offer", offer: data.offer, name: connection.name }); break;
首先,我们获取我们尝试呼叫的用户的 connection。如果存在,我们会向他发送 offer 详细信息。我们还将 otherName 添加到 connection 对象。这样做是为了以后查找起来更简单。
对响应的回答与我们在 offer 处理程序中使用的模式类似。我们的服务器只是将所有消息作为 answer 传递给另一个用户。在 offer 处理程序后添加以下代码 −
case "answer": console.log("Sending answer to: ", data.name); //例如 UserB 回答 UserA var conn = users[data.name]; if(conn != null) { connection.otherName = data.name; sendTo(conn, { type: "answer", answer: data.answer }); } break;
最后部分是处理用户之间的 ICE 候选。我们使用相同的技术,只是在用户之间传递消息。主要区别在于候选消息可能以任何顺序在每个用户上发生多次。添加 候选 处理程序 −
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; //通知其他用户,以便他可以断开他的对等连接 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" }); } } } });
以下是我们信令服务器的完整代码 −
//需要我们的 websocket 库 var WebSocketServer = require('ws').Server; //在端口 9090 处创建一个 websocket 服务器 var wss = new WebSocketServer({port: 9090}); //所有连接到服务器的用户 var users = {}; //当用户连接到我们的服务器时 wss.on('connection', function(connection) { console.log("User connected"); //当服务器收到来自已连接用户的消息时 connection.on('message', function(message) { var data; //仅接受 JSON 消息 try { data = JSON.parse(message); } catch (e) { console.log("Invalid JSON"); data = {}; } //切换用户消息类型 switch (data.type) { //当用户尝试登录时 case "login": console.log("User logged", data.name); //如果有人用这个用户名登录则拒绝 if(users[data.name]) { sendTo(connection, { type: "login", success: false }); } else { //在服务器上保存用户连接 users[data.name] = connection; connection.name = data.name; sendTo(connection, { type: "login", success: true }); } break; case "offer": //例如,用户 A 想要呼叫用户 B console.log("Sending offer to: ", data.name); //如果 UserB 存在则向他发送优惠详情 var conn = users[data.name]; if(conn != null) { //设置UserA与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); //例如 UserB 回答 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; //通知其他用户,以便他可以断开他的对等连接 if(conn != null) { sendTo(conn, { type: "leave" }); } break; default: sendTo(connection, { type: "error", message: "Command not found: " + data.type }); break; } }); //当用户退出时,例如关闭浏览器窗口 //如果我们仍处于"offer","answer" 或 "candidate" 状态,这可能会有所帮助 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 应用程序的前端框架。您可以在 http://getbootstrap.com/ 了解更多信息。创建一个名为"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 文件 −
//我们的用户名 var name; var ConnectedUser; //连接到我们的信令服务器 var conn = new WebSocket('ws://localhost:9090'); conn.onopen = function () { console.log("已连接到信令服务器"); }; //当我们从信令服务器收到消息时 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; //当有人想打电话给我们时 case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //当远程对等方向我们发送 candidate 时 case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //发送 JSON 编码消息的别名 function send(message) { //将其他对等用户名附加到我们的消息中 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"; // 当用户点击按钮时登录 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"; //********************** //启动对等连接 //********************** } };
首先,我们选择页面上元素的一些引用。然后我们隐藏调用页面。然后,我们在登录按钮上添加一个事件监听器。当用户单击它时,我们会将他的用户名发送到服务器。最后,我们实现 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"; //********************** //启动对等连接 //********************** //使用Google公共stun服务器 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); }; //当我们收到对方的消息时,将其显示在屏幕上 dataChannel.onmessage = function (event) { chatArea.innerHTML += connectedUser + ": " + event.data + "<br />"; }; dataChannel.onclose = function () { console.log("data channel is closed"); }; } };
如果登录成功,应用程序将创建 RTCPeerConnection 对象并设置 onicecandidate 处理程序,该处理程序将所有找到的 icecandidate 发送给另一个对等体。它还会创建一个数据通道。请注意,在创建 RTCPeerConnection 对象时,构造函数中的第二个参数(可选):[{RtpDataChannels: true}](如果您使用的是 Chrome 或 Opera)是必需的。下一步是向另一个对等体创建要约。一旦用户收到要约,他就会创建一个 answer 并开始交易 ICE 候选者。将以下代码添加到 client.js 文件 −
//发起呼叫 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"); }); } }); //当有人向我们发送 offer 时 function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //创建对 offer 的答复 yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //当我们收到远程用户的答复时 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 处理程序,该处理程序启动一个要约。然后我们实现 onmessage 处理程序所期望的几个处理程序。它们将被异步处理,直到两个用户都建立连接。
下一步是实现挂断功能。这将停止传输数据并告诉另一个用户关闭数据通道。添加以下代码 −
//挂断 hangUpBtn.addEventListener("click", function () { send({ type: "leave" }); handleLeave(); }); function handleLeave() { connectedUser = null; yourConn.close(); yourConn.onicecandidate = null; };
当用户点击"挂断"按钮时 −
- 它将向其他用户发送"离开"消息。
- 它将关闭 RTCPeerConnection 以及数据通道。
最后一步是向另一个对等方发送消息。将"点击"处理程序添加到"发送消息"按钮 −
//当用户点击"发送消息"按钮时 sendMsgBtn.addEventListener("click", function (event) { var val = msgInput.value; chatArea.innerHTML += name + ": " + val + "<br />"; //向已连接的对等体发送消息 dataChannel.send(val); msgInput.value = ""; });
现在运行代码。您应该能够使用两个浏览器选项卡登录到服务器。然后,您可以建立与其他用户的对等连接并向他发送消息,以及通过单击"挂断"按钮关闭数据通道。
以下是整个 client.js 文件 −
//我们的用户名 var name; var ConnectedUser; //连接到我们的信令服务器 var conn = new WebSocket('ws://localhost:9090'); conn.onopen = function () { console.log("已连接到信令服务器"); }; //当我们从信令服务器收到消息时 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; //当有人想打电话给我们时 case "offer": handleOffer(data.offer, data.name); break; case "answer": handleAnswer(data.answer); break; //当远程对等方向我们发送 candidate 时 case "candidate": handleCandidate(data.candidate); break; case "leave": handleLeave(); break; default: break; } }; conn.onerror = function (err) { console.log("Got error", err); }; //发送 JSON 编码消息的别名 function send(message) { //将其他对等用户名附加到我们的消息中 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"; // 当用户点击按钮时登录 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"; //********************** //启动对等连接 //********************** //使用Google公共stun服务器 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); }; //当我们收到对方的消息时,将其显示在屏幕上 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"); }); } }); //当有人向我们发送 offer 时 function handleOffer(offer, name) { connectedUser = name; yourConn.setRemoteDescription(new RTCSessionDescription(offer)); //创建对 offer 的答复 yourConn.createAnswer(function (answer) { yourConn.setLocalDescription(answer); send({ type: "answer", answer: answer }); }, function (error) { alert("Error when creating an answer"); }); }; //当我们收到远程用户的答复时 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; }; //当用户点击"发送消息"按钮时 sendMsgBtn.addEventListener("click", function (event) { var val = msgInput.value; chatArea.innerHTML += name + ": " + val + "<br />"; //向已连接的对等体发送消息 dataChannel.send(val); msgInput.value = ""; });