All Articles

WebRTCを仕組みから実装までやってみる

このエントリはFrontrend Advent Calendar 2013 23日目の記事です。

2014/03/16追記 WebRTC-DataChannelについてもエントリ書きました。↓からどうぞ。 WebRTC-DataChannel使ってみたよ

WebRTCを仕組みの理解から実装まで

Advent Calendarを書くということでなんか新しいことやったほうがいいかなーって思ってたので、今回はWebRTCを調べてみました。 調べながらだったので間違っている箇所もあるかもですが、専門家の方のツッコミあれば歓迎です。

先に作ったサンプルデモを触りたい方は以下のアドレスからどうぞ。

WebRTC Video Chat Sample

※接続名は同時にアクセスしている方全員から見えますのでご注意ください!接続依頼が来た際にはダイアログが出るようにしてますが、安易に応答すると知らない人とつながっちゃうので、知人同士など見知った方同士でやることを強くオススメします。

ソースコードはいつもどおりGithubにあげてます。

ysugimoto / RTCPeerConnectionSample

概論

そもそもWebRTCって何?って話ですが、

WebRTC (Web Real-Time Communication)とはWorld Wide Web Consortium (W3C)が提唱するリアルタイムコミュニケーション用のAPIの定義で、プラグイン無しでウェブブラウザ間のボイスチャット、ビデオチャット、ファイル共有ができる。

WebRTC: Wikipediaより引用

W3C: WebRTC 1.0: Real-time Communication Between Browsers

ということです。もう少し突っ込むと、ブラウザ間でそれぞれ「peer(ピア)」と呼ばれるオブジェクトを保持し、その異なるピア間でデータ通信(ストリームを含む)を行う、という感じですかね。 重要なのは、そのピア同士の通信はサーバを介さない、ということでしょうか。それぞれのクライアントが直接通信を行うので、サーバーをTCPなどで介してやりとりするよりも手軽でコストも低い感じですね(実装は手軽じゃありませんが)。

最近では「SkyWay」というサービスもリリースされましたね。こちらはpeer.jsというPeer接続をうまくラップしているライブラリを使って実現しているようです。

ちなみに本エントリではWebRTC仕組みの理解とChromeに実装されている素のAPIを介して通信をするのが目的なのでライブラリは使いません。SkyWayでうまく通信できなかったのが悔しかったんじゃないんだからね 普段ライブラリ使ってる方も根本の仕組みの確認を含めて参考にしていただければ幸いです。

なお、Chromeで動かす場合はchrome://flagsで設定を一部有効化しておく必要があります。webRTC周りとgetUserMedia周りの設定を有効にした状態で望みましょう。

WebRTCを実現する上で必要なステップはおおまかに以下です。

  • Peerの生成
  • メディア接続
  • Call対象の特定
  • Sessionの共有
  • メディアストリームの共有

あと、知っておくべきキーワードも上げておきます。知らないとダメってわけでは無いですが、本格的なものを作るとなると必須かと。

  • Candidate
  • (Remote/Local)Description
  • ICE
  • NAT Traversal

なお、このあたりはWebRTCに使われるP2Pの技術というエントリですでにわかりやすく紹介されているので、私は主に実装よりの話にしようかと思います… きっと一部の方に需要があると信じています。

Peerの生成

WebRTCのAPIは現在はChromeとFirefoxで実装されています。どちらもwebkitRTCPeerConnection/mozRTCPeerConnectionのベンダープレフィックス付き実装です。 後述するRTCIceCandidateとRTCSessionDescriptionはベンダープレフィックスがついていないので注意が必要です。ついてない理由はおそらく単純に文字列をラップした型付きオブジェクトであればいいからで、 ロジカルなメソッドは実装される必要はないからではないかと思われます。でもややこしい。

var peer = new webkitPeerConnection({
    "iceServers": [{"url": "stun:stun.l.google.com:19302"}]
});

引数にはiceServerと呼ばれる情報を持ったオブジェクトを渡します。ここは正しい引数形式で渡さないと例外となるので注意。 iceServersというキーに対してurlという文字列データを持ったオブジェクトの配列をセットしますが、面倒なので上のコードのようなフォーマットで渡せばOKです。 "stun:stun.l.google.com:19302"というのはGoogleが提供しているSTUNサーバを使わせてもらう指定です。STUNサーバについては後述します。

これでブラウザ上にpeerオブジェクトが生成されます。

メディア接続

HTML上に配置したvideo要素にカメラをattachします。おなじみnavigator.getUserMedia()ですね。こちらもwebkitGetUserMedia/mozGetUserMediaとベンダープレフィックス付きです。

navigator.webkitGetuserMedia(
    { audio: true, video: true },
    successCallback,
    errorCallback
);

