🧊

OSSのWebRTC SFU mediasoup v3のコードを読む(サーバー/NodeJS編)

クライアントは読んだので、次はサーバーを。

OSSのWebRTC SFU mediasoup v3のコードを読む(クライアント編) - console.lealog();

こっちもまだ未リリースですが、読むだけなら損はないだろうという話。

コードの入手

先日`master`に入ったので、そのまま読めます。

GitHub - versatica/mediasoup: Cutting Edge WebRTC Video Conferencing

`mediasoup`はNodeJSで書かれてる部分と、C++で書かれてる部分から成っているので、
とりあえずNodeで書かれてるパートを読んで、そこから察しつつ必要なら別記事でC++も見ていこうかと。

Nodeは`/lib`に、C++は`/worker/src`がメインのソースが置いてある。

コードの雰囲気

ファイルはこのように。(Node側だけ)

.
├── AudioLevelObserver.js
├── Channel.js
├── Consumer.js
├── EnhancedEventEmitter.js
├── Logger.js
├── PipeTransport.js
├── PlainRtpTransport.js
├── Producer.js
├── Router.js
├── RtpObserver.js
├── Transport.js
├── WebRtcTransport.js
├── Worker.js
├── errors.js
├── index.js
├── ortc.js
├── supportedRtpCapabilities.js
└── utils.js

というわけでクライアントよりも規模は大きい(C++側はもっと多い)ので、片っ端から読んでいくパターンはあまり効率がよくなさそう。

なので、先日デモ用途で用意した最低限の実装を足がかりにしていくことにする。

GitHub - leader22/mediasoup-demo-v3-simple

サーバーサイドでやってることはざっくりこう。

  • クライアントが接続してきたら
    • `Room`を作ってそこにいれる
    • 以後やりとりできるようWebSocketを張る
  • WebSocket上での各種I/O
    • RtpCapabilitiesの連携
    • WebRTCセッションの確立
    • `Room`への参加
    • `Room`へのメディアの送信・受信

詳細はサーバーサイドのコードを読むとして、登場するAPIやら概念を軽く追う。

クライアントが接続してきたら

まずはじめの処理、このリポジトリの`/server/server.js`でやってるだいたいの流れが以下。

  • 指定された数だけ、`meidasoup`の`Worker`を起動
  • クライアントが接続してきたら、`Room`の概念を用意する
    • `mediasoup`ではなくアプリ側で
  • 各`Room`には単一の`Worker`をアサインする
    • その際、`Worker`の`createRouter()`を呼んで、以後はそれを`Room`内で扱う
    • `Router`が実際の本丸

WebSocket上での各種I/O

WebSocketは、`protoo`という作者謹製のWebSocketラッパーが使われてる。
送ったメッセージに対するレスポンスを`await`して待てるAPIが特徴的。

そんなWebSocketでやり取りされるメッセージの最低限が以下の通り。

  • getRouterRtpCapabilities
  • createWebRtcTransport
  • connectWebRtcTransport
  • join
  • produce

これはあくまでデモのコードで使ってる切り口なのに注意。必要最低限ではあるけど。

getRouterRtpCapabilities

これは`mediasoup`のお作法として必要なステップ。

サーバー側で対応しているメディアのコーデックやパラメータを、いったんクライアントに理解させるAPI

`mediasoup-client`の`Device`クラスが`load()`する時にコレを使う。

createWebRtcTransport / connectWebRtcTransport

次にWebRTC的なセッションを確立する。

`Router`の`createWebRtcTransport()`で作っておいて、`transport.connect()`する。

`mediasoup`の用語で、メディアを送信することを`produce`といって、受信することを`consume`という。
それぞれ`Producer`と`Consumer`が対応する概念で、それができるのが`Transport`という概念。

join

`Room`に入ってきたクライアントに対して、既に`Room`にいてメディアを送信しているピアの情報を伝える処理。

既に`Room`にいてメディアを送信しているピアの数だけ、`Consumer`を用意してメディアを受信してる。

APIとしては、`transport.consume()`が該当の処理。

produce

クライアントから送信されたメディアを扱う処理。

ここではサーバー側の`transport.produce()`を呼びつつ、`Room`内の他のピアのために`Consumer`を用意してる。

カンファレンスアプリのデモの場合、その`Room`に入った = 受信の意思があるとして`Consumer`を用意できることになる。

どこから追うか

以上の流れを踏まえると、コードリーディングのきっかけとして適当なのはこんな感じかなーと。

  • `index.js`
  • `Router.js`
  • `WebRTCTransport.js`

`lib/index.js`

まずはこの部分から。

エクスポートしてるのは、

  • `version`
    • ただのバージョン番号
  • `createWorker()`
    • さっきの`Worker`を作って返す処理で、`Promise`で`Worker`を返す
  • `getSupportedMediaCodecs()`
    • 起動時に指定できる対応コーデックやパラメータのリスト
  • `observer`
    • 各種イベントを拾える専用の`EventEmitter`

というわけで、もちろん`createWorker()`からスタート。

これは初期化した`Worker`の`@success`イベントを待って`resolve()`される。

`lib/Worker.js`

`@success`が発火するまでを追う。

  • `this._channel.once(this._pid)`で受けているイベント内で発火
  • `this._channel`は、`this._child`で初期化される`Channel`クラス
    • `this._pid`が渡されてる
  • `this._child`は、`child_process.spawn()`した子プロセス
    • `this._pid`は、この子プロセスの`pid`
    • 実行されるのはC++のmediasoupの`Worker`
    • `worker/src/main.cpp`
  • この子プロセスの`stdio[3]`が`Channel`クラスに渡ってる
`child_process.spawn()`のオプション

`detached`と`stdio`の2つが重要。

`detached`は親プロセスとの関係性を定義してて、`false`の場合は`detach`しない = 親プロセスと生涯を共にする。

Child Process | Node.js v11.12.0 Documentation

`stdio`は親子間での入出力を制御するために指定するもの。

Child Process | Node.js v11.12.0 Documentation

基本的には`stdin`、`stdout`、`stderr`の3種類に対して設定し、デフォルトはすべて`pipe`。

しかしここでは4つ目を指定してるのがミソで、そのFile Descriptorを介してNodeとC++がやり取りをする。
それを担ってるのが`Channel`クラスという話。

`lib/Channel.js`

  • C++側と会話するためのクラス
    • `request()`で送信
    • `this._socket.on('data')`で受信
  • `request()`時は、`this._socket.write()`して書き込み
    • `this._sents`に送った内容を保持する
    • いったん`Promise`を返す
  • そして`this._socket.on('data')`で受け取る
    • C++側から書き込まれるのはJSONかログの2択
    • JSONの場合は`this._sents`とレスを照合してさっきの`Promise`を`resolve()`する
    • ログはそのまま出力

そういう意味での連絡チャンネルであり、深く追う必要はなさそう。

ともあれ無事にC++側の`Worker`が初期化されると、この`Channel`を通じて最初のメッセージが返ってくる。
それを受けて`Worker`が`@success`を発火するという流れ。

`lib/Router.js`

`Room`ごとにアサインされるNode側の`Worker`は、

  • Node側に`Router`をつくり
  • C++側に`Worker`を(子プロセスで)つくり
  • 両者を`Channel`でつなぐ

という感じで、Node側の窓口であるこの`Router`に生えてるのは次のとおり。

  • `createXxxTransport()`
    • `WebRtcTransport`だけでなく、`PlainRtpTransport`と`PipeTransport`の3つある
  • `pipeToRouter()`
    • `Router`と別の`Router`をつなげられるっぽい
  • `createAudioLevelObserver()`
    • RTPからセッション内の音声のレベルを検知する仕組み
    • `volume`/`silence`の2つのイベントが発行される
    • 実態はC++
  • `canConsume()`
    • クライアントが`consume()`したいといっても、実際にコーデックなどの条件を満たしてるか確認

`createXxxTransport()`で作ることのできる3種類を見ていく。

`WebRtcTransport`

  • extends `Transport`
  • WebRTC用でありブラウザとメディアを送受信する用
  • `connect()`
    • DTLSのRoleとFingerprintで接続
  • `restartIce()`
    • その名の通りICEのリスタート

などなど、APIはいろいろあるが基本的に`Channel`を経由していて、実態はC++側にある。

`PlainRtpTransport`

  • extends `Transport`
  • RTP用
  • `connect()`
    • RTPを飛ばすIPとポートで接続

これを使うと、`ffmpeg`と連携してあれこれしたりできる。

`PipeTransport`

  • extends `Transport`
  • `pipeToRouter()`する用
    • v3から追加された

`Router`どうしをつなぐユースケースって、データセンター間とかそういう感じかな?

`Transport`

すべての`XxxTransport`が継承するこいつ。

  • `Producer`を返す`produce()`
    • `pause()`/`resume()`
  • `Consumer`を返す`consume()`
    • `pause()`/`resume()`
    • `setPreferredLayers()`
    • `requestKeyFrame()`

これも結局の実態はC++側にある。

まとめ

ファイルの一覧と、その用途のおさらい。

.
├── AudioLevelObserver.js: RTPから直接音声の入力を調べて返す用
├── Channel.js: NodeとC++の連絡用
├── Consumer.js: メディア受信用のクラス
├── EnhancedEventEmitter.js: Promiseが返せるEventEmitter
├── Logger.js: ロギング用
├── PipeTransport.js: RouterどうしをつなげるTransport
├── PlainRtpTransport.js: RTPをやり取りするTransport
├── Producer.js: メディア送信用のクラス
├── Router.js: Node側の本丸
├── RtpObserver.js: RTPを直接さわるときの基底クラス
├── Transport.js: 各Transportの基底クラス
├── WebRtcTransport.js: ブラウザとやり取りするTransport
├── Worker.js: C++の子プロセスを抱えるクラスで、Routerを生み出す
├── errors.js: エラー用のutils
├── index.js: モジュールとしてのエクスポート
├── ortc.js: RTPで使うパラメータや送受信の際のマッチングなどのutils
├── supportedRtpCapabilities.js: サポートしているコーデックやパラメータの一覧
└── utils.js: utils

ぜんぶ同じディレクトリにいるけど、これでうっすら関係性が見えるはず・・!

  • `Room`の概念は`mediasoup`本体にはない
    • v2まではあったらしいが、v3にはない
  • 基本的にC++側に実装の本体はある
    • Node側はブラウザや外界とのI/Oしか担当しない
    • 内部的な`Channel`で非同期にやりとりする
  • ブラウザだけではなく、IP/ポートでRTPを直接やり取りすることもできる
  • Node側では`Worker`と`Router`を作って、そこに各エンドポイントと`Transport`を確立
    • あとは`Transport`上で、`Producer`と`Consumer`を任意に用意してメディアを流す

細かいユースケースまで見ていけば、ここでこういう処理をしてるのにはこういう理由がある!とかまで追えるけど、あくまでOverviewがこのシリーズの目的なので、詳細度はこれくらいで。

さて・・、C++側も読むしかないなーという感じ。(ただ実際すらすら読めないので、社内の優秀な若者に代読してもらって相槌を打って理解したい気持ち)