🧊

WebRTCやるのに最低限必要なJavaScriptのAPIについて

JavaScriptでWebRTCやるための基礎知識 - console.lealog();

春なので書きました。
言うなれば、これの2019年度版です。

はじめに

最低限のJavaScriptでWebRTCを扱うにあたり、どういうクラスがあって、どういうAPIを、どう使うのかについての記事です。

いわゆるフロントエンドのエンジニアがWebRTCを使ったサービスを作るなどの場合は、だいたい何かしらのSDKを使うと思います。
その場合はそのSDKのDocsを読めばそれで十分で、その先の仕組みを知る必要はないかなーと思います。

ただし、

  • SDKの中で何が行われてるか知っておきたい
  • WebRTCのSDKを作る側である

みたいな場合は、一読の価値ありかもです。

ようするに、弊社の新入社員のような人材のための記事です!

基本的なWebRTCの仕組みみたいなパートはざっくり軽めにして、JavaScriptAPIについて濃い目に書いていこうと思ってます。

WebRTCとは

まずは概要から。

  • Web(ブラウザ)でP2P通信ができる仕様です
  • SkypeやLINEみたいなビデオチャットが、なんとブラウザのAPIだけで作れます
    • カメラとマイクがついた端末で、それ用のAPIを使ってビデオのストリームを取得し
    • そのストリームを、それ用のAPIで別の端末と送受信できます
  • メディア以外にバイナリデータなどを送ることもできます
  • P2Pを確立するためには、お互いの通信要件をネゴシエーションする必要があります
    • これをシグナリングと言います
    • 第3者としてのサーバーを立ててやり取りするのが一般的です
    • やり方に規定はなく、WebSocketでもいいし、伝書鳩でもいいです
  • P2Pの相手はブラウザでもいいし、実装があればサーバーでもいいです
    • サーバーにやらせて端末の配信負荷を軽減する手法もあったりします

事前知識をもう少し。

シグナリング

P2P通信を確立するために必要なステップです。

前述の通りどんな方法でやり取りしてもいいのでそこはさておき、「どんな情報をやり取りするか」についてです。

基本的には`SDP`というフォーマットをやり取りすることになります。

具体的にはこんな資料があります。(日付は古いけど内容的には問題なしです)

SDP for WebRTC

ざっくり含まれる情報を分類すると、

  • 何を送受信したいのか
    • 動画なのか音声なのかデータなのか
    • 1つなのか2つなのかNつなのか
    • 送受信したいのか、送信・受信専用でいいのか
    • どんなコーデックでどんな拡張を使うのか
  • P2P通信を実行するトランスポートの候補
    • いわゆる通信の経路
    • どのIPのどのポートでどのプロトコル
    • 選択肢が多いほうがつながる確率が高いので複数の候補を
  • その候補から経路を確定するために必要なパラメータ
    • 具体的にはICEというプロトコルを使って経路を決める
    • その過程で必要なユーザー名とかパスワード
    • あとは経路が決まったあとにデータを暗号化するためのパラメータなど
  • その他
    • 帯域を指定したり
    • SDPのフォーマットとして必要なだけで、WebRTCには関係ないテンプレとか

とりあえず抑えておきたいこととしては、この「SDPに含まれている情報が全て」であり、基本的にはSDPを見れば、どういう通信をしているかがわかることになってます。

なのでJavaScriptAPIも、「どのAPIをどう使えばSDPに反映されるのか」を気にして見ていくことになります。

ちなみにSDPですが、ただのテキストファイルのくせに情報量が多くつらつらと長ったらしくパースも大変なくせにその上フォーマットも紆余曲折あったりと、WebRTC界隈でのdisられ筆頭です。

WebRTCがつながるまで

さて本題に入るため、参考にするシナリオを考えます。
なんだか本題に入るまでが長くなってますが、そんな技術と歴史の積み重ねで成り立ってるのがWebRTC・・・!

