一、概述

(图片来源:李超)
mediasoup
的服务端由两部分构成:
1、使用 C++
编写的作为子进程的媒体层 (ICE
, DTLS
, RTP
等)。可执行文件在 Linux
或 macOS
上为 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)
libuv
和 V8
是 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
| this._child = spawn( spawnBin, spawnArgs, { env : { MEDIASOUP_VERSION : '__MEDIASOUP_VERSION__' },
detached : false,
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
|
#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);
return 0; }
|
三、进程及进程间通信:ASP.NET Core 版
我们通常在 .Net
中使用 Process
类创建子进程,而 Process 类满足不了需求并且直接使用 Win32
的 CreateProcess
将问题复杂化了。我决定使用 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 { _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 左右,效果图(图上是本地视频,图下是远程视频):

参考资料