构建信令服务器

了解清楚信令之后,接下来是如何构建 WebRTC 一对一信令服务器。我们将从以下几个方面来介绍如何构造信令服务器:一是信令服务器的实现方案;二是信令服务器的业务逻辑;三是信令服务器的实现;四是信令服务器的安装与部署。

信令服务器的实现方案

要实现一个一对一的 WebRTC 信令服务器可以有很多种方案,这里介绍两种最常见的实现方案。

方案一,使用 C/C++Java 等语言从零开始开发一个信令服务器。这种方案的实现成本非常高,要写很多代码,还要对编写的代码进行大量的测试。使用这种方案,即使开发一个最简单的 HTTPS 服务器,至少也要花两周以上的时间。

方案二,利用现成的 Web 服务器做应用开发,如以 ApacheNginxNodeJS 为服务,在其上做应用开发是非常不错的选择。

建议采用第二种方案,它有以下几方面优势:

  • 一般信令系统都需要使用 HTTP/HTTPSWS/WSS 等传输协议,而 ApacheNginxNodeJS 等服务器显然在这方面有天然的优势。

  • 实时通信的信令服务器一般负载都不是特别高。举个例子,假设有 10000 个房间同时在线,我们可以评估出大部分房间只需要处理几个信令,那么总的消息量也不过是几万个,这个量级对于 NginxNodeJS 来说,单台服务器就可以应付了。

  • 通过 NginxNodeJS 实现信令服务器特别简单,只要几行代码就可以实现。

  • 稳定性高。像 ApacheNginxNodeJS 这类服务器都经过了长时间的验证,所以它们的稳定性是可以得到保障的。

基于以上几点原因,建议你采用第二种方案实现自己的信令系统。

在第二种方案中,尤其推荐使用 Node.js 来实现信令服务器。虽然 NodeJS 在性能上不如 Nginx,但对于我们这种学习项目来说使用它已经足够了,而且它使用起来也特别简单,还有非常好的生态链,很多逻辑关系不需要我们自己写,大大减少了开发信令服务器的工作量。

信令服务器的业务逻辑

关于 WebRTC 一对一信令服务器的业务逻辑前面已做了一些介绍,其中最重要的是房间的概念。当两个用户要进行通信时,他们首先要创建一个房间,成功加入房间之后,双方才能交换必要的信息,如 Offer/AnswerCandidate 等。当通信的双方结束通话后,用户需要发送离开房间的消息给信令服务器,此时信令服务器需要将房间内的所有人清除;如果房间里已经没有人了,还需要将空房间销毁掉。

对于这样一套机制,如果我们自己实现的话,需要花不少时间。好消息是,著名的 socket.io 库已经实现了这套逻辑,只要我们在 NodeJS 中引入它即可。

信令服务器的实现

接下来看一下信令服务器的实现。要实现信令服务器,我们需要思考以下几个问题:如何通过 NodeJS 实现一个 HTTP 服务?如何使用 socket.io 库?如何进行信令的转发?下面我就来回答上面的问题,当这几个问题解答完了,信令服务器也就实现了。

1)如何通过 NodeJS 实现一个 HTTP 服务?在 NodeJS 上开发一个 HTTP 应用只要几行代码即可,如代码 4.1 所示。

…
const http = require('http');//引入http库
const express = require('express'); //引入express库

// 创建HTTP服务,并侦听8980端口
const app = express();
const http_server = http.createServer(app);
http_server.listen(8080, '0.0.0.0');
…

上面的代码中引入了两个库:一个是 http 库,用于创建 HTTP 服务;另一个是 express 库,是一套开发 Web 应用的框架,它提供了很多开发 Web 应用的工具。

通过上面引入的两个库,你很容易写出一个 HTTP 服务来。首先,通过 express 创建一个 Web 应用,如第 6 行代码所示;之后调用 HTTP 库的 createServer() 方法创建 HTTP 对象,即 http_server;最后调用 http_server 对象的 listen() 方法侦听 8080 端口。通过上面的步骤就实现了一个 HTTP 服务。

