11 | 如何通过Node.js实现一套最简单的信令系统?

通过前面几篇文章的讲解,我想现在你应该已经对 WebRTC 有了一个清楚的认知了。接下来的章节咱们就使用 WebRTC 逐步实现一套真实可用的 1 对 1 实时直播系统吧。

WebRTC 1.0 规范对 WebRTC 要实现的功能、API 等相关信息做了大量的约束,比如规范中定义了如何采集音视频数据、如何录制以及如何传输等。甚至更细的,还定义了都有哪些 API,以及这些API 的作用是什么。但这些约束只针对于客户端,并没有对服务端做任何限制。

那WebRTC规范中为什么不对服务器也做约束呢?其实,这样做有以下三点好处。

  • 第一点,可以集中精力将 WebRTC 库做好。WebRTC的愿景是使浏览器能够方便地处理音视频相关的应用,规范中不限制服务端的事儿,可以使它更聚焦。
  • 第二点,让用户更好地对接业务。你也清楚,信令服务器一般都与公司的业务有着密切的关系,每家公司的业务都各有特色,让它们按照自已的业务去实现信令服务器会更符合它们的要求。
  • 第三点,能得到更多公司的支持。WebRTC扩展了浏览器的基础设施及能力,而不涉及到具体的业务或产品,这样会更容易得到像苹果、微软这种大公司的支持,否则这些大公司之间就会产生抗衡。

当然,这样做也带来了一些坏处,最明显的一个就是增加了学习 WebRTC 的成本,因为你在学习WebRTC的时候,必须自己去实现信令服务器,否则你就没办法让 WebRTC 运转起来,这确实增加了不少学习成本。

不过有了本专栏,你就不用再担心这个问题了。接下来,我就向你讲解一下,如何实现一套最简单的 WebRTC 信令服务系统。有了这套信令服务系统,WebRTC就能运转起来,这样你就能真正地体验到 WebRTC 的强大之处了。

在WebRTC 处理过程中的位置

在开始讲解 WebRTC 信令服务器之前,我们先来看一下本文在 WebRTC 处理过程中的位置。

WebRTC处理过程图

通过上面这幅图,你可以清楚地知道本文所讲的主要内容就是红色方框中的信令服务器部分。

WebRTC信令服务器的作用

你若想要实现 WebRTC 信令服务器,首先就要知道它在 WebRTC 1 对 1 通信中所起的作用。实际上它的功能是蛮简单的,就是进行信令的交换,但作用却十分关键。在通信双方彼此连接、传输媒体数据之前,它们要通过信令服务器交换一些信息,如媒体协商

举个例子,假设 A 与 B 要进行音视频通信,那么 A 要知道 B 已经上线了,同样,B 也要知道 A 在等着与它通信呢。也就是说,只有双方都知道彼此存在,才能由一方向另一方发起音视频通信请求,并最终实现音视频通话。比如我们在《08 | 有话好商量,论媒体协商》一文中讲的媒体信息协商的过程就是这样一个非常典型的案例,双方的 SDP 信息生成后,要通过信令服务器进行交换,从而达到媒体协商的目的。

那在 WebRTC 信令服务器上要实现哪些功能,才能实现上述结果呢?我想至少要实现下面两个功能:

  1. 房间管理。即每个用户都要加入到一个具体的房间里,比如两个用户 A 与 B 要进行通话,那么它们必须加入到同一个房间里。
  2. 信令的交换。即在同一个房间里的用户之间可以相互发送信令。

信令服务器的实现

了解了 WebRTC 信令服务器的作用,并且还知道了信令服务器要实现的功能,接下来我们就操练起来,看看如何实现信令服务器吧!我将从下面5个方面来向你逐步讲解如何实现一个信令服务器。

1. 为什么选择 Node.js?

要实现信令服务器,你可以使用 C/C++、Java 等语言一行一行从头开始编写代码,也可以以现有的、成熟的服务器为基础,做二次开发。具体使用哪种方式来实现,关键看你的服务器要实现什么功能,以及使用什么传输协议等信息来决策。

