一、概述
Mediasoup 主要提供了 3 个库和 1 个 demo。
| 库名 | 说明 |
|---|---|
| mediasoup | 主要包含三部分。一是 worker 可执行程序,由 C++ 实现,是本系列分析的重点;二是 Node 库,由 TypeScript 实现;三是 Rust 库,和 Node 的主要不同在于它没有以进程方式而是以静态库方式使用 mediasoup-worker。 |
| mediasoup-client | Web 客户端库。TypeScript 实现。 |
| libmediasoupclient | Native 客户端库。C++ 实现。 |
| mediasoup-demo | 官方 Demo。 |
| Examples | 各种示例。 |
网络上对 mediasoup 的 Node.js 层——准确说是对官方的 mediasoup-demo 的源码分析比较多,对于 mediasoup-client 和 mediasoup-worker (之后简 worker) 等的源码详细分析相对较少。本人之前有将 GB28281 集成进 mediasoup 的想法并验证了可行性,以及使用 .Net 重新实现过 Node.js 层(含 mediasoup-client 和 mediasoup-demo),对 worker 的源码进行过比较粗略地浏览。最近基于想要弥补一些比较模糊的认知,并且 mediaoup 本身也在进化,故就再做了一次源码的梳理。
至于 mediasoup 是什么、能做什么、与其他 SFU 相较而言的优缺点、Demo 如何运行、为什么不用单一语言来实现等等讨论不是本系列关注的重点。
二、名词/概念
mediasoup 定义和抽象了一些名词和概念,为了方便描述,做整理如下表:
| 名词/概念 | 说明 |
|---|---|
| Settings | 设置。用于读取命令行配置、将配置输出到日志,以及运行时更新配置。另外用 线程本地存储 保存配置以确保每个线程都有一个 Settings 配置对象。当然,以 worker 进程方式运行不存在这个问题。设置的选项(option: getopt.h)具体包括:logLevel、logTags、rtcMinPort 和 rtcMaxPort、dtlsCertificateFile 和 dtlsPrivateKeyFile 这六项,分别表示日志等级、日志标签(如ice、dtls等,控制哪些类的日志才会传出)、最小和最大 rtc 端口,以及 dtls 证书文件和私钥的物理路径。这些参数都是可选的,如果未提供则会使用默认值或自动生成。 |
| Logger | 日志。有 debug、warn、error 和 none 四个等级。日志通过 ChannelSocket 传输到父进程,如果 ChannelSocket 尚未创建则会在标准输出中打印日志。mediasoup 给很多方法都加了 Trace 日志,打开 MS_LOG_TRACE 编译宏开关并且设置日志等级为 debug 则会输出。类似于 Settings,Logger 也用线程本地存储保存 Buffer 和 ChannelSocket 以确保每个线程都有一个对象。 |
| ChannelSocket | 通道 Socket。抽象了 worker 进程和父进程的通信。得益于 libuv,对于进程间通信开发者不用关心操作系统是用的 Linux/UNIX 的 UNIX Domain Socket 还是 Windows 的 IPC 等。而 mediasoup 的这层 ChannelSocket 抽象,也为后来支持的 以库的方式使用 worker 打下基础。ChannelSocket 是双向的,主要向父进程传输日志;接受父进程的如创建 Transport、Producer 等指令请求;向父进程传输指令请求的执行结果;向父进程传输 Producer 暂停关闭、SCTP 发送 Buffer 已满之类的通知。所有消息都是基于文本的。 |
| PayloadChannelSocket | 负载通道 Socket。类似于 ChannelSocket,不同的是 PayloadChannelSocket 用于 DirectTransport 向父进程发送 RTP 或 RTCP 数据包等。消息是基于文本和二进制的。 |
| Worker | 工作者。注意这里并非指 worker 进程,但某种意义下可以指代 worker 进程。ChannelSocket 和 PayloadChannelSocket 只负责消息的接收和发送,而 Worker 是具体消息的处理入口。它根据消息的类别进行处理,对于自身可以负责的请求比如获取资源使用率、创建及关闭 Router、dump 获取本 Worker 下的 Router 的 Id 集合等则直接处理,其余的就交给 Router 来处理。遵循谁创建谁销毁的原则,Router 的销毁工作也由 Worker 负责。 |
| Router | 路由。Router 是比较重要的概念,不准确地说可以将 Router 和房间对应起来。其保存了 Transport、Producer、Consumer、Producer下对应的 Consumer 以及 DataProducer 和 DataConsumer 相关的集合。如上文所述,Worker 将其处理不了的工作交给 Router,Router 能够处创建和关闭 Transport 以及和 RtpObserver 相关的几个操作请求,其余的就交给 Transport 处理。另外,Router 只负责 Transport 和 RtpObserver 的销毁工作,对于 Producer、Consumer 等则由创建这些对象的 Transport 负责。 |
| Transport | 传输通道。Transport 是抽象类,具体类包括:WebRtcTransport、PlainTransport、DirectTransport 和 PipeTransport。 |
| WebRtcTransport | WebRtc 传输通道。WebRtcTransport 包含 Socket 服务端,将接收到的数据包尝试转换为 StunPacket、Rtcp Packet、RtpPacket 或 Dtls 数据 中的一种。StunPacket 由 IceServer 处理,Rtcp Packet 会经过处理后发送给 Consumer, RtpPacket 由相应的 Producer 处理,Dtls 数据当然是先解密再处理;也会通过 Sokcet 将数据发送到本 Transport 下对应的 Consumer 中去。 |
| PlainTransport | Plain 传输通道。类似于 WebRtcTransport,不同的是不会收到 StunPacket 。 |
| PipeTransport | Pipe 传输通道。用于跨 Router/worker Rtp 包传输。 |
| DirectTransport | Direct 传输通道。DirectTransport 不包含 Socket 服务端,将接收到的数据直接通过 PayloadChannel 发送给 Consumer。 |
| Producer | 生产者。类型有四类:SIMPLE、SIMULCAST、SVC 和 PIPE。 |
| Consumer | 消费者。Consumer 是抽象类,具体类包括:SimpleConsumer、SimulcastConsumer、SvcConsumer 和 PipeConsumer。如果是 PipeTranspor 进行消费,则 type 为 “pipe”,否则为 Producer 的 type。 |
| SimpleConsumer | 单体消费者。 |
| SimulcastConsumer | Simulcast 消费者。 |
| SvcConsumer | Svc 消费者。 |
| PipeConsumer | Pipe 消费者。 |
| DataProducer | Data 生产者。类型有两类:SCTP 和 DIRECT。只有传递 enableSctp 为 true 的参数才能创建类型为 SCTP 的 DataProducer;只能在 DirectTransport 之上创建类型为 DIRECT 的 DataProducer。 |
| DataConsumer | Data 消费者。只有传递 enableSctp 为 true 的参数才能创建类型为 SCTP 的 DataProducer;只能在 DirectTransport 之上创建类型为 DIRECT 的 DataConsumer。 |
| RtpObserver | Rtp 观察者。RtpObserver 是抽象类,具体类包括:AudioLevelObserver 和 ActiveSpeakerObserver。 |
| AudioLevelObserver | 音量观察者。收到音频 Rtp 包后累计音量。比如每1秒间隔计算触发一次计算,如果对应 Producer 收集了 10 个及以上的包则计算音频的平均值,如果值大于默认 -80dB 则将 Producder 的 Id 收集起来,最多收集指定参数 maxEntries 那么多个。如果存在符合条件的 Producer 则发出 volumes 事件,否则发出 silence 事件。事件会发送给父进程,而父进程会发送给相应的客户端。 |
| ActiveSpeakerObserver | 说话人观察者。ActiveSpeakerObserver 是 3.8.0 新增的。具体算法不算太简单,大致看并不是谁说话就算,而是计算出会议中说话人中谁是主导者。如果存在符合条件的唯一 Producer 则发出 dominantspeaker 事件。事件会发送给父进程,而父进程会发送给相应的客户端。 |
父对象通常会监听子对象的相关事件,比如 Worker 会监听 ChannelSocket 和 PayloadChannelSocket 的相关事件(Router没有监听器),Router 会监听 Transport 的相关事件, Transport 会监听 Producer 、 Consumer 等的相关事件。
如果要扩展其他协议,比如支持 GB28181 的 PS 流,可以创建自定义 Transport 。
如果要集成一些 AI 功能,比如目标识别,手势识别,语音指令识别等,可以创建自定义 RtpObserver 。
三、ChannelSocket 消息类型
ChannelRequest::MethodId 枚举定义了各种消息。其命名有一定规律,除 *.close 和 RtpObServer 相关的消息外,第一个下划线前的第一个单词通常标明了消息由谁来处理。比如 TRANSPORT_PRODUCE,表示 Transport 需要处理 Produce 消息。
1 | // File: worker/src/Channel/ChannelRequest.cpp |
四、PayloadChannelSocket 消息类型
PayloadChannelRequest::MethodId 枚举定义一种消息。
1 | // File: worker/src/PayloadChannel/PayloadChannelRequest.cpp |
五、ChannelSocket 通知类型
为了方便使用, mediasoup 定义了 ChannelNotifier 类用于 worker 向父进程发送通知。通知类型整理如下:
| Sender | 类型 | 说明 |
|---|---|---|
| Workder | running | worker 运行中。 |
| ActiveSpeakerObserver | dominantspeaker | 当前主导发言者。 |
| AudioLevelObserver | volumes | 当前发言者的音量。 |
| - | silence | 之前发言者已静音。 |
| Consumer | producerpause | 生产者已暂停。 |
| - | producerresume | 生产者已恢复。 |
| - | producerclose | 生产者已关闭 |
| - | trace | 跟踪消费者信息。类型包括:rtp、keyframe、nack、pli 和 fir。 |
| DataConsumer | bufferedamountlow | 数据消费者 Sctp Buffer 低。 |
| - | dataproducerclose | 数据生产者已关闭。 |
| PlainTransport | tuple | TransportTuple。 当 comedia 为 true 时,通过收到的数据提取到 TransportTuple。 |
| - | rtcptuple | TransportTuple。 当 comedia 为 true 且 rtcpMux 为 false, 通过收到的数据提取到 TransportTuple。 |
| Producer | videoorientationchange | 视频方向发生改变。 |
| - | score | Rtp 流的质量评分。 |
| - | trace | 跟踪生产者信息。类型包括:rtp、keyframe、nack、pli 和 fir。 |
| SctpAssociation | sctpsendbufferfull | DataConsumer 的 Sctp 发送 Buffer 已满。 |
| SimpleConsumer | score | Rtp 流的质量评分。 |
| SimulcastConsumer | score | Rtp 流的质量评分。包含自身的和 Producer 的。 |
| - | layerschange | Layers 已改变。 |
| SvcConsumer | score | Rtp 流的质量评分。包含自身的和 Producer 的。 |
| - | layerschange | Layers 已改变。 |
| Transport | trace | 跟踪 Transport 信息。类型包括:probation 和 bwe。 |
| - | sctpstatechange | Sctp 状态已改变。 |
| WebRtcTransport | iceselectedtuplechange | Ice 选择的 TransportTuple 已改变。 |
| - | icestatechange | Ice 状态已改变。 |
| - | dtlsstatechange | Dtls 状态已改变。 状态包括:connecting、connected、completed、disconnected、closed 和 failed。 |
六、PayloadChannelSocket 通知类型
为了方便使用, mediasoup 定义了 PayloadChannelNotifier 类用于 worker 向父进程发送通知。通知类型整理如下:
| Sender | 类型 | 说明 |
|---|---|---|
| DirectTransport | rtp | Rtp 包。 |
| - | rtcp | Rtcp 包。 |
| - | message | 消息。 |
七、worker 进程的启动
worker 是可执行程序,(一般来说)需要通过其他进程来启动。
启动时首先需要设置环境变量 MEDIASOUP_VERSION,通常设置为 mediaoup 真实的版本号。当然,如果非要设置成其他内容也没关系。
1 | // File: worker/src/main.cpp |
还可以以命令行参数的方式设置 logLevel、logTags、rtcMinPort 和 rtcMaxPort、dtlsCertificateFile 和 dtlsPrivateKeyFile,分别表示日志等级、日志标签(如ice、dtls等,控制哪些类的日志才会传出)、最小和最大 rtc 端口,以及 dtls 证书文件和私钥的物理路径。这些参数都是可选的,如果未提供则会使用默认值或自动生成。
因为 worker 是独立进程,需要和父进程通信。以 Linux 为例,在 fork 子进程后会将父进程的文件描述符传递到子进程中。具体来说 Node.js 程序 fork worker 进程之前,会创建几个 libuv 概念下而非 Linux 概念下的抽象意义上的 pipe,在 Linux 中使用的是 UNIX Domain Socket。fork 进程后,会在子进程将要使用的文件描述符重定向。这里子进程期望持有的文件描述符是 3-6,而实际上父进程创建的可能是 11-15,fork 之后子进程得到的还是 11-15,只要在子进程中使用 fcntl 系统调用重定向即可。通过合理的数量和顺序上的约定能确定重定向为 3-6。最终在子进程中 exec mediasoup-worker(见:uv__process_child_init)。
mediasoup 抽象出叫做 ChannelSocket 和 PayloadChannelSocket 的概念来表示 worker 和父进程的通信信道。
mediasoup 最初只使用了 1 个文件描述符,后来改为了 2 个,再后来改为了 4 个。
八、以库的方式使用 worker
在较长的一段时间,worker 都是以进程的方式运行,不过直到 2021 年 3 月发布的 3.7.0 开始, 准确地说是从 1a805366 的这次提交开始,已经支持以库的方式使用。
如果要像 mediasoup-sfu-cpp 那样用纯 C++ 实现整个 mediasoup 服务端,则可以直接调用 mediasoup_worker_run 方法。当然,也就不用设置环境 MEDIASOUP_VERSION 变量了。
1 | // File: worker/src/lib.hpp |
以进程方式运行时进程间通所使用的是文件描述符(consumerChannelFd等),以库的方式运行则可以将函数指针传递给 mediasoup_worker_run 的其他参数(PayloadChannelReadFn等),都能够构造出 ChannelSocket 和 PayloadChannelSocket。
还可以用多个线程执行 mediasoup_worker_run,以达到多个 worker 进程的效果。
备注:mediasoup-sfu-cpp 是基于旧版 mediasoup,尚未更新至可以使用 mediasoup_worker_run 方法的版本。
参考资料
现代C++和Mediasoup的WebRTC集群服务实践
纯 C++ 实现的 Mediasoup 闫华
【流媒体】Mediasoup库的架构(C++部分)
libuv
mediasoup v3 Design
mediasoup v3 API
mediasoup-client v3 API