2)如何使用 socket.io 库?在使用 socket.io 库之前,也需要像开发 HTTP 服务一样,先通过 require 将它引入程序中,然后利用 socket.ioon 方法接收消息,用 emit 方法发送数据。下面是 socket.io 的几个常见方法:

  • 给本次连接发送消息,如代码 4.2 所示。

    代码4.2 发送消息
    socket.emit('cmd')
  • 给本次连接发送带参数的消息,如代码 4.3 所示。

    代码4.3 发送带参数消息
    socket.emit('cmd',arg1); // 多个参数往后排
  • 给除本次连接外房间内的所有人发消息,如代码 4.4 所示。

    代码4.4 给房间内的所有人发消息
    socket.to(room).emit('cmd')
  • 接收消息,如代码 4.5 所示。

    代码4.5 接收消息
    socket.on('cmd', function(){ … })
  • 接收带参数的消息,如代码 4.6 所示。

    代码4.6 接收带参数的消息
    socket.on('cmd', function(arg1){ … })

3)如何转发信令?你需要根据收到的客户端不同的信令,给它返回不同的结果,如代码 4.7 所示。

代码4.7 转发信令
io.sockets.on('connection', (socket) => {

    // 收到 message 时,进行转发
    socket.on('message', (message) => {
        // 给另一端转发消息
        socket.to(room).emit('message', message);
    });

    // 收到 join 消息
    socket.on('join', (room) => {
        var o = io.sockets.adapter.rooms[room];

        // 得到房间里的人数
        var nc = o ? Object.keys(o.sockets).length : 0;

        if (nc < 2) { // 如果房间中不超过 2 人
            socket.join(room);

            // 发送 joined 消息
            socket.emit('joined', room);
            ...
        } else { // 最多两个客户端
            socket.emit('full', room); // 发送 full 消息
        }
    });

    ...
});

从上面的代码片段中可以看到,所有消息的处理都是在客户端与服务器建立连接之后进行的。因此,需要提前将 connection 消息的处理函数注册到 socket.io 中(即第 2 行代码的含义);然后,再分别注册各消息(joinedmessage……)的处理函数。这样,当服务器收到客户端发来的消息时,socket.io 就会根据消息类型调用注册的处理函数,从而完成对应的业务处理。

经过上面的讲解,现在再看代码 4.7 是不是觉得很简单了?其具体过程如下:如果服务端收到 message 消息,它不做任何处理,直接进行转发(第 7 行代码);如果是 join 消息,则首先将用户加入服务端管理的房间中,之后向客户端返回 joined 消息(第17、第19行代码);如果用户加入时房间里已经有两个用户了,则拒绝该用户的加入,并返回 full 消息,以告之目前房间里人已经满了(第 22 行代码);对于其他消息的处理以此类推。

经过上面的步骤后,信令服务器开发完成。接下来介绍如何将开发好的代码部署到服务器上。

信令服务器的安装与部署

在服务器上部署信令服务器需要三个步骤:

  1. 安装 NodeJS

  2. 安装 NPM,并安装信令服务器的依赖库。

  3. 启动服务。

下面我们就按照上面的步骤实际操作一下:

  1. 安装 NodeJS。在不同的环境中安装 NodeJS 的方法略有不同,但都十分方便,下面是在不同系统下安装 NodeJS 的方法:

    • Ubuntu系统

      apt install nodejs
    • CentOS

      yum install nodejs
    • MacOS

      brew install nodejs
  2. 安装 NPMNPM 起什么作用呢?实际上,它与 aptyumbrew 工具类似,也是一个包管理器,只不过是专门用来管理、安装 NodeJS 需要的依赖库的。安装 NPM 与安装 NodeJS 类似,在 Ubuntu 下安装命令如下:

    apt install npm

    其他系统中的安装方法不再赘述。NPM 安装好后,我们就可以用它来安装 NodeJS 的依赖库 expresssocket.io 库(http 库是 NodeJS 自带的,不需要安装)。其安装方法如下:

    npm install socket.io@2 .0.3
    npm install express
  3. 现在代码编写好了,运行环境也搭建好了,接下来就可以启动服务了。假如你将上面编写的信令服务器程序命名为 signserver.js,那么只要在安装依赖库的目录下执行下面的命令,就可以启动信令服务器。

    node signserver.js

    此时,你可以在控制终端上执行以下命令,来观察信令服务器是否正常启动。

    netstat -ntpl |grep 8080

    如果在控制终端上能查看到 8080 端口已经就绪,则说明信令服务器已开始工作,可以随时接收客户端向该端口发送的信令消息。至此,WebRTC 一对一信令服务器就完成了。