この記事では、「AさんとBくんがお互いに動画と音声を送受信したい場合」を考えていきます。

  • Aさんの準備
    • カメラととマイクから動画と音声を取得
    • 使えるコーデックやらを整理(ブラウザがよしなにまとめてくれる)
    • 通信経路の候補も集める
  • Bくんも準備
    • Aさんと同じことをする
  • Aさんからオファーを作って送る
    • 自身の情報をさっきのSDPにまとめあげて、オファーとしてBくんに渡す
  • Bくんがオファーを受け取る
    • そのオファーに対して、了承するなり拒否するなり、アンサーを作る
  • Bくんもアンサーを作って送る
    • 同じくSDPにまとめあげて、自身の情報 + オファーに対する解としてAさんに渡す
  • Aさんがアンサーを受け取る
    • それを受け取ったら、いざP2Pの開始

WebRTCの流れで「オファー・アンサー」というキーワードが出てくるのはこういう流れからです。
もちろん細かいところは端折ってますが、概ねこんな感じで、必要あれば後からまた補足します。

さてここからがやっと本題です。
それぞれのステップを実現するためにどういうクラスとAPIを使うのかを見ていきます!

ブラウザの実装について

と言いたいところですが先にコレも・・。

WebRTCのAPI群は、JavaScript的にもまだ完全にFixした段階ではないです。
ここ数年でだいぶマシになってほぼほぼ固まってきた感じはあるけど。

なので、

  • W3Cの仕様にはあるけど実装がない
  • W3Cの仕様にはないけど実装されてる
  • ブラウザAにはあるけどブラウザBにはない
  • ブラウザAでもバージョンによって違う
    • 動いたり動かなかったり
    • 挙動が違ったり

的なことが往々にしてあります。
まぁJavaScript界隈的には普通のことですね・・。

この記事の内容も、現時点ではこうですが、将来的には変わるかもしれません。
インターネットに転がってる記事も、当時のAPIを使ってる今となっては古いやつとかあったりするので、逐次ちゃんと実際のブラウザで検証するのがいいです。

では今度こそ本題へ。

RTCPeerConnection

その名の通り、WebRTCするなら必須のクラスです。
これに生えてるAPIを使って処理を進めていきます。

const pc = new RTCPeerConnection(options);

`options`にはオブジェクトを指定でき、そのキーにできるものはいくつかありますが、よく使われるものとしては、

  • `iceServers`
  • `iceTransportPolicy`

があります。

で、これが何なのかを説明するためにはまた事前知識が必要になってきます。

ICEとは

さっきから何度も登場してるこの単語についてです。

  • ICEは、WebRTCの通信を実行する経路をいい感じに見つけ出す仕組みです
    • ブラウザがその実装を持っていて、内部的に使われてます
    • JavaScriptAPIで直接触ることはないです(いちおう)
  • これも内部的にですが、SDPに載せる通信経路の候補を集めるフェーズがあります
  • 集められる経路候補にはタイプがあります
    • ローカルホストとか、外部のネットワーク(インターネットから見た)とか
  • 集めたいタイプはある程度`RTCPeerConnection`のオプションで制限できます
    • デフォルトだとローカル限定で経路を集めます
    • ただローカルNWのIPなんて教えたところで、インターネット越しでは通信できません
    • プライベートなNW内での通信ならできるけど
  • なので、基本的にはインターネットから見た経路を伝えたいはず
  • そのために自分がインターネットからどう見えているのか、インターネットにいるサーバーに問い合わせます
    • これをSTUNという仕組みでやってます
  • 同様に、企業NWのように制約が多い場合は自由に経路を選べません
    • なので、通信の経路を純粋なP2Pではなく、中継サーバー経由にすることもできます
    • これをTURNという仕組みでやってます

で、このSTUNとかTURNを使うためのオプションが、さっきのやつです。

// STUNを使う
const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:1.2.3.4" },
  ],
});

// TURNを強制
const pc = new RTCPeerConnection({
  iceServers: [
    {
      urls: "turn:1.2.3.4",
      credential: "cred",
      username: "user",
    },
  ],
  iceTransportPolicy: "relay",
});

もちろん実際にはSTUN/TURNサーバーのアドレスに適当なものを指定して使います。

RTCPeerConnection#createOffer()

さて、とにもかくにもSDPを用意します。

「WebRTCがつながるまで」の章で書いたように、まずはオファー用のSDPを作って、それをシグナリングします。

オファー用のSDPを作るためのAPIが`createOffer()`です。

const pc = new RTCPeerConnection();
const offer = await pc.createOffer();

offer.type; // "offer"
offer.sdp; // SDP本体

値は`Promise`で返るのでよしなに。

ちなみにChromeのM73でこれだけ実行すると、次のSDPが出力されます。

v=0
o=- 4601616591182606030 3 IN IP4 127.0.0.1
s=-
t=0 0
a=msid-semantic: WMS

今の時点ではなんの情報もないです。
どんなメディアを送りたいかとか、そういうAPIを一切使ってないからです。

このSDPを育てていくのがここからの話。

MediaStream / MediaStreamTrack

今回は動画と音声を送受信したいので、やることとしては、

  • 動画と音声のストリームを取得
  • それをSDPに反映

まずはストリームの取得なのですが、`RTCPeerConnection`にそれ用のAPIはありません。
が、安心してください!別のAPIがちゃんとありますよ!というわけで。

JavaScript的には、`MediaStream`というクラスと、それを構成する`MediaStreamTrack`というクラスがそれです。

取得する方法はいくつかあるのですが、代表的なものは以下のコードの通り。

// video + audio
const videoAndAudioStream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true,
});

// audioのみ
const audioStream = await navigator.mediaDevices.getUserMedia({
  audio: true,
});

// 画面共有
const displayStream = await navigator.mediaDevices.getDisplayMedia({ video: true });

`getUserMedia()`の引数オブジェクトはもっと細かく指定できるのですが、今回は割愛します。

取得した`MediaStream`には、N個の`MediaStreamTrack`が内包されてます。
動画と音声なら、動画の`video`と音声の`audio`の2つ、画面共有なら画面の動画の`video`の1つだけ。

それぞれ取り出すこともできます。

const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true,
});

stream.getTracks(); // MSTぜんぶ
stream.getAudioTracks(); // audioのMSTだけ
stream.getVideoTracks(); // videoのMSTだけ

なぜ`MediaStreamTrack`単位で考えるかというと、それがWebRTCにおいてよくあるユースケースだからです。

動画だけ送りたい、音声だけ受け取りたいみたいなケースのために、そもそも`MediaStream`ではなく、`MediaStreamTrack`単位でいろいろ処理してこ!というのが最近のトレンドなわけです。

MediaStream(Track)をDOMに表示する

const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true,
});

const $video = document.querySelector('video');
// もしくは
const $video = document.createElement('video');

$video.srcObject = stream;
await $video.play();

このように、`HTMLVideoElement`の`srcObject`に渡すことで再生できます。

RTCPeerConnection#addTrack()

さて、`MediaStreamTrack`を手に入れたので、それを`RTCPeerConnection`に渡します。

`addTrack()`というAPIに、`MediaStreamTrack`と`MediaStream`を渡します。

const pc = new RTCPeerConnection();

const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true,
});

for (const track of stream.getTracks()) {
  pc.addTrack(track, stream);
}

これでこのメディアを送信したいという意図を伝えることができました。
この状態で再び`createOffer()`を実行してSDPを生成すると、さっきと大きく結果が変わります。

実際にやってみるとわかりますが、めちゃくちゃ長いテキストになります。
まじで長いので割愛しますが、「シグナリング」の章で書いたSDPに含まれるべき情報のほとんどが出力されます。

ほとんど、と言ったのは全てではないからで、この時点では「P2P通信を実行するトランスポートの候補」の情報は載ってません。

この通信経路の候補を集める処理は、これでいくぞ!オファー出すぞ!という意思が固まったタイミングで行われます。
なので逆にいうとそれまでは、

  • `addTrack()`したものを取り消すこともできる
    • 今回の記事では紹介してないAPIを使えば
  • 追加で`addTrack()`してもいい

というわけです。

では次に、オファー出すぞ!これで確定するぞ!のAPIです。

RTCPeerConnection#setLocalDescription()

作成したオファーSDPは、相手に送り届けると同時に、自身の設定として`RTCPeerConnection`に反映します。
そのためのAPIが、`setLocalDescription()`です。

オファー側のコードの流れをおさらいするとこのように。

const pc = new RTCPeerConnection();

const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true,
});

for (const track of stream.getTracks()) {
  pc.addTrack(track, stream);
}

const offer = await pc.createOffer();

await pc.setLocalDescription(offer);

pc.localDescription; // ココで確認できる

WebRTCに関するJavaScriptAPIの仕様的には、「`createOffer()`してから`setLocalDescription()`するまでの間に、オファーのSDPを書き換えてはいけない」です。

ただ実際にはSDPに変更を加えたいけど、それ用のAPIが実装されてないなどの理由で、SDPを自分たちで修正することがあったりもします。

何はともあれ、`setLocalDescription()`することで処理はまた一つ次へ進みます。

RTCPeerConnection#onicecandidate

今の状態のSDPを送っても、「P2P通信を実行するトランスポートの候補」が含まれていないため実際に通信はできません。

相手のオファーを受け取って、「メディアの送受信がしたい旨はわかったけど、どこのIPのどのポートと送受信すればいいの?」ってなるからです。

その「P2P通信を実行するトランスポートの候補」は、`RTCIceCandidate`というクラスで表され、`RTCPeerConnection`の`icecandidate`イベントを監視することで入手できます。

`setLocalDescription()`するとこの候補たちが収拾されて、見つかり次第イベントが発火します。

const pc = new RTCPeerConnection();

pc.addEventListener('icecandidate', ev => {
  if (ev.candidate !== null) {
    // 経路の候補が見つかったとき
  }
  // 全ての経路を見つけ尽くしたとき
});

先述の通りローカルNWであるホストのIPとポートだけなら、自分のことなので一瞬でわかります。
ただしインターネット側から見た場合の、STUNやTURNを利用する場合のIPとポートは、すぐにはわかりません。

なので、非同期にこのイベントを監視して、

  • 全ての経路を見つけ尽くしてから、まとめて送る
  • 経路が見つかり次第、送ってあげる

この2パターンが考えられるわけで、前者を「Vanilla ICE」、後者を「Trickle ICE」と呼んだりします。

つまりシグナリングというのは、ICEの経路候補 + それ以外の情報を、なんとかして通信相手に伝えることを指します。

前者の場合は、`ev.candidate === null`になってから、`pc.localDescription`を確認することで、ICEの経路候補がSDPに含まれていることを確認できます。
`a=candidate`からはじまる行があればそれです。

こうして出来上がったSDPの完全体を、何らかの方法で通信相手に送ります。

ここまででオファーを出す側は一段落で、次はオファーを受け取ってアンサーを返す側へスイッチ。

このイベントで受け取った`RTCIceCandidate`と「Trickle ICE」については、またあとで紹介します。

RTCPeerConnection#setRemoteDescription()

`setLocalDescription()`と対になるAPIです。

自身が生成したSDPを反映するのが`setLocalDescription()`で、通信相手から受け取ったSDPを反映するのが`setRemoteDescription()`です。

const pc = new RTCPeerConnection();

// なんらかの手段で offer を受け取ったものとして

await pc.setRemoteDescription(offer);

pc.remoteDescription; // ココで確認できる

今回はどちらも動画と音声を送受信するので、`addTrack()`をよしななタイミングでやっておくのも忘れずに。

RTCPeerConnection#createAnswer()

ここまでで紹介してない最後の1ピースが`createAnswer()`で、`createOffer()`と対になるAPIです。

