使用 ASP.NET Core 实现 mediasoup 的信令服务器

一、概述


(图片来源:李超)

mediasoup 的服务端由两部分构成:
1、使用 C++ 编写的作为子进程的媒体层 (ICE, DTLS, RTP 等)。可执行文件在 LinuxmacOS 上为 mediasoup-worker,在 Windows 上为 mediasoup-worker.exe
2、使用 Javascript(Typescript) 编写的、基于 Node.js 的用于与 mediasoup-worker 进行通信的组件。因为官方或几乎所有第三方的 mediasoup 服务端都是使用的是 Node.js 来实现,所以官方提供一个中间层让开发者不直接和 mediassoup-workder 交互。

本文主要讨论如何使用 ASP.NET Core 替换 Javascript(Node.js) 的实现。


(备注:由于是在参考图基础上 PS 的,不太准确,有心情了再改吧。)

二、进程及进程间通信:Node.js 版

1、Node.js 的 spawn 和 libuv uv_spawn(fork/exec)

libuvV8 是 Node.js 的基石,而 mediasoup-worker 也使用了 libuv。
在 Node.js 程序中,安装 mediasoup 的模块时会将 mediasoup-worker 会自动编译在 node_modules 里。可以直接将 mediasoup-worker 拷贝出来在 Shell 中运行——当然,一运行就会退出。

1
2
> ./mediasoup-worker
mediasoup-worker::main() | you don't seem to be my real father!

通过查看 mediasoup-worker 的源码得知其需要一个 MEDIASOUP_VERSION 环境变量——当然,加上后一运行还是会退出。

1
2
3
> MEDIASOUP_VERSION=3.5.5 ./mediasoup-worker
UnixStreamSocket::UnixStreamSocket() | throwing MediaSoupError: uv_pipe_open() failed: inappropriate ioctl for device
mediasoup-worker::main() | error creating the Channel: uv_pipe_open() failed: inappropriate ioctl for device

原因是 mediasoup-worker 依赖于两个目前并不存在的文件描述符 3 和 4。这里的 3 和 4 其实是一种约定。那在 Shell 中重定向到标准输出试试。

1
2
> MEDIASOUP_VERSION=3.5.5 ./mediasoup-worker 3>&1 4>&1
37:{"event":"running","targetId":"3574"},

能够获取到 mediasroup-worker 启动成功后的输出。

在 Linux 上,在 fork 子进程的时候,会将父进程的文件描述符传递到子进程中,这是进程间通信的一种方式。Node.js 程序 fork 进程之前,会创建几个 libuv 概念下而非 Linux 概念下的抽象意义上的 pipe,在 Linux 中使用的是 Unix Domain Socket 实现。Node.js 程序或者说 libuv fork 进程后,会在子进程将要使用的文件描述符重定向。比如在父进程,期望子进程持有的文件描述符是 3 和 4 而实际上是 11 和 13,fork 之后还是 11 和 13 ,在子进程中使用 fcntl 系统调用重定向。通过合理的数量和顺序上的约定能确定重定向为 3 和 4 。最终在子进程中 exec mediasoup-worker(见:uv__process_child_init)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// File: node_modules/mediasoup/src/Worker.ts
this._child = spawn(
// command
spawnBin,
// args
spawnArgs,
// options
{
env :
{
MEDIASOUP_VERSION : '__MEDIASOUP_VERSION__'
},

detached : false,

// fd 0 (stdin) : Just ignore it.
// fd 1 (stdout) : Pipe it for 3rd libraries that log their own stuff.
// fd 2 (stderr) : Same as stdout.
// fd 3 (channel) : Producer Channel fd.
// fd 4 (channel) : Consumer Channel fd.
stdio : [ 'ignore', 'pipe', 'pipe', 'pipe', 'pipe' ]
}
);

参考:Node.js 的 spawn 和 libuv 的 uv_spawn 的实现源码,以及 mediasoup 的 Node.js 模块的源码。

备注:libuv 在 Windows 上进程间通信使用的是命名管道(Named Pipe)。

2、C 实现

下面是使用 C 语言实现的一个非常粗糙的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
//
// main.c
// TestMedaisoup
//
// Created by Alby on 2020/3/31.
// Copyright © 2020 alby. All rights reserved.
//
#include <stdio.h>
#include <uv.h>
#define ASSERT(expr) \
do { \
if (!(expr)) { \
fprintf(stderr, \
"Assertion failed in %s on line %d: %s\n", \
__FILE__, \
__LINE__, \
#expr); \
abort(); \
} \
} while (0)
static int close_cb_called;
static int exit_cb_called;
static uv_process_t process;
static uv_process_options_t options;
static char* args[5];
#define OUTPUT_SIZE 1024
static char output[OUTPUT_SIZE];
static int output_used;
static void init_process_options(char* test, uv_exit_cb exit_cb) {
char *exepath = "/Users/XXXX/Developer/OpenSource/Meeting/Lab/worker/mediasoup-worker";
args[0] = exepath;
args[1] = NULL;
args[2] = NULL;
args[3] = NULL;
args[4] = NULL;
options.file = exepath;
options.args = args;
options.exit_cb = exit_cb;
options.flags = 0;
}
static void close_cb(uv_handle_t* handle) {
printf("close_cb\n");
close_cb_called++;
}
static void exit_cb(uv_process_t* process,
int64_t exit_status,
int term_signal) {
printf("exit_cb\n");
exit_cb_called++;
ASSERT(exit_status == 1);
ASSERT(term_signal == 0);
uv_close((uv_handle_t*)process, close_cb);
}
static void on_alloc(uv_handle_t* handle,
size_t suggested_size,
uv_buf_t* buf) {
buf->base = output + output_used;
buf->len = OUTPUT_SIZE - output_used;
}
static void on_read(uv_stream_t* tcp, ssize_t nread, const uv_buf_t* buf) {
if (nread > 0) {
output_used += nread;
printf(buf->base);
} else if (nread < 0) {
ASSERT(nread == UV_EOF);
uv_close((uv_handle_t*)tcp, close_cb);
}
}
int main() {

const int stdio_count = 5;
int r;
uv_pipe_t pipes[4];
uv_stdio_container_t stdio[5];
init_process_options("spawn_helper5", exit_cb);
for(int i = 1; i < stdio_count; i++) {
uv_pipe_init(uv_default_loop(), &pipes[i-1], 0);
}
stdio[0].flags = UV_IGNORE;
for(int i = 1; i < stdio_count; i++) {
stdio[i].flags = UV_CREATE_PIPE | UV_READABLE_PIPE | UV_WRITABLE_PIPE;
stdio[i].data.stream = (uv_stream_t*)&pipes[i-1];
}

char* quoted_path_env[1];
quoted_path_env[0] = "MEDIASOUP_VERSION=3.5.5";
options.env = quoted_path_env;
options.stdio = stdio;
options.stdio_count = stdio_count;
r = uv_spawn(uv_default_loop(), &process, &options);
ASSERT(r == 0);
for(int i = 1; i < stdio_count; i++) {
r = uv_read_start((uv_stream_t*) &pipes[i - 1], on_alloc, on_read);
ASSERT(r == 0);
}
r = uv_run(uv_default_loop(), UV_RUN_DEFAULT);
ASSERT(r == 0);
ASSERT(exit_cb_called == 1);
ASSERT(close_cb_called == 5); /* Once for process once for the pipe. */

return 0;
}

三、进程及进程间通信:ASP.NET Core 版

我们通常在 .Net 中使用 Process 类创建子进程,而 Process 类满足不了需求并且直接使用 Win32CreateProcess 将问题复杂化了。我决定使用 Libuv——幸好微软提供了一个 Libuv 的 Nuget 包,支持 Linux、macOS 和 Windows;其次 LibuvSharp 提供了 P/Invoker 实现。
下面是 C# 版的 spawn, 看起来没有 Node.js 版那么简洁,但是功能完全一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// ......
_pipes = new Pipe[StdioCount];
// 备注:忽略标准输入
for (var i = 1; i < StdioCount; i++)
{
_pipes[i] = new Pipe() { Writeable = true, Readable = true };
}

try
{
// 备注:和 Node.js 不同,_child 没有 error 事件。不过,Process.Spawn 可抛出异常。
_child = Process.Spawn(new ProcessOptions()
{
File = mediasoupOptions.WorkerPath,
Arguments = args.ToArray(),
Environment = env,
Detached = false,
Streams = _pipes,
}, OnExit);

ProcessId = _child.Id;
}
catch (Exception ex)
{
_child = null;
Close();

if (!_spawnDone)
{
_spawnDone = true;
_logger.LogError($"Worker() | worker process failed [pid:{ProcessId}]: {ex.Message}");
Emit("@failure", ex);
}
else
{
// 执行到这里的可能性?
_logger.LogError($"Worker() | worker process error [pid:{ProcessId}]: {ex.Message}");
Emit("died", ex);
}
}
// ......

备注:LibuvSharp 原版有个小 bug。 (uv_process_t*)(NativeHandle.ToInt32() + Handle.Size(HandleType.UV_HANDLE)); 需要改为 (uv_process_t*)(NativeHandle.ToInt64() + Handle.Size(HandleType.UV_HANDLE));。另外要使用 Pipe 创建管道而不是看起来更像的 IPCPipe——我被坑得很惨。

四、WebSocket:使用 SignalR 替代 protoo 或 socket.io

通常,在浏览器使用 WebSocket 组件而不是原生 WebSocket 对开发者来说更友好。 Node.js 版常用的是 socket.io, mediasoup 官方 Demo 使用的是 protoo , 而在 ASP.NET Core 下,使用 SignalR 是更好的选择。在改写的过程中发现服务端向客户端发送数据不支持返回值, 不过这个可以准备一个服务端方法供客户端调用来解决。

备注: 在重新实现了服务端的情况下,相应的客户端也需要配合调整,这意味着没法使用官方的客户端。

五、ASP.NET Core 实现

Talk is cheap(图左是 Node.js 实现,图右是 ASP.NET Core 实现):

在本机运行延迟是 130ms 左右, 效果图(图左是本地视频,图右是远程视频):

在外网服务器运行 multiparty-meeting 这个非官方 Demo 的延迟是 160ms 左右,效果图(图上是本地视频,图下是远程视频):

参考资料

mediasoup
multiparty-meeting
nodejs
libuv
libuv-build
LibuvSharp
How to: Use Named Pipes for Network Interprocess Communication
UnixDomainSocketEndPoint Class
How to connect to a Unix Domain Socket in .NET Core in C#
Unix: Why not use Unix Domain Sockets for Named Pipes?
Serving .NET Core apps on Linux with nginx and Kestrel
Introduction to ASP.NET Core SignalR
基于mediasoup的多方通话研究(一)
多人实时互动之各WebRTC流媒体服务器比较