以我们要实现的信令服务器为例,因它只需要传输几个简单的信令,而这些信令既可以使用 TCP、 HTTP/HTTPS传输,也可以用 WebSocket/WSS 协议传输,所以根据它使用的传输协议,你就可以很容易地想到,通过 Web 服务器(如Nginx、Node.js)来构建我们的信令服务器是最理想、最省时的、且是最优的方案。

你可以根据自己的喜好选择不同的 Web 服务器(如 Apache、Nginx 或 Node.js)来实现,而今天我们选择的是Node.js,所以接下来我们将要讲解的是如何使用 Node.js 来搭建信令服务器

实际上,Apache、Nginx和Node.js都是非常优秀、且成熟的Web服务器,其中 Nginx 可以说是性能最好的Web服务器了,但从未来的发展角度来说,Node.js则会更有优势

Node.js 的最大优点是可以使用 JavaScript 语言开发服务器程序。这样使得大量的前端同学可以无缝转到服务器开发,甚至有可能前后端使用同一套代码实现。对于使用 JavaScript 语言实现全栈开发这一点来说,我想无论是对于个人还是对于企业都是极大的诱惑。更可贵的是 Node.js 的生态链非常完整,有各种各样的功能库,你可以根据自己的需要通过安装工具(如 NPM)快速将它们引入到你的项目中,这极大地提高了 JavaScript 研发同学的开发效率。

Node.js 的核心是V8(JavaScript)引擎,Node.js通过它解析JavaScript 脚本来达到控制服务器的目的。对于 JavaScript 同学来说,Node.js的出现是革命性的,它不仅让 JavaScript 同学成为了全栈开发工程师,而且还让JavaScript开发同学的幸福指数飙升,真正地感受到了 JavaScript 无所不能的能力。对于我这样的开发“老鸟”来说,10年前还不敢想象通过 JavaScript 来写服务器程序呢,现在它却已成为现实!

当然, Node.js 不仅可以让你用 JavaScript 控制服务器,它还为你留了拓展接口,这些拓展接口甚至可以让你使用 C/C++ 为它编写模块,这样 Node.js 的功能就更加强大了。

2. Node.js的基本工作原理

Node.js 工作原理图

Node.js的工作原理如上图所示,其核心是 V8 引擎。通过该引擎,可以让 JavaScript 调用 C/C++方法或对象。反过来讲,通过它也可以让 C/C++ 访问JavaScript方法和变量。

Node.js 首先将 JavaScript 写好的应用程序交给 V8 引擎进行解析,V8理解应用程序的语义后,再调用 Node.js 底层的 C/C++ API将服务启动起来。所以 Node.js 的强大就在于JavaScript 与 C/C++ 可以相互调用,从而达到使其能力可以无限扩展的效果

我们以Node.js开发一个 HTTP 服务为例,Node.js 打开侦听的服务端口后,底层会调用 libuv 处理该端口的所有 HTTP 请求。其网络事件处理的过程就如下图所示:

Node.js 事件处理模型图

当有网络请求过来时,首先会被插入到一个事件处理队列中。libuv会监控该事件队列,当发现有事件时,先对请求做判断,如果是简单的请求,就直接返回响应了;如果是复杂请求,则从线程池中取一个线程进行异步处理。

线程处理完后,有两种可能:一种是已经处理完成,则向用户发送响应;另一种情况是还需要进一步处理,则再生成一个事件插入到事件队列中等待处理。事件处理就这样循环往复下去,永不停歇。

3. 安装与使用 Node.js

了解了 Node.js的基本原理后,接下来我们还是要脚踏实地来看看具体如何安装、使用Node.js。

(1)安装Node.js

不同环境下安装 Node.js的方法也不一样,不过都很简单。

在Ubuntu系统下执行:

apt install nodejs

或在Mac 系统下执行:

brew install nodejs

通过以上步骤,Node.js 很快就安装好了(我这里安装的 Node.js版本为:v8.10.0)。

(2)安装 NPM

除了安装 Node.js 之外,还要安装NPM(Node Package Manager),也就是 Node.js 的包管理器,或叫包安装工具。它与 Ubuntu 下的 APT(Advanced Package Tool)命令或 Mac 系统下的 BREW 命令类似,是专门用来管理各种依赖库的。