const pc = new RTCPeerConnection();

// なんらかの手段で offer を受け取ったものとして

await pc.setRemoteDescription(offer);

const answer = await pc.createAnswer();

answer.type; // "answer"
answer.sdp; // SDP本体

await pc.setLocalDescription(answer);

受け取ったオファーを解釈して、そのオファーを快諾するのか、一部は拒否するのかなどオペレーションを経た後に、アンサーSDPを生成します。

ここでいうオペレーションとは、オファーと同じく動画と音声を送受信したいので適切に`addTrack()`するとか、やっぱり受信専用がいいので`addTrack()`しないとかのことです。
その判断をした後に、`createAnswer()`を実行します。

SDPを生成したら、オファー側と同じように`setLocalDescription()`するまでがセットです。

あとはそのアンサーを、オファー側になんらかの手段で送り返すだけ。

RTCPeerConnectionおさらい

流れのおさらいです。

オファー側から、

  • `createOffer()`
  • `setLocalDescription()`
  • 通信経路の候補が出揃うのを待つ
  • オファー送信

そしてアンサー側で、

  • オファー受取
  • `setRemoteDescription()`
  • `createAnswer()`
  • `setLocalDescription()`
  • 通信経路の候補が出揃うのを待つ
  • アンサー送信

またオファー側に戻って、

  • アンサー受取
  • `setRemoteDescription()`

という流れで処理を行います。

オファー側は`createOffer()`で、アンサー側は`createAnswer()`で適切なSDPを生成し、両側で`setLocalDescription()`と`setRemoteDescription()`を呼び終えたら、P2P通信が開始されるはずです。

これだけ見ると割とわかりやすいフローですね・・?

RTCPeerConnection#addIceCandidate()

ここまでの説明は、ICEの通信経路の候補が見つけ尽くしてから、シグナリングする前提で書いてました。

const pc = new RTCPeerConnection();

pc.addEventListener('icecandidate', ev => {
  if (ev.candidate !== null) {
    // 経路の候補が見つかった
  }
  // 全ての経路を見つけ尽くした
});

この分岐の下のパターンを"待ってから"、処理していたという意味です。

これは実装としても仕様としても問題なく動くのですが、昨今のトレンドとしては「一秒でも早く通信を開始したい」というものがあります。
あとは環境によって(UDPが使えなかったりとかなにかと事情があるはず)、すべての経路を見つけ尽くすまで(=`ev.candidate === null`になるまで)、思ったより時間がかかる場合があります。

それをただ待ち続けるのもイケてないので、先述の「Trickle ICE」をやろう!となるわけです。

「Trickle ICE」では、見つかった候補をすぐにシグナリングします。
そしてその候補を受け取って取り込むためのAPIが、この`addIceCandidate()`です。

const pc = new RTCPeerConnection();

// なんらかの手段で candidate を受け取ったものとして

await pc.addIceCandidate(candidate);

ただしこのメソッドを呼んでいいのは、`setRemoteDescription()`した後だけです。
`RTCIceCandidate`には、「この経路候補は、SDPの中に記述されてるこの動画を送るためのもの」みたいなヒモ付があって、その情報がないと対応に困るからです。

適切なタイミングでこのメソッドを実行すると、受け取った経路候補を自身の候補と最速で照らし合わせて、使える経路が見つかり次第即、P2P通信をはじめます。

RTCPeerConnection#ontrack

オファー・アンサーで無事にSDPを交換して、ICEの経路もいい感じにやり取りできたとして、メディアが送受信されることをどう補足するのか?です。

ちゃんとそれ用のイベントがあります!

const pc = new RTCPeerConnection();

pc.addEventListener('track', ev => {
  ev.track; // MediaStreamTrack
  ev.streams; // [MediaStream]
});

このように、通信相手が何かメディアを送っていた場合、その`MediaStreamTrack`(と、`MediaStream`)がイベントで拾えます。
なのでこれを使って相手のストリームをDOMに表示すれば、晴れてブラウザでビデオチャットが実装できるというわけです。

今回は割愛しますが、他にも`RTCPeerConnection`から`MediaStreamTrack`を拾う方法はあります。

RTCPeerConnection#on...

なんらかの問題があって、シグナリングやメディアの送受信に失敗することもあります。
一度メディアが流れてP2Pしていても、回線が不安定になったりで切断されてしまうこともあります。

そういうあれこれをどう捕捉するか?ですが、もちろんあれこれイベントが用意されてるのでそれを監視することになります。
イベントはいろいろあるのですが、選りすぐって紹介します。

signalingstatechange

const pc = new RTCPeerConnection();

pc.addEventListener('signalingstatechange', () => {
  pc.signalingState; // ココで確認できる
});

シグナリングの状況です。

つまり、`setLocalDescription()`したかどうか、`setRemoteDescription()`したかどうかなどの状態が取れます。

この値が`have-remote-offer`じゃない場合は、`addIceCandidate()`するのやめてキューに貯めて待つ・・みたいな実装ができます。

iceconnectionstatechange

const pc = new RTCPeerConnection();

pc.addEventListener('iceconnectionstatechange', () => {
  pc.iceConnectionState; // ココで確認できる
});

ICEの接続状態、aka、WebRTCの通信の接続状態です。
JavaScriptAPIからはさわれないネットワークのレイヤーの状況を知るための唯一の手段です。

これが`failed`とか`disconnected`になると、P2Pの通信も途絶えていることになります。
受信している動画はバッファを再生しきったら止まるし、こちらからも送信できなくなります。

なのでこのイベントは常に監視して、エラー表示するとか、再接続を試みるとか、なんらかのアクションを取ることになります。

これも今回は割愛しますが、ICEの接続をやり直すことを、「ICEリスタート」と呼んだりもします。

RTCPeerConnection#close()

その名の通り、P2P接続の終了を意味する`close()`です。

一度`close()`した`RTCPeerConnection`を復活させることはできないので、そこだけ注意が必要です。

おわりに

最もありがちなユースケースを最もシンプルに実現するために必要なAPIだけを紹介してきましたが、それでも結構な量になりましたね・・。

おさらいすると、JavaScriptのクラスとして最低限知っておくべきは3つ。

あとは主に`RTCPeerConnection`のAPIを使ってSDPを生成して、それをなんらかの方法でシグナリングするだけです。

単純なP2Pをやるだけならこの記事で紹介したAPIでいいですが、ここから一歩でも踏み込もうとすると、また知っておくべきAPIがいろいろでてきます・・!

まぁそれはまた機会があればいずれ・・ということで、その時のための章立てだけ書き残しておきます。

* シグナリングサーバーの実装Tips
- グレアをいかにして解消するか
- `negotiationneeded`イベント

* 同じ挙動を実装する方法は何パターンかある話
- `RTCPeerConnection`を複数つくるとか
- SDPを使わないでシグナリングするとか

* `addTransceiver()`および`RTCRtpTransceiver`
- 送信のみ・受信のみ
- SDPとの関係

* `RTCRtpSender`よもやま
- `RTPSender`と`RTPReceiver`
- 各種RTPパラメータたち

* `RTCDataChannel`
- 仕組み
- WebSocketとの差異

* `MediaStream`まわり
- `MediaStreamTrack`の合成
- ミュート処理
- `RTCRtpSender#replaceTrack()`
- メディアデバイスの指定
- メディアに関する制約(モバイル含め)
- `ImageCapture`や`MediaRecorder`など周辺API

* その他のクラスとAPI
- `RTCStatsReport`
-- WebRTCで統計データを扱う
- `RTCCertificate`
-- `generateCertificate()`

* 多人数での通信について
- SFUの存在意義とか
- Simulcast/SVCの現状

* 未来のはなし
- `RTCIceTransport`
- `RTCQuicTransport`

無限に記事が書けますね・・w

ではでは、なにかあればTwitterなどでお気軽にお声がけください!