第一引数にはメディアソースに関するオブジェクトを渡します。audio/videoともにtrueなので、カメラとマイク接続を指定する指定です。なお、接続には許可を求めるダイアログバーが出るので、そこから許可を選択します。 第二引数には成功時、第三引数には失敗時のコールバック関数を登録します。例えば、成功時にvideoに接続するには以下のように。

navigator.webkitGetUserMedia(
    { audio: true, video: true },
    function(stream) {
        // video要素取得
        var video = document.getElementById('video');

        // srcにBlob URLを指定するとカメラの画像がストリームで流れる
        video.src = window.webkitURL.createObjectURL(stream);

        // 自分のpeerにカメラストリームを接続させる
        peer.addStream(stream)
    },
    function(err) {
        console.log(err.name + ': ' + err.message);
    }
);

peer.addStream(stream)とすると、RTC接続確立後に相手のストリームに渡されるようになります。マイク入力はvideo要素から流れます。

Call対象の特定

W3Cには「SignalingChannel」というオブジェクトを使っているようですが、HTML5 Rocksによれば、この部分はWebRTCとは別みたいなことが書かれていて、WebSocketやSIPとか使えるよって書いてあります。 一番簡単そうなのでWebSocketでやるのがいいですね。SIPだったらAsteriskとか使うといいのかも。で、特定にはUUIDなどをつけて管理することで特定の相手に向けてコールしたりできるようにしています。 後述しますが、RTCIceCandidateやRTCSessionDescriptionのデータ自体はJSONシリアライズ可能なデータなので、WebSocketでも十分に転送可能です。

Sessionの共有