以Linux为例,在 APT 没有出现之前,在Linux上安装软件是件特别麻烦的事儿,比如要安装一个编辑器,其基本步骤有如下:

  • 先将这个工具(编辑器)的源码下载下来;
  • 执行./configure 生成Makefile 文件;
  • 执行 make 命令对源码进行编译;
  • 如果编译成功,执行 make install 将其安装到指定目录下;
  • 如果编译过程中发现还需要其他库,则要对依赖库执行前面的4步,也就是先将依赖库安装好,然后再来安装该工具。

由这你可以看出,以前在Linux下安装个程序或工具是多么麻烦。

不过 Linux 有了 APT工具后,一切都变得简单了。你只要执行apt install xxx一条命令就好了,它会帮你完成上面的一堆操作。

对于 Node.js的安装包也是如此,NPM就是相当于 Linux 下的 APT 工具,它的出现大大提高了JavaScript 开发人员的工作效率

下面我们就来看一下如何安装 NPM 工具,实际上,NPM 的安装就像前面安装 Node.js 一样简单。

在Ubuntu下执行:

apt install npm

或在Mac下执行:

brew install npm

4. Socket.io的使用

除了 Node.js 外,我们最终还要借助Socket.io来实现 WebRTC 信令服务器。Socket.io特别适合用来开发WebRTC的信令服务器,通过它来构建信令服务器大大简化了信令服务器的实现复杂度,这主要是因为它内置了房间的概念。

Socket.io 与 Node.js关系图

上图是 Socket.io 与 Node.js配合使用的逻辑关系图,其逻辑非常简单。Socket.io 分为服务端和客户端两部分。服务端由 Node.js加载后侦听某个服务端口,客户端要想与服务端相连,首先要加载 Socket.io 的客户端库,然后调用 io.connect();即可与服务端连接上。

这里需要特别强调的是 Socket.io 消息的发送与接收。Socket.io 有很多种发送消息的方式,其中最常见的有下面几种,也是你必须要掌握的。

  • 给本次连接发消息
	socket.emit()
  • 给某个房间内所有人发消息
	io.in(room).emit()
  • 除本连接外,给某个房间内所有人发消息
	socket.to(room).emit()
  • 除本连接外,给所有人发消息
	socket.broadcast.emit()

