四、构建一个聊天轮盘

掌握 MediaStream 和 PeerJS 的最好方法是构建真实世界的应用,这也是我们在本章中要做的。聊天轮盘是一个网站,它将随机网站访问者配对在一起,进行网络摄像头和基于文本的对话。讨论如何构建聊天轮盘将有助于我们深入研究 PeerJS 和 PeerServer,因为这需要我们将 PeerServer 与 Express 集成在一起。我们还将在我们的网站上添加媒体控件,以便暂停/恢复本地媒体流,并允许用户选择他们想要的麦克风/网络摄像头,这将有助于我们更深入地研究媒体流。我们将实际创建一个聊天轮盘,只允许特定国家的用户聊天,这将需要在连接到对等服务器之前进行额外的验证步骤;因此,让我们更深入地了解 PeerServer 与 Express 的集成。

在本章中,我们将涵盖以下主题:

  • 运行您自己的对等服务器实例
  • 创建自定义对等服务器
  • 将对等服务器与 Express 集成
  • 验证用户是否可以连接到对等服务器
  • 查找连接到服务器的用户的 IP 地址和国家
  • 允许用户直接在网页上使用麦克风和网络摄像头
  • 讨论构建一个完整工作的聊天轮盘的要求

创建自己的对等服务器

在我们开始构建聊天轮盘之前,让我们看看如何运行我们自己的对等服务器实例。

PeerServer 在 npm 云上作为 npm 包提供。让我们创建一个自定义的 PeerServer,并将其与我们在上一章中构建的 PeerJS 应用一起使用。

首先创建一个名为Custom-PeerServer的目录,并在其中放置app.jspackage.json文件。

package.json文件中,放置以下代码并运行npm install命令下载 PeerServer 包:

{
  "name": "Custom-PeerServer",
  "dependencies": {
    "peer": "0.2.8",
    "express": "4.13.3"
  }
}

在写的时候,最新版本的 PeerServer 是 0.2.8。在这里,我们还将下载express包,因为我们需要演示如何将 PeerServer 与 Express 集成。

PeerServer 包提供了一个库来创建自定义的 PeerServer 或将 PeerServer 与 Express 集成,还提供了一个可执行文件来直接创建我们自己的 PeerServer 实例,而无需任何自定义。

从外壳运行对等服务器

如果您想直接从 shell 运行自己的 PeerServer 实例而不进行任何定制,那么在Custom-PeerServer/node_modules/peer/bin目录下运行以下命令:

./peerjs port 8080

它现在应该打印以下命令:

Started PeerServer on ::, port: 8080, path: / (v. 0.2.8)

这确认了PeerServer正在运行。要测试PeerServer实例是否工作,请转到我们在上一章中创建的应用的index.html文件,并替换以下代码:

peer = new Peer(id, {key: ""});

前面的代码将替换为下面的代码:

peer = new Peer(id, {host: "localhost", port: 8080});

现在运行应用,它应该像往常一样工作。

使用对等服务器库

PeerServer 库是用来创建自定义的 PeerServer。对等服务器库还允许我们将对等服务器与快速服务器集成在一起。

创建自定义对等服务器

这里有一个示例代码,演示了如何创建自己的自定义对等服务器。将以下代码放入app.js文件,运行node app.js命令启动服务器:

var PeerServer = require("peer").PeerServer;
var server = PeerServer({port: 8080});

server.on("connection", function(id) {
  console.log(id + " has connected to the PeerServer");
});

server.on("disconnect", function(id) {
  console.log(id + " has disconnected from the PeerServer");
});

这里,代码的前两行创建了自定义的对等服务器。然后,我们附加了事件处理程序,当用户连接到对等服务器或从对等服务器断开连接时,这些事件处理程序将被触发。自定义对等服务器不提供检查对等服务器是否被允许连接到对等服务器的应用编程接口。它只允许我们在对等点连接后或对等点断开连接时做一些事情。

要测试自定义对等服务器是否工作,请转到我们在上一章创建的应用的index.html文件,并替换以下代码:

peer = new Peer(id, {key: ""});

前面的代码将替换为下面的代码:

peer = new Peer(id, {host: "localhost", port: 8080});

现在运行应用,它应该像往常一样工作。

将对等服务器与快速服务器集成

我们还可以将 PeerServer 与快递服务器进行集成,也就是说快递服务器的特定路径会提供信令服务。将对等服务器与快速服务器集成的主要优势是,我们可以检查对等服务器是否被允许连接到对等服务器,如果不被允许,我们可以阻止对等服务器使用它。

下面是一个示例代码,演示了如何将对等服务器与快速服务器集成。将以下代码放入app.js文件中,运行node app.js命令启动服务器:

var express = require("express");
var app = express();

var server = app.listen(8080);

app.use("/signaling", function(httpRequest, httpResponse, next){
  //check whether peer is allowed to connect or not.

  next();
});

var ExpressPeerServer = require("peer").ExpressPeerServer(server, {debug: true});

app.use("/signaling", ExpressPeerServer);

ExpressPeerServer.on("connection", function(id){

});

ExpressPeerServer.on("disconnect", function(id){

});

这里我们使用PeerServer库提供的中间件来集成 PeerServer 和 Express。这里,对等服务器在/signaling路径上可用。你可以使用任何你想使用的路径。

PeerServer库没有提供任何方法来检查对等体是否被允许连接到 PeerServer,所以我们使用我们自己的技术,也就是说,我们在ExpressPeerServer中间件之上附加了另一个中间件,它执行这个检查。虽然这种技术看起来不错,但是如果我们的定制中间件阻止请求继续进行,那么 PeerServer 将触发connectiondisconnect事件,并破坏前端的Peer实例。

您可以在https://www.npmjs.com/package/peer了解更多关于 PeerServer 的信息。

创建聊天轮盘

我们将构建的聊天轮盘只针对居住在印度的人,也就是说,如果对等体的 IP 地址没有解析到印度,对等体就无法连接到对等服务器。我们添加了这个过滤器,使网站的代码更加复杂,这样您就可以了解如何检查用户是否被允许连接到对等服务器。

我们将使用一台服务器来服务网页,并充当对等服务器,也就是说,我们将对等服务器与快速服务器集成在一起。

我们不会设计聊天轮盘的前端。我们将只专注于构建架构和功能。

本章的练习文件包含两个目录:ChatrouletteCustom-PeerServer。在Chatroulette目录中,有两个目录:InitialFinal。在Final目录中,你会找到完整的 chatroulette 源代码。在Initial目录下,你只会找到我们聊天轮盘的 HTML 代码。Initial目录是帮助你快速开始建立聊天轮盘。

您将把与站点前端功能相关的代码放在Initial/public/js/main.js文件中,您将把与服务器端功能相关的代码放在Initial/app.js文件中。

构建后端

我们的网站基本上会包含三个 URL 端点:一个服务主页的根路径,/find路径查找一个免费用户的 ID 进行聊天,最后/signaling路径作为 PeerServer 的端点。

每个用户都有一个由对等服务器生成的唯一标识。用户要使用/find网址检索另一个免费用户的 ID,必须先连接到 PeerServer。

服务器将维护两个不同的数组,即第一个数组包含连接到对等服务器的用户的标识,第二个数组包含需要伙伴聊天的用户的标识。

让我们开始构建后端。将以下代码放入app.js文件中,以创建我们的网络服务器并服务于我们网站的主页:

var express = require("express");
var app = express();

app.use(express.static(__dirname + "/public"));

app.get("/", function(httpRequest, httpResponse, next){
  httpResponse.sendFile(__dirname + "/public/html/index.html");
})

var server = app.listen(8080);

这里我们将index.html文件作为我们的主页。运行node app.js命令启动服务器。我假设你在本地主机上运行node.js,那么打开浏览器上的http://localhost:8080/网址查看主页。主页应该类似于下图:

Building the backend

以下是主页的不同元素:

  • 在主页的顶部,我们将显示PeerServer连接、DataConnectionMediaConnection的状态。
  • 然后我们将显示一个视频元素和消息框。MediaStream的远程对等体将被呈现在视频元素上。
  • 然后,我们有下拉框供用户选择他们想要使用的麦克风和网络摄像头,如果他们有多个麦克风或网络摄像头连接到他们的计算机。
  • 然后我们有复选框,允许用户暂停或恢复他们的音频和视频。
  • 最后,我们有一个按钮,允许用户断开与当前用户的连接,并与另一个用户聊天。