コール対象が特定できるようになったところで、実際にコールしてPeer接続を行うには、RTCSessionDescriptionと呼ばれるセッションの共有が必要です。ここがややこしいところでした…。 大事なのは、「localとremoteそれぞれのDescriptionをクロス接続させる」ということだと理解しています。わかりにくいのでAliceとBobの接続フローで説明してみます。 なお、RTCでは接続依頼を「Offer」、返答を「Answer」というみたいです。

  1. Alice: BobにOfferを送る
  2. Peer: AliceのOfferリクエストからDescriptionを生成してAliceに渡す
  3. Alice: 生成されたDescriptionをLocalDescriptionにセット
  4. Peer: DescriptionをBobに転送
  5. Bob: 受け取ったDescriptionをremoteDescriptionにセットする
  6. Bob: AliceにAnswerを送る
  7. Peer: AnswerリクエストからDescriptionを生成してBobに渡す
  8. Alice: 生成されたDescriptionをLocalDescriptionにセット
  9. Bob: 受け取ったDescriptionをremoteDescriptionにセットする
  10. Peer: DescriptionをAliceに転送
  11. Alice: 受け取ったDescriptionをremoteDescriptionにセットする
  12. 接続確立(∩´∀`)∩

うまく言葉で説明できない…ので、接続確立後の構成はこんなイメージになります。それぞれのlocal/remoteのDescriptionが相手のremote/localのDescriptionになっている感じです。

なお、SessionDescriptionのデータ本体も文字列データなので、WebSocketなどで転送可能です。ちなみに下記のようなデータです:

v=0
o=- 3883943731 1 IN IP4 127.0.0.1
s=
t=0 0
a=group:BUNDLE audio video
m=audio 1 RTP/SAVPF 103 104 0 8 106 105 13 126

// ...

a=ssrc:2223794119 label:H4fjnMzxy3dPIgQ7HxuCTLb4wLLLeRHnFxh810

詳しくはSession Description ProtocolのWikipediaを参照してください。

Offserの送信

まずOfferを送ります。このタイミングで自分用(local)のSDPができますので、コールバック内でセットし、そのSDPを相手に送ります。

2013/12/23追記: SDPインスタンスのtypeプロパティからOffer/Answerの判定ができる、との指摘を頂きました。 それに基づいてwebsocketのメッセージハンドラ内の処理と送信パラメータを変更しました。ありがとうございます。

// Offer送信
peer.createOffer(function(sdp) {
    // 引数のSDPは自分用
    peer.setLocalDescription(sdp, function() {
        // セット完了したら、相手に自分のSDPを送る
        websokcet.send(JSON.stringify({
            "sdp": sdp,     // これ、自分の。
            "to" : to_uuid  // 君に届け
        }));
    });
});

// websocketのメッセージイベントで受け取る
websocket.onmessage = function(evt) {
    var message = JSON.parse(evt.data),
        sdp;

    if ( message.sdp ) {
        sdp = new RTCSessionDescription(message.sdp);
        // 相手用(remote)にセット
        peer.setRemoteDescription(sdp, function() {
            // 自分へのOffer-SDPだったらAnswerを返す
            if ( sdp.type === "offer" ) {
                // ファイナルアンサー
            }
        });
    }
};

ここでも自分用なのか相手用なのかをしっかり把握しておかないとハマります…。 特に同じソースで共有するので、切り分けをちゃんとする必要がありますね。現時点での状態は下のような感じです。

現在片想い中ですね。ちなみにここで認証作業などをすれば応答制御が可能だと思います。

Answerの送信

Offerを受け取ってremoteDescriptionにセットしたら、今度は送り元に対してAnswerを送ります。やることはOfferと同じですが、typeがAnswerになります。

``` // Answer送信 peer.createAnswer(function(sdp) { // この引数のSDPは自分用! peer.setLocalDescription(sdp, function() { // セット完了したら、相手に自分のAnswerSDPを送る websokcet.send(JSON.stringify({ "sdp": sdp, // これ、自分の。 "to" : from_uuid // 君に届け })); }); }); ```

Answerを受け取ったら、同じくremoteDescriptionにセットします。これで晴れてクロスな接続が完了しました!

これでSessionの確立ができたので、ストリームの共有ができるようになるのですが、Offer/Answerの間に、数回ストリームの共有に関するCandidateと呼ばれるデータの受け渡しが発生します。 これは非同期なので、peer.onicecandidateというイベントハンドラ内で監視します。こちらも自分と相手双方で共有する必要があります。

peer.onicecandidate = function(evt) {
    var candidate;

    // evt.candidateプロパティにデータが入っているので、WebSocketでデータを共有するため送信
    if ( evt.candiate ) {
        websocket.send(JSON.stringify({"candidate": evt.candidate}));
    }
};

// websokcetのメッセージハンドラ内で送信されてきたデータを復元してセットする
websocket.onmessage = function(evt) {
    var message = JSON.parse(evt.data),
        candidate;

    // evt.candidateがあればCandidateの共有
    if ( evt.candidate ) {
        // candidateってなんか甘そうだよね
        candidate = new RTCIceCandidate(evt.candidate);
        peer.addIceCandidate(candidate);
    }
    };

なお、このcandidateデータも下のようなフォーマットで文字列シリアライズ可能なオブジェクトなので、WebSocketで送信できます。

{
  "sdpMLineIndex":0,
  "sdpMid":"audio",
  "candidate":"a=candidate:3802297132 1 udp 2113937151 192.168.0.3 54130 typ host generation 0\r\n"
}

この共有が終わると、今度はメディアストリームの共有に入ります。ストリーム共有の際もやはり非同期なので、peer.onaddstreamというイベントハンドラで監視します。

peer.onaddstream = function(stream) {
    // 自分のリモートにセット
    var video = document.getElementById('remoteVideo');

    video.src = window.webkitURL.createObjectURL(stream);
};

これでお互いのpeer間でビデオの共有が開始されます。手間がかかります。

ここで注意点は、RTCPeerConnectionオブジェクトには、peer.onaddstreamに対してpeer.addStream、peer.onicecandidateに対してpeer.addIcceCandidateというメソッドがありますが、これらは相互に作用するものではないという点に注意が必要です。 つまり、peer.addStream()をしたらpeer.onaddstreamが呼ばれそうなものですが、実際はそうではないんですねこれが。これに気づくのに1時間くらい無駄にしました。なので、

addStream()/addIceCandidate()メソッドは自分(local)用、 onaddstream/onicecandidateイベントハンドラは相手(remote)からの接続リクエストに対して、

というように分けて考えるとわかりやすいです。先に書いた接続のフローをもう一度まとめると、

  1. Alice: BobにOfferを送る
  2. Peer: AliceのOfferリクエストからDescriptionを生成してAliceに渡す
  3. Alice: 生成されたDescriptionをLocalDescriptionにセット
  4. Peer: DescriptionをBobに転送
  5. Bob: 受け取ったDescriptionをremoteDescriptionにセットする
  6. Bob: AliceにAnswerを送る
  7. Peer: AnswerリクエストからDescriptionを生成してBobに渡す
  8. Alice: 生成されたDescriptionをLocalDescriptionにセット
  9. Bob: 受け取ったDescriptionをremoteDescriptionにセットする
  10. Peer: DescriptionをAliceに転送
  11. Peer: candidateデータを双方に送信
  12. Alice/Bob: peer.onicecandidateイベント発火、candidateデータを相手に送信
  13. Alice/Bob: 受け取ったcandidateデータをpeer.addIceCandidate()メソッドでセットする
  14. Peer: streamデータを双方に送信
  15. Alice/Bob: peer.onaddstreamイベント発火、リモートのvideoにセットする
  16. 接続確立(∩´∀`)∩

11〜15の処理は非同期なので正確にどのフローで起きるかまではトレースできませんでしたが、だいたいこんな感じで接続が確立されると思います。

上記のフローを素のAPIで実装してみているので、興味があればgithubを見てみてくださいね。おまけでWebSocketのチャット機能もついてます。

まとめ

めちゃめちゃ面倒くさいけど、今の時点でも十分に簡単になっている感触はある。それでもpeer.jsとか使ったほうが楽でいいと思います。 どこで見たのか忘れてしまったけど、SkypeはVoIPなので、これとは違う技術でやってるらしいですね。

それから、RTCPeerConnectionオブジェクトには、他にもデータ送信に関するcreateDataChannel()メソッド、ダイアルトーンに関するcreateDTMFSender()メソッドがあります。 createDataChannelはChrome26から実装されている、と書かれていますがCanaryでないと例外を吐いて動きませんでしたので、また今度試す。createDTMFSenderは…時間があれば。

その他、再度参考サイトの紹介で終わりにしたいと思います。

WebRTC 1.0: Real-time Communication Between Browsers

WebRTC in the real world: STUN, TURN and signaling

WebRTC for Beginners

WebRTCに使われるP2Pの技術

現場からは以上…ですが、せっかくなのでNAT Traversalについても少し書いておきます。 あんまり関係ない話なので飛ばして頂いて構いません><


おまけ:NAT Traversalについて

いわゆるP2PにおいてNAT越えはとても重要な課題で、オンラインゲームの技術者さん方もNAT越えの技術研究をされているようです(ググるとPDF資料がたくさんヒットします)ね。 私はネットワークにそれほど詳しくないので調べたことだけを備忘録としてまとめておきます。このあたりの専門家の方に話を聞いてみたいですね。

そもそもの問題

通常はルータなどを介してインターネットに接続しますが、ルータにグロ−バルIPが割り当てられていて、それぞれのマシンは内部でプライベートアドレスとポートにマッピングされているのは一般的です。 WebRTCではその仕組みから「マシン同士が直接データ共有を行う」ので、それぞれのマシンのIPとポートを正確に、つまりNATによってマッピングされたあとのIPとポートまでを知っている必要があります。 ですが、そのマシン同士の経路にNATがあると、それぞれのIPアドレスとポートは通常読み取ることはできず、正確に知ることはできません。マッピングテーブルが変われば変わることだってあるでしょうし。 その問題を解決する方法が現在いくつかあるようです。

UPnP

「Universal Plug and Play 」の略称で、これを使ってポートマッピングの設定できるので、グローバスIPの取得が比較的簡単になる…らしい(よく調べてない) モデムがUPnPに対応してないとダメらしいので、あんまり詳しく調べてない。

参考:UPnPのNAT越えについて調べてみた

STUN

「Simple Traversal of UDP through NATs」の略称で、NAT越えの方法としてRFC3489で定められた標準的な仕組み、とのこと。googleやMozillaもWebRTC用(かどうかはわかりませんが)にSTUNサーバが提供されています。

google: stun.l.google.com:19302 Mozilla: stun.services.mozilla.com SkyWay: stun.skyway.io:3478

が使えそうです。冒頭で書いたSkyWayも独自にSTUNサーバを用意してくれているようです。素晴らしいですね。 外部のSTUNサーバに対してクライアントが一度接続し、グローバルIPとマッピングされたポート番号を記憶しておくことで、そのデータを使ってピアは相手のマシンを特定することができる、という感じかな?

ただし、STUNプロトコルでは取得したアドレスはすべてのピアに対して有効でない場合があり、 特にシンメトリックNATではNAT越えができないなどで、後述のTURNを併用されることもあるそうです。

TURN

STUNでは解決の難しかった問題を解決するためにTURNを併用することが多いそうです。TURNはほぼすべてのNATに対してピア接続を有効にする手段とのことですが、 P2Pの接続を中継することで実現するため、接続ごとにTURNサーバへの接続が必要であり、UDP/TCPのコストが発生するためサーバ提供側に対して非常に負荷がかかる。 そのため、STUNが使えるNAT環境ならSTUNを、ダメなら最終手段としてTURNで解決する手法がいいとのこと(Wikipediaより)。

STUN/TURNサーバについては、Google CodeにOSSがあるので一度手元のVPSにインストールして設置してみましたがうまく行かず・・・。 もう少し設定とネットワーク周りの調査が必要っぽいです。このへんは解決できたらエントリにするかもです。プライベートIPとグローバルIPの設定が必要らしいので、AWSならうまくいくかも。

で、これらの最適なNAT Traversalの方法を達成する方法を総称してICEと呼ぶそうです。RTCPeerConenctionの引数に「iceServers」と指定するのはこれのためでしょうね。配列で複数サーバ情報を渡せますし。 簡単なP2Pアプリ(外部にデータが漏れても良いような)ならGoogleやMozillaの提供するSTUNサーバで十分だと思います。

という、とりとめのないおまけでした。

今度こそ現場からは以上です。