你也可以看看下面的例子,其中S 表示服务器,C 表示客户端,它们是发送消息与接收消息的比对。

  • 发送 command 命令
	S: socket.emit('cmd’);
	C: socket.on('cmd',function(){...});
  • 发送了一个 command 命令,带 data 数据
	S: socket.emit('action', data);
	C: socket.on('action',function(data){...});
  • 发送了command命令,还有两个数据
	S: socket.emit(action,arg1,arg2);
	C: socket.on('action',function(arg1,arg2){...});

有了以上这些知识,你就可以实现信令数据通讯了。

5. 实现信令服务器

接下来我们来看一下,如何通过 Node.js下的 Socket.io 来构建一个服务器。

首先是客户端代码,也就是在浏览器里执行的代码。以下是index.html代码:

<!DOCTYPE html>
<html>
  <head>
    <title>WebRTC client</title>
  </head>
  <body>
    <script src='/socket.io/socket.io.js'></script>
    <script src='js/client.js'></script>
  </body>
</html>

该代码十分简单,就是在body里引入了两段 JavaScript 代码。其中,socket.io.js 是用来与服务端建立 Socket 连接的;client.js 的作用是做一些业务逻辑,并最终通过 Socket 与服务端通讯。

下面是client.js的代码:

var isInitiator;

room = prompt('Enter room name:'); //弹出一个输入窗口

const socket = io.connect(); //与服务端建立socket连接

if (room !== '') { //如果房间不空,则发送 "create or join" 消息
  console.log('Joining room ' + room);
  socket.emit('create or join', room);
}

socket.on('full', (room) => { //如果从服务端收到 "full" 消息
  console.log('Room ' + room + ' is full');
});

socket.on('empty', (room) => { //如果从服务端收到 "empty" 消息
  isInitiator = true;
  console.log('Room ' + room + ' is empty');
});

socket.on('join', (room) => { //如果从服务端收到 “join" 消息
  console.log('Making request to join room ' + room);
  console.log('You are the initiator!');
});

socket.on('log', (array) => {
  console.log.apply(console, array);
});

在该代码中,首先弹出一个输入框,要求用户写入要加入的房间;然后,通过 io.connect() 建立与服务端的连接;最后再根据socket返回的消息做不同的处理,比如收到房间满或空的消息等。

以上是客户端(也就是在浏览器)中执行的代码。下面我们来看一下服务端的处理逻辑。

服务器端代码,server.js是这样的:

const static = require('node-static');
const http = require('http');
const file = new(static.Server)();
const app = http.createServer(function (req, res) {
  file.serve(req, res);
}).listen(2013);

const io = require('socket.io').listen(app); //侦听 2013

io.sockets.on('connection', (socket) => {

  // convenience function to log server messages to the client
  function log(){ 
    const array = ['>>> Message from server: ']; 
    for (var i = 0; i < arguments.length; i++) {
      array.push(arguments[i]);
    } 
      socket.emit('log', array);
  }

  socket.on('message', (message) => { //收到message时,进行广播
    log('Got message:', message);
    // for a real app, would be room only (not broadcast)
    socket.broadcast.emit('message', message); //在真实的应用中,应该只在房间内广播
  });

  socket.on('create or join', (room) => { //收到 “create or join” 消息

	var clientsInRoom = io.sockets.adapter.rooms[room];
    var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0; //房间里的人数

    log('Room ' + room + ' has ' + numClients + ' client(s)');
    log('Request to create or join room ' + room);

    if (numClients === 0){ //如果房间里没人
      socket.join(room);
      socket.emit('created', room); //发送 "created" 消息
    } else if (numClients === 1) { //如果房间里有一个人
	  io.sockets.in(room).emit('join', room);
      socket.join(room);
      socket.emit('joined', room); //发送 “joined”消息
    } else { // max two clients
      socket.emit('full', room); //发送 "full" 消息
    }
    socket.emit('emit(): client ' + socket.id +
      ' joined room ' + room);
    socket.broadcast.emit('broadcast(): client ' + socket.id +
      ' joined room ' + room);

  });

});

该段代码中,在服务端引入了 node-static 库,使服务器具有发布静态文件的功能。服务器具有此功能后,当客户端(浏览器)向服务端发起请求时,服务器通过该模块获得客户端(浏览器)运行的代码,也就是上面我们讲到的 index.html 和 client.js,下发给客户端(浏览器)。

服务端侦听 2013 这个端口,对不同的消息做相应的处理:

  • 服务器收到 message 消息时,它会直接进行广播,这样所有连接到该服务器的客户端都会收到广播的消息。
  • 服务端收到“create or join”消息时,它会对房间里的人数进行统计,如果房间里没有人,则发送“created”消息;如果房间里有一个人,发送“join”消息和“joined”消息;如果超过两个人,则发送“full”消息。

要运行该程序,需要使用 NPM 安装 socket.io 和node-static,安装方法如下:

npm install socket.io
npm install node-static

启动服务器并测试

通过上面的步骤,你就使用“Socket.io + Node.js”实现了一个信令服务器。现在你还可以通过下面的命令将服务启动起来了:

node server.js

如果你是在本机上搭建的服务,则可以在浏览器中输入“localhost:2013”,然后在浏览器中新建一个tab ,在里边再次输入“localhost:2013”。这时,你就可以通过浏览器的控制台去看看发生了什么吧!

最后,再说一个快捷键小技巧吧,在Chrome下,你可以使用Command-Option-J或Ctrl-Shift-J的DevTools快速访问控制台。

小结

在本文中,我们介绍了 Node.js 的工作原理、Node.js的安装与布署,以及如何使用“Sokcet.io + Node.js”实现 WebRTC 信令消息服务器。Socket.io 由于有房间的概念,所以与WebRTC非常匹配,因此,用它开发WebRTC信令服务器就会大大地减少工作量。

另外,本文中的例子虽说很简单,但在后面的文章中我会以这个例子为基础,在其上面不断增加一些功能,这样最终你就会看到一个完整的Demo程序。所以你现在还是要学习好这每一个知识点,打好基础,积跬步才能至千里。

思考时间

文中所讲的JavaScript代码需要运行在两个不同的 V8 引擎上,你知道它们的对应关系吗?

欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

所做Demo的GitHub链接(有需要可以点这里)

精选留言

  • 花果山の酸梅汤

    2019-09-10 16:40:05

    client代码运行于浏览器渲染引擎中的V8引擎,server代码运行于Node.js的JS运行时的V8引擎部分。一个面向渲染一个面向提供后台服务。
    作者回复

    very good!

    2019-09-11 17:47:44

  • no ne

    2021-09-23 14:48:52

    最新的版本的socket.io会有不兼容问题(如服务器代码里的socketIo.listen(https_server)会报错)
  • 天天

    2019-12-11 08:50:32

    老师,您在介绍node. js的Reactor模式时和我以往理解的有点出入。您说道有个线程池的概念,我的理解应该是没有的喔(虽然我们可以使用worker来创建)还请提点一下我呦,推我篇文章也行😁谢谢啦
    作者回复

    nodejs 底层用的 libuv, 在 libuv 中使用了线程池,你可以看一下libuv的源码 https://github.com/libuv/libuv/tree/v1.x/src

    2020-01-14 23:37:10

  • jike

    2019-11-11 16:44:24

    不懂 node 的小白
    客户端运行报错 404 http://127.0.0.1:8000/socket.io/?EIO=3&transport=polling&t=MvPeDk4;
    服务端运行没有任何输出 浏览器也访问不了
    作者回复

    如果你对 node 不熟悉的话,确实会有这类问题。建议你看一下我在imooc.com中的webrtc入门与实战的视频,在那个视频中有讲具体的步骤!

    2019-11-15 09:18:40

  • Ethan

    2019-08-09 07:46:08

    客户端一定要引入socket. io吗?可以直接用 websocket api吗
    作者回复

    可以,用websoket你要自己写一个房间服务器

    2019-08-10 12:07:33

  • 忧天小鸡

    2022-03-29 18:01:43

    这client对不懂前端的人来说,完全跑不起来
  • bohao

    2021-09-05 00:03:59

    新版本的 socket.io 代码需要修改下,比如 io.sockets.adapter.rooms[room] 是个 Map<String, Set<String>>
  • 人生在事

    2020-11-04 16:13:21

    李老师,文章实例里客户端都没有指明连哪个端口,服务端怎么监听的啊?
    作者回复

    按说你应该能想到才对,它与浏览器用的同一个连接

    2020-11-10 18:10:35

  • 宇宙之王

    2020-09-14 22:01:08

    老师好,有个问题不明白,socket = io.connect(); 没指定端口也能连接通吗?第一次接触node,废了半天劲,这章源码和课程不太一致,运行不了,安装各种node模块又把server.js 做了如下修改 暂时去掉了https和log4j,已部署到服务器测试,大家有调不通的先试试这个,有问题互相多沟通
    'use strict'

    var http = require('http');
    var https = require('https');
    var fs = require('fs');
    var socketIo = require('socket.io');

    var express = require('express');
    var serveIndex = require('serve-index');

    var USERCOUNT = 4;

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

    //http server
    var http_server = http.createServer(app);
    var io = socketIo.listen(http_server);

    io.sockets.on('connection', (socket)=> {

    socket.on('message', (room, data)=>{
    console.log('message, room: ' + room + ", data, type:" + data);
    socket.to(room).emit('message',room, data+'from server!');
    });

    socket.on('join', (room)=>{
    socket.join(room);
    var myRoom = io.sockets.adapter.rooms[room];
    var users = (myRoom)? Object.keys(myRoom.sockets).length : 0;
    console.log('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;
    console.log('the user number of room is: ' + users);

    socket.to(room).emit('bye', room, socket.id);
    socket.emit('leaved', room, socket.id);
    });

    });

    http_server.listen(8080, '0.0.0.0');



    作者回复

    你觉得呢?实际上它会利用浏览器的连接,你再好好理解一下

    2020-09-19 23:34:02

  • like_wind

    2020-03-23 17:16:28

    没太理解老师说的这个房间的概念,文中提到的“房间概念”是指客户端和服务端的一个连接就是一个房间吗?还是说这个房间指的是业务上的某一个房间,比如说某直播平台的一个直播间?
    作者回复

    是的,就是指后者“一个直播间”

    2020-03-24 16:07:25

  • dahaowenge

    2019-10-10 18:47:05

    node serves.js Error: ENOENT: no such file or directory, open './cert/1557605_www.learningrtc.cn.key'
    作者回复

    你要使用自己的证书,这是我的证书你是不能用的。另外要使用真正的证书,你可以通过购买域的,然后申请免费证书的方式获取。购买一个域名最便宜的 6元。但如果在国内购买域名的话,还要进行备案,这个在北京需要 20天的时间

    2019-10-12 02:27:20

  • Jason

    2019-08-27 10:18:34

    老师好,问一下, 这套简单的信令系统,已经实现了交换各端sdp信息的功能了吗?
    作者回复

    实现了,不过对他的讲解在第23篇文章中才会做详细介绍

    2019-08-27 12:01:29

  • 许童童

    2019-08-08 18:27:33

    两个不同的 V8 引擎上,你知道它们的对应关系吗
    一个是nodejs服务端的V8,一个是浏览器中客户端的V8。
    作者回复

    没错!

    2019-08-08 23:41:35

  • ifelse

    2025-07-16 12:23:19

    学习打卡
  • 里脊

    2025-02-07 16:00:42

    这节课就是实现了一个 socket 服务器吗?怎么交换 webrtc 信息没有说🤔
  • 小明

    2024-09-11 09:32:17

    请问这个课程还在维护吗?这节课的代码好像不能运行。
    server.js 服务端运行后,然后打开index.html,点击‘connect'没有任何的打印,没有’join‘这些变化
  • ShawnWu

    2023-11-30 22:34:23

    server能用node跑起来,但是client没有监听任何websockt地址,应该咋样建联呢,没搞清楚怎么跑起来客户端
  • 人生在事

    2020-11-04 16:18:35

    <script src='/socket.io/socket.io.js'></script>这段代码没看到,李老师。第一次接触nodejs和socket.io.js,不太明白,希望指导一下
    作者回复

    就是引入 socket.io库

    2020-11-10 18:31:50

  • SherwinFeng

    2020-01-16 23:21:35

    老师,我运行了github上的demo,遇到了一个问题:
    client端不能显示发送出去的消息
    已尝试的排查:
    ①查看了日志发现client发送的消息服务器端已经接收到了,并且显示了消息内容(这里自己修改了demo中的debug信息,以便记录消息内容,原demo只能显示data.type)
    ②F12调试时发现client.js中的监听“message”事件没有执行
    这是什么原因造成呢?
    作者回复

    看一下是不是客户端与服务端的参数没有匹配上?

    2020-02-23 12:02:45

  • Geek_r2sfwe

    2019-11-21 11:07:44

    老师你好,我下载了您的代码,更改了cert,但是报了一个错误,我不知道怎么解决
    node server.js
    /root/node_modules/streamroller/lib/RollingFileWriteStream.js:133
    async _shouldRoll() {
    ^^^^^^^^^^^

    SyntaxError: Unexpected identifier
    at createScript (vm.js:56:10)
    at Object.runInThisContext (vm.js:97:10)
    at Module._compile (module.js:549:28)
    at Object.Module._extensions..js (module.js:586:10)
    at Module.load (module.js:494:32)
    at tryModuleLoad (module.js:453:12)
    at Function.Module._load (module.js:445:3)
    at Module.require (module.js:504:17)
    at require (internal/module.js:20:19)
    at Object.<anonymous> (/root/node_modules/streamroller/lib/index.js:2:27)

    是我的环境有问题么
    作者回复

    应该是你的环境问题,你看一下你的node版本和npm版本是不是太低了?

    2019-11-23 17:24:51