🍃このブログは移転しました。
3秒後、自動的に移動します・・・。

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

C++はまだnoobなんですが、雰囲気を察して読んでみたメモです。

サーバー/NodeJS編はこちら。

OSSのWebRTC SFU mediasoup v3のコードを読む(サーバー/NodeJS編) - console.lealog();

最低限のコードだけでも読んでて息切れするほどにデカいので、前後編にしました。
ただそれでもすべてのコードを読めたわけではないという。

コードの入手

NodeJS編と同じです。

ディレクトリだけ少し違うくて、Nodeが`/lib`でC++は`/worker/src`が本体。

多すぎるのでディレクトリとエントリーポイントである`main.cpp`だけを載せるとこのような構造。

.
├── Channel
├── RTC
│   ├── Codecs
│   ├── REMB
│   ├── RTCP
│   └── RtpDictionaries
├── Utils
├── handles
└── main.cpp

`RTC`ディレクトリの中がメディアサーバーとしての本丸って感じで、70ファイルくらいあります。

コードを読むための前提知識としては、

  • Node側から子プロセスとして`spawn()`されるのが`main.cpp`
    • それが`Worker`という概念を初期化する
  • Node側とC++側は、`Channel`という概念を使って互いにメッセージングしてる
    • `Worker`は基本的にパッシブで、メッセージを待ち受ける
    • なのでコードを読むのもそのメッセージ名からたどることになるはず
  • SFUの1Roomごとに、`Worker`が1つ存在する
    • 参加するクライアントはみんな同じ`Worker`に対して接続する

という感じ。

ではまずエントリーポイントから。

main()

言わずもがな`main.cpp`。

  • `main()`で各種コンポーネントの初期化をしてる
    • ベースになってる`libuv`を筆頭に
  • Node側とやり取りするための`Channel`の設定
  • 各種サーバー設定の反映
  • 各クラスのstaticの初期化
    • OpenSSLやSRTP、DTLSなどなど
    • 後述
  • `Worker`の起動
    • 起動できればNode側に最初のイベントが飛ぶ
    • `Worker`は`libuv`のループを走らせて待機

RTC::DtlsTransport / RTC::SrtpSession

C++文化なのか、クラスのstaticにて自クラスのインスタンスのための処理をやるらしい。

まずはDTLSの`RTC/DtlsTransport.cpp`の`ClassInit()`から。

  • DTLSのためにTLSのコンテキストを初期化
    • DTLSのバージョンをOpenSSLのバージョンで決める
    • 古いやつだとDTLSv1.0になる
    • もっと古いとエラー
  • ECDHの有効化など
  • `use_srtp`の拡張を指定
  • 各ハッシュごとにFingerprintの用意

つぎ、SRTPは`RTC/SrtpSession.cpp`にて。

ほかにもstatic関連でやってることはあるけど割愛。

Worker

`Worker.cpp`です。

  • `Channel`でのメッセージに対してのハンドラを自身に定義して紐づけ
    • あとは`SIGTERM`などのシグナルに対するハンドラも
  • Node側に`running`だと状態を通知
  • `libuv`のloopを実行

`Channel`を通して受けたメッセージを見てハンドラを実行、その結果をまた`Channel`で返すのが主な仕事。

後で登場するけど、ここで`Router`の新規作成やらインスタンスの保持もやってます。
`Channel`から受け取ったメッセージのいくつかは、そのまま`Router`のハンドラに委譲したりもしてる。

なのでそのメッセージを追っていけば、処理の本体にもたどりつけるはず。

最低限のデモから動きをおさらい

ちょっとだけNodeJSのサイドを見直します。

というのも、クライアントからの入力を受けるのはNodeが起点になるからで、そのきっかけをおさらいしておきたく。

GitHub - leader22/mediasoup-demo-v3-simple

またもこの最低限の自作デモから、サーバーサイドで使ってる各クラスのAPIを抜粋すると・・、

  • Worker
    • createRouter()
  • Router
    • createWebRtcTransport()
    • canConsume()
  • WebRtcTransport
    • connect()
    • produce()
    • consume()

最低限のSFUの機能を使うのに必要なNodeのAPIはこれだけ。

これらを追えば、C++側に投げてるメッセージのタイプがわかるはずで、C++側の入り口に到達できるという算段。

以下、Node側でやってることのサマリです。

Worker

createRouter()
  • `worker.createRouter`というメッセージを送る
    • レスポンスはなし

Router

createWebRtcTransport()
  • SFUとして待ち受けるサーバーを建てる処理
    • IPとかUDP/TCPどっちを優先するとか
  • `router.createWebRtcTransport`というメッセージを送る
    • ICEとDTLSのデータが返る
    • クライアントがいつでもつなぎにいけるようになる
canConsume()
  • サーバー起動時に指定していたコーデックやパラメータの設定を照会するだけ
  • C++側は関係ない

WebRtcTransport

呼び出しシーケンス的には、`produce()` OR `consume()`すると、内部的に`connect()`が先に走るイメージ。

connect()
  • クライアントからDTLSのパラメータを受け取る
    • さっき渡してたやつ
  • `transport.connect`というメッセージを送る
    • DTLSの役割が返る