信令服务器的完整代码

通过上面的讲解,相信大多数读者完全能自己实现一个 WebRTC 一对一信令服务系统。不过对于新手来说,即使讲解得再详细,也不如有一个完整的例子做参考来得实际。为此我特意将信令服务器的完整代码放在本章的最后作为参考,具体参见代码 4.8。

代码4.8 信令服务器完整代码
'use strict';

// 依赖库
var log4js = require('log4js'); // 用于输出日志
var http = require('http'); // 提供HTTP 服务
var https = require('https'); // 提供HTTPS 服务
var fs = require('fs'); // 用于读取文件内容
var socketIo = require('socket.io');
var express = require('express');
var serveIndex = require('serve-index');

// 一个房间里可以同时在线的最大用户数
var USERCOUNT = 3;

// 日志的配置项
log4js.configure({
    appenders: {
        file: {
            type: 'file',
            filename: 'app.log',
            layout: {
                type: 'pattern',
                pattern: '%r %p - %m',
            },
        },
    },
    categories: {
        default: {
            appenders: ['file'],
            level: 'debug',
        },
    },
});

var logger = log4js.getLogger();

var app = express();
app.use(serveIndex('./public'));
app.use(express.static('./public'));

// 设置跨域访问
app.all('*', function (req, res, next) {
    // 设置允许跨域的域名,* 代表允许任意域名跨域
    res.header('Access-Control-Allow-Origin', '*');

    // 允许的header类型
    res.header('Access-Control-Allow-Headers', 'content-type');

    // 跨域允许的请求方式
    res.header('Access-Control-Allow-Methods', 'DELETE, PUT, POST, GET, OPTIONS');

    if (req.method.toLowerCase() == 'options') {
        res.send(200); // 让options尝试请求快速结束
    } else {
        next();
    }
});

// HTTP 服务
var http_server = http.createServer(app);
http_server.listen(80, '0.0.0.0');

// 你的网站证书
var options = {
    key: fs.readFileSync('./cert/cert.key'),
    cert: fs.readFileSync('./cert/cert.pem'),
};

// HTTPS 服务
var https_server = https.createServer(options, app);
var io = socketIo.listen(https_server);

// 处理连接事件
io.sockets.on('connection', (socket) => {
    // 中转消息
    socket.on('message', (room, data) => {
        logger.debug('message, room: ' + room + ', data, type:' + data.type);
        socket.to(room).emit('message', room, data);
    });

    // 用户加入房间
    socket.on('join', (room) => {
        socket.join(room);
        var myRoom = io.sockets.adapter.rooms[room];
        var users = myRoom ? Object.keys(myRoom.sockets).length : 0;

        logger.debug('the user number of room (' + room + ') is: ' + users);

        // 如果房间里人未满
        if (users < USERCOUNT) {
            // 发给除自己之外房间内的所有人
            socket.emit('joined', room, socket.id);

            // 通知另一个用户,有人来了
            if (users > 1) {
                socket.to(room).emit('otherjoin', room, socket.id);
            }
        } else {
            // 如果房间里人满了
            socket.leave(room);
            socket.emit('full', room, socket.id);
        }
    });

    // 用户离开房间
    socket.on('leave', (room) => {
        // 从管理列表中将用户删除
        socket.leave(room);

        var myRoom = io.sockets.adapter.rooms[room];
        var users = myRoom ? Object.keys(myRoom.sockets).length : 0;
        logger.debug('the user number of room is: ' + users);

        // 通知其他用户有人离开了
        socket.to(room).emit('bye', room, socket.id);

        // 通知用户服务器已处理
        socket.emit('leaved', room, socket.id);
    });
});

https_server.listen(443, '0.0.0.0');