HTML 页面中的每个交互元素都有一个与之关联的标识。在对网站前端进行编码时,我们将使用他们的 id 来获取他们的参考。

现在让我们创建我们的信令服务器。这是这个的代码。将其放入app.js文件中:

var requestIp = require("request-ip");
var geoip = require("geoip-lite");

app.use("/signaling", function(httpRequest, httpResponse, next){

  var clientIp = requestIp.getClientIp(httpRequest);
  var geo = geoip.lookup(clientIp);

  if(geo != null)
  {
    if(geo.country == "IN")
    {
      next();
    }
    else
    {
      httpResponse.end();
    }
  }
  else
  {
    next();
  }
});

var ExpressPeerServer = require("peer").ExpressPeerServer(server);

app.use("/signaling", ExpressPeerServer);

var connected_users = [];

ExpressPeerServer.on("connection", function(id){
  var idx = connected_users.indexOf(id); 
  if(idx === -1) //only add id if it's not in the array yet
  {
    connected_users.push(id);
  }
});

ExpressPeerServer.on("disconnect", function(id){
  var idx = connected_users.indexOf(id); 
  if(idx !== -1) 
  {
    connected_users.splice(idx, 1);
  }

  idx = waiting_peers.indexOf(id);
  if(idx !== -1) 
  {
    waiting_peers.splice(idx, 1);
  }  
});

以下是代码的工作原理:

  • 在用户可以连接到 PeerServer 之前,我们会找到用户的 IP 地址所属的国家。我们将使用request-ip模块找到 IP 地址,并使用geoip-lite模块将 IP 地址解析到国家。如果国家是IN或者国家名称无法解析,那么我们将允许用户通过触发下一个中间件来连接到对等服务器,否则我们将通过发送空响应来阻止他们。
  • 当用户连接到对等服务器时,我们将在connected_users数组中添加用户的标识,如果用户连接到对等服务器,该数组将维护一个标识列表。同样,当用户与对等服务器断开连接时,我们将从connected_users数组中删除该用户的标识。

现在让我们定义/find路径的路线,用户可以使用该路径找到另一个可以自由聊天的用户。下面是这个的代码。将该代码放入app.js文件:

var waiting_peers = [];

app.get("/find", function(httpRequest, httpResponse, next){

  var id = httpRequest.query.id;

  if(connected_users.indexOf(id) !== -1)
  {

    var idx = waiting_peers.indexOf(id); 
     if(idx === -1) 
    {
      waiting_peers.push(id);
    }

    if(waiting_peers.length > 1)
    {
      waiting_peers.splice(idx, 1);  
      var user_found = waiting_peers[0];
      waiting_peers.splice(0, 1);
      httpResponse.send(user_found);
    }
    else
    {
      httpResponse.status(404).send("Not found");
    }
  }
  else
  {
    httpResponse.status(404).send("Not found");
  }
})

下面是代码的工作原理:

  • waiting_users数组保存有空闲并正在寻找聊天伙伴的用户的 id。
  • 当用户向/find路径发出请求时,路由处理器首先通过检查用户标识是否出现在connected_users数组中来检查用户是否连接到对等服务器。
  • 如果用户没有连接到对等服务器,那么它会发送一个 HTTP 404 错误。如果用户连接到对等服务器,它将检查用户的标识是否存在于waiting_list数组中。如果没有,它会加入数组并继续。
  • 现在,它检查是否有任何其他用户标识也存在于waiting_list数组中,如果有,则发送列表中的第一个用户标识,然后从waiting_list数组中删除所有用户标识。如果它在waiting_list数组中没有找到任何其他用户标识,那么它只发送404 error

现在我们已经完成了网站后端的构建。在我们开始构建网站前端之前,请确保您使用最新的代码重新启动了服务器。

建设前端

首先主页一加载,我们就需要找到连接到用户电脑的麦克风和网络摄像头并列出来,以便用户选择想要的设备。下面是这样做的代码。将该代码放入main.js文件:

window.addEventListener("load", function(){
  MediaStreamTrack.getSources(function(devices){
    var audioCount = 1;
    var videoCount = 1;

    for(var count = 0; count < devices.length; count++)
    {
      if(devices[count].kind == "audio")
      {
        var name = "";

        if(devices[count].label == "")
        {
          name = "Microphone " + audioCount;
          audioCount++;
        }
        else
        {
          name = devices[count].label;
        }

        document.getElementById("audioInput").innerHTML = document.getElementById("audioInput").innerHTML + "<option value='" + devices[count].id + "'>" + name + "</option>";
      }
      else if(devices[count].kind == "video")
      {
        var name = "";

        if(devices[count].label == "")
        {
          name = "Webcam " + videoCount;
          videoCount++;
        }
        else
        {
          name = devices[count].label;
        }

        document.getElementById("videoInput").innerHTML = document.getElementById("videoInput").innerHTML + "<option value='" + devices[count].id + "'>" + name + "</option>";
      }
    }
  });
});

这里我们正在使用MediaStream.getSources检索音频和视频输入设备,并填充<select>标签,以便用户可以选择一个选项。

主页一加载,我们还需要创建一个Peer实例。下面是这样做的代码。将该代码放入main.js文件:

var peer = null;
var dc = null;
var mc = null;
var ms = null;
var rms = null;

window.addEventListener("load", function(){
  peer = new Peer({host: "localhost", port: 8080, path: "/signaling", debug: true}); 

  peer.on("disconnected", function(){

    var interval = setInterval(function(){
      if(peer.open == true || peer.destroyed == true)
      {
        clearInterval(interval);
      }
      else
      {
        peer.reconnect();
      }
    }, 4000)
  })

  peer.on("connection", function(dataConnection){
    if(dc == null || dc.open == false)
    {
      dc = dataConnection;

      dc.on("data", function(data){
        document.getElementById("messages").innerHTML = document.getElementById("messages").innerHTML + "<li><span class='right'>" + data + "</span><div class='clear'></div></li> ";
        document.getElementById("messages-container").scrollTop = document.getElementById("messages-container").scrollHeight;
      })

      dc.on("close", function(){
        document.getElementById("messages").innerHTML = "";
      })
    }
    else
    {
      dataConnection.close();
    }
  })

  peer.on("call", function(mediaConnection){
    if(mc == null || mc.open == false)
    {
      mc = mediaConnection;
      navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
      navigator.getUserMedia({video: true, audio: true}, function(mediaStream) {
        ms = mediaStream;
        mc.answer(mediaStream);
        mc.on("stream", function(remoteStream){
          rms = remoteStream;
          document.getElementById("peerVideo").setAttribute("src", URL.createObjectURL(remoteStream));
          document.getElementById("peerVideo").play();
        })

      }, function(e){ alert("An error occured while retrieving webcam and microphone stream"); })
    }
    else
    {
      mediaConnection.close();
    }
  })
});

这里是代码的工作原理:

  • 首先,我们声明了五个全局变量。peer将为Peer实例保存参考,dc将为DataConnection保存参考,mc将为MediaConnection保存参考,ms将为本地MediaStream保存参考,rms将为远程MediaStream保存参考。
  • 然后,一旦页面完成加载,我们就连接到对等服务器,创建一个Peer实例,并为disconnectedconnectioncall事件处理程序附加事件处理程序。
  • 然后,我们确保在对等方由于某种原因与对等服务器断开连接的情况下,它会自动尝试连接到对等服务器。
  • 如果另一个对等体试图与我们建立DataConnection,那么只有在当前没有其他DataConnection建立的情况下,我们才会接受,否则我们会拒绝。接受DataConnection后,我们附加了dataclose事件的事件处理程序,打印聊天框中的传入消息,如果DataConnection关闭,则清除聊天框中的所有消息。
  • 同样,如果另一个对等体试图与我们建立MediaConnection,我们只有在当前没有其他MediaConnection建立的情况下才会接受,否则我们会拒绝。接受MediaConnection后,我们将附加stream事件的事件处理程序,以便当远程MediaStream到达时,我们可以显示它。

在前面的代码中,我们正在等待另一个对等体与我们建立DataConnectionMediaConnection

现在让我们编写一个代码来找到一个自由对等体,并与之建立DataConnectionMediaConnection。下面是这个的代码。将此代码放在main.js文件中:

function ajaxRequestObject()
{
  var request;
  if(window.XMLHttpRequest)
  {
    request = new XMLHttpRequest();
  }
  else if(window.ActiveXObject) 
  {
    try 
    {
      request = new ActiveXObject('Msxml2.XMLHTTP');
    }
    catch (e)
    {
      request = new ActiveXObject('Microsoft.XMLHTTP');
    }
  }

  return request;
}

function connectToNextPeer()
{
  var request = ajaxRequestObject();

  var url = "/find?id=" + peer.id;

  request.open("GET", url);

  request.addEventListener("load", function(){
    if(request.readyState === 4) 
    {
      if(request.status === 200) 
      {
        dc = peer.connect(request.responseText, {reliable: true, ordered: true});

        dc.on("data", function(data){
          document.getElementById("messages").innerHTML = document.getElementById("messages").innerHTML + 
          "<li><span class='right'>" + data + "</span><div class='clear'></div></li>";
          document.getElementById("messages-container").scrollTop = document.getElementById("messages-container").scrollHeight;
        })

        dc.on("close", function(){
          document.getElementById("messages").innerHTML = "";
        })

        navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;

        var audioInputID = document.getElementById("audioInput").options[document.getElementById("audioInput").selectedIndex].value;
        var videoInputID = document.getElementById("videoInput").options[document.getElementById("videoInput").selectedIndex].value;

        navigator.getUserMedia({video: {mandatory: {sourceId: videoInputID}}, audio: {mandatory: {sourceId: audioInputID}}}, function(mediaStream) {
          ms = mediaStream;

          if(document.getElementById("audioToggle").checked)
          {
            var tracks = ms.getAudioTracks();
            if(document.getElementById("audioToggle").checked)
            {
              tracks[0].enabled = true;
            }
            else
            {
              tracks[0].enabled = false;
            }
          }

          if(document.getElementById("videoToggle").checked)
          {
            var tracks = ms.getVideoTracks();
            if(document.getElementById("videoToggle").checked)
            {
              tracks[0].enabled = true;
            }
            else
            {
              tracks[0].enabled = false;
            }
          }

          mc = peer.call(request.responseText, ms);

          mc.on("stream", function(remoteStream){
            rms = remoteStream;
            document.getElementById("peerVideo").setAttribute("src", URL.createObjectURL(remoteStream));
            document.getElementById("peerVideo").play();
          })

        }, function(e){ alert("An error occured while retrieving webcam and microphone stream"); });

      }
    }
  }, false);

  request.send(null);
}

function communication()
{
  if(peer != null && peer.disconnected == false && peer.destroyed == false)
  {
    if(dc == null || mc == null || dc.open == false || mc.open == false)
    {
      connectToNextPeer();
    }
  }
}

setInterval(communication, 4000);

这个代码很长但是很容易理解。下面是代码的工作原理:

  • 首先,我们定义了一个ajaxRequestObject()函数,它只返回一个 AJAX 对象,并通过创建一个 AJAX 对象来隐藏浏览器差异。
  • 然后我们定义了connectToNextPeer()方法,该方法从/next路径请求一个空闲 ID,如果找到了,它将与这个对等体建立DataConnectionMediaConnection。它还附加了与前面代码相同的必要事件处理程序。
  • 检索MediaStream时,使用用户在下拉列表中选择的设备。
  • 在调用另一个对等点之前,它会将enabled属性设置为truefalse,具体取决于复选框是否被选中。
  • 最后,我们设置一个计时器,如果对等体连接到对等服务器,并且MediaConnectionDataConnection当前没有与另一个对等体建立连接,则该计时器每四秒钟调用一次connectToNext()对等体。

现在我们需要编写代码,当用户按下消息框文本输入栏上的回车键时,将消息发送给连接的对等方。下面是这样做的代码。将该代码放入main.js文件:

document.getElementById("message-input-box").addEventListener("keypress", function(){
  if(dc != null && dc.open == true)
  {
    var key = window.event.keyCode;
    if (key == 13) 
    {
      var message = document.getElementById("message-input-box").value;
      document.getElementById("message-input-box").value = "";
      dc.send(message);
      document.getElementById("messages").innerHTML = document.getElementById("messages").innerHTML + "<li><span class='left'>" + message + "</span><div class='clear'></div></li> ";
      document.getElementById("messages-container").scrollTop = document.getElementById("messages-container").scrollHeight;
    }
    else
    {
      return;
    }
  }
})

这里,在首先,我们检查DataConnection是否成立。如果DataConnection当前已建立,那么我们将向连接的对等方发送一条消息,并在消息框中显示该消息。

现在我们需要编写代码,以便在用户切换复选框时暂停或恢复音频和视频。下面是这样做的代码。将该代码放入main.js文件:

document.getElementById("videoToggle").addEventListener("click", function(){
  if(ms !== null)
  {
    var tracks = ms.getVideoTracks();

    if(document.getElementById("videoToggle").checked)
    {
      tracks[0].enabled = true;
    }
    else
    {
      tracks[0].enabled = false;
    }
  }
});

document.getElementById("audioToggle").addEventListener("click", function(){
  if(ms !== null)
  {
    var tracks = ms.getAudioTracks();

    if(document.getElementById("audioToggle").checked)
    {
      tracks[0].enabled = true;
    }
    else
    {
      tracks[0].enabled = false;
    }
  }
});

这里,我们通过将truefalse分配给轨道的启用属性来实现该功能。

我们需要关闭MediaConnectionDataConnection,当用户点击下一个用户按钮时,再找一个用户聊天。下面是这样做的代码。将此代码放在main.js文件中:

document.getElementById("next").addEventListener("click", function(){
  if(mc != null)
  {
    mc.close();
  }

  if(dc != null)
  {
    dc.close();
  }

  connectToNextPeer();  
})

如果目前有任何MediaConnectionDataConnection成立,那么我们正在关闭。那我们就叫connectToNextPeer()法建立MediaConnectionDataConnection

现在,我们最终需要显示对等连接和对等服务器连接的状态。下面是这样做的代码。将该代码放入main.js文件:

setInterval(function(){
  if(dc == null || mc == null || dc.open == false || mc.open == false)
  {
    document.getElementById("peerStatus").innerHTML = "Waiting for a free peer";
  }
  else
  {
    document.getElementById("peerStatus").innerHTML = "Connected to a peer";
  }

  if(peer != null && peer.disconnected == false && peer.destroyed == false)
  {
    document.getElementById("peerServerStatus").innerHTML = "Connected to PeerServer";
  }
  else
  {
    document.getElementById("peerServerStatus").innerHTML = "Not connected to PeerServer";
  }
}, 4000);

这里我们每4秒检查并更新一次状态。

测试网站

要测试我们刚刚创建的聊天轮盘网站,首先确保服务器正在运行,然后在两个不同的选项卡、浏览器或设备中打开http://localhost:8080/网址。

现在,您将看到他们两个都自动连接,并且能够相互聊天。

总结

在本章中,我们看到了如何使用我们自己的与 Express 集成的 PeerServer 实例来构建聊天轮盘。我们建立的网站几乎具备了聊天轮盘应该具备的所有功能。您现在可以添加一些功能,例如屏幕共享、将特定性别的用户相互连接、将特定年龄的用户连接、集成验证码以防止垃圾邮件,以及您选择的其他功能。

在撰写本文时,WebRTC 团队正在开发一个 API,允许您从屏幕中检索流以进行屏幕共享。由于该应用编程接口仍在开发中,您可以使用浏览器插件从屏幕上检索流。您可以在https://www . webrtc-experience . com/plugin free-Screen-Sharing/找到更多关于使用插件从屏幕中检索流的信息。

在下一章中,我们将讨论使用网络套接字在客户端和服务器之间进行实时双向通信。