produce()
  • クライアントがメディアを送信してきた処理
  • `transport.produce`というメッセージを送る
    • 送るのはメディアの種類やRTPのパラメータ
    • 返るのはメディアの状況
consume()
  • クライアントがメディアを受信したいときの処理
  • `transport.consume`というメッセージを送る
    • 送るのはメディアの種類やRTPのパラメータ
    • 返るのはメディアの状況

C++のコードを読む

さっき調べた`Channel`のメッセージのタイプからそれぞれの処理を見ていきます。

  • worker.createRouter
  • router.createWebRtcTransport
  • transport.connect
  • transport.produce
  • transport.consume

worker.createRouter

まず最初に必要となる`Router`を作るタイミングで発火。

メッセージは`Worker`で受け取って、`RTC::Router`を初期化してるだけ。
`RTC/Router.cpp`のコンストラクタでやってることも何もなくて、以降のタイミングで使われるもの。

router.createWebRtcTransport

クライアントが`produce()`や`consume()`でメディアを送受信するために、まずWebRTCのトランスポートが必要。
それを用意する前段のタイミングで発火する。

これは`RTC/Router.cpp`から。

  • `Transport`の初期化
    • `RTC::WebRtcTransport`
  • `Router`に保持
  • `FillJson()`してクライアントに情報を返す

初期化してる`WebRtcTransport`がメイン。
`RTC/WebRtcTransport.cpp`は、圧巻の1200行。

まずはコンストラクタ。

  • `RTC/Transport.cpp`を継承してる
  • ICEのセットアップ
    • 待ち受けるIPの数だけUDP/TCPソケットを作る
    • `RTC::UdpSocket`/`RTC::TcpSocket`
    • そしてパケットの待受
    • STUN / RTCP / RTP / DTLS
  • ICEのサーバーを建てる
    • `RTC::IceServer`
  • DTLSの用意
    • `RTC::DtlsTransport`

`fillJson()`で返すサーバーの情報は、概ねこんな感じ。

{
  iceRole: 'controlled',
  iceParameters: {
    usernameFragment,
    password,
    iceLite: true,
  },
  iceCandidates: [
    {}, {}, ..., {},
  ],
  iceState,
  dtlsParamseters: {
    fingerprints: [{
      algorithm,
      value,
    }, {}, ..., {},],
    role,
  },
  dtlsState,
  rtpHeaderExtensions: {
    absSendTime,
    mid,
    rid,
    rrid,
  },
  rtpListener,
}

これをクライアントに返すので、クライアントは任意のタイミングで接続を開始できるようになる仕組み。

ここで初期化してるクラスの深掘りをしておく。

RTC::IceServer
  • ICE-Liteなので、クライアントにはレスポンスするだけ
  • 引数は`ufrag`と`password`
  • クライアントからSTUNメッセージが届くのを待つ
    • さっき待ち受けた
  • ハンドラは`ProcessStunMessage()`
    • STUNのバインディングリクエストを検証する
    • 内容に応じてエラーレスポンスや成功レスポンスを返す
  • 成功の場合、`HandleTuple()`でクライアントのトランスポートアドレスをさばく
  • stateが`connected`か`complete`になったら、DTLSの接続へ
    • `WebRtcTransport::MayRunDtlsTransport()`
    • あとで`connect()`された時に呼ばれるはず
  • ICEの経路が決まると、`WebRtcTransport::OnIceSelectedTuple()`を呼ぶ
    • このIP/ポート上で、今後一切のパケットが流れる
RTC::DtlsTransport

こちらも圧巻の1200行超え。

コンストラクタはこれくらい。
このクラスもあとで`connect()`されてからいろいろ呼ばれるはず。

ともあれ、`WebRtcTransport`というクラスが、`iceSelectedTuple`上で、クライアントとのパケットのやり取りを一手に引き受けることになる。
なにかの処理を追うときは、まずこのクラスからでよさそう。

前編のまとめ

Nodeはユーザーランドのコードから呼ばれるただのI/Oであって、処理の実態はほとんどC++にある。
ただNode側とC++側でも概念は一致していて、透過的にインスタンスを作れると思ってよい。

  • Roomごとに`Worker`ができる
    • `Worker`は実行されるプロセスであって、メディア処理の実態ではない
    • `Worker`は`Channel`を保持していて、NodeとC++をつないでいる
  • `Worker`は`Router`を作ってRoomに割り当てる
    • 以降の処理は`Router`が起点になる
    • もちろん`Channel`を保持している
  • `Router`は`Transport`をクライアントとの間に作る
    • `Transport`には種類があって、今回は`WebRtcTransport`を使ってる
    • その上でクライアントとパケットを送り合う
    • = その上でメディアをやり取りする
    • そこで出てくる概念が`Producer`/`Consumer`

という構造になっていて、この記事ではメディアのやり取りをするために準備を整えるところまで書きました。

この時点ではWebRTC的な通信は開始されてないけど、いつでもいいのでメディアを送受信するAPIを叩けば、通信が開始できるという段階。
後編では、実際にメディアを送受信する時に呼ばれる処理の流れを見ていきます。

引き続き後編へ。