panda's tech note

WebRTCのデータチャネル

WebRTCの続きです。オーディオ・ビデオなどのマルチメディア通信は,マイクやカメラなどのデバイス設定が必要なため,まずはデータチャネルの方から取り組みます。シグナリングはWebSocketなどを使うと理解が大変になるので手動で行います。そのため,シグナリングの回数が少なく済むVanilla ICEを今回は採用します。

サンプルアプリケーション – 簡易チャット

データチャネルのサンプルとして,簡易チャットアプリケーションを紹介します。具体的な説明の前に,まずはソースコードをすべて掲載します。このアプリケーションを ここ に置きましたので,ブラウザタブまたはウィンドウを2つ開いて試してください。

<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="Author" content="Hirochika Asai" />
<script type="text/javascript">
// ICE server URLs
let peerConnectionConfig = {'iceServers': [{"urls": "stun:stun.l.google.com:19302"}]};

// Data channel オプション
let dataChannelOptions = {
    ordered: false,
}

// Peer Connection
let peerConnection;

// Data Channel
let dataChannel;

// ページ読み込み時に呼び出す関数
window.onload = function() {
    document.getElementById('status').value = 'closed';
}

// 新しい RTCPeerConnection を作成する
function createPeerConnection() {
    let pc = new RTCPeerConnection(peerConnectionConfig);

    // ICE candidate 取得時のイベントハンドラを登録
    pc.onicecandidate = function(evt) {
        if ( evt.candidate ) {
            // 一部の ICE candidate を取得
            // Trickle ICE では ICE candidate を相手に通知する
            console.log(evt.candidate);
            document.getElementById('status').value = 'Collecting ICE candidates';
        } else {
            // 全ての ICE candidate の取得完了(空の ICE candidate イベント)
            // Vanilla ICE では,全てのICE candidate を含んだ SDP を相手に通知する
            // (SDP は pc.localDescription.sdp で取得できる)
            // 今回は手動でシグナリングするため textarea に SDP を表示する
            document.getElementById('localSDP').value = pc.localDescription.sdp;
            document.getElementById('status').value = 'Vanilla ICE ready';
        }
    };

    pc.onconnectionstatechange = function(evt) {
        switch(pc.connectionState) {
        case "connected":
            document.getElementById('status').value = 'connected';
            break;
        case "disconnected":
        case "failed":
            document.getElementById('status').value = 'disconnected';
            break;
        case "closed":
            document.getElementById('status').value = 'closed';
            break;
        }
    };

    pc.ondatachannel = function(evt) {
        console.log('Data channel created:', evt);
        setupDataChannel(evt.channel);
        dataChannel = evt.channel;
    };

    return pc;
}

// ピアの接続を開始する
function startPeerConnection() {
    // 新しい RTCPeerConnection を作成する
    peerConnection = createPeerConnection();

    // Data channel を生成
    dataChannel = peerConnection.createDataChannel('test-data-channel', dataChannelOptions);
    setupDataChannel(dataChannel);

    // Offer を生成する
    peerConnection.createOffer().then(function(sessionDescription) {
        console.log('createOffer() succeeded.');
        return peerConnection.setLocalDescription(sessionDescription);
    }).then(function() {
        // setLocalDescription() が成功した場合
        // Trickle ICE ではここで SDP を相手に通知する
        // Vanilla ICE では ICE candidate が揃うのを待つ
        console.log('setLocalDescription() succeeded.');
    }).catch(function(err) {
        console.error('setLocalDescription() failed.', err);
    });

    document.getElementById('status').value = 'offer created';
}

// Data channel のイベントハンドラを定義する
function setupDataChannel(dc) {
    dc.onerror = function(error) {
        console.log('Data channel error:', error);
    };
    dc.onmessage = function(evt) {
        console.log('Data channel message:', evt.data);
        let msg = evt.data;
        document.getElementById('history').value = 'other> ' + msg + '\n' + document.getElementById('history').value;
    };
    dc.onopen = function(evt) {
        console.log('Data channel opened:', evt);
    };
    dc.onclose = function() {
        console.log('Data channel closed.');
    };
}

// 相手の SDP 通知を受ける
function setRemoteSdp() {
    let sdptext = document.getElementById('remoteSDP').value;

    if ( peerConnection ) {
        // Peer Connection が生成済みの場合,SDP を Answer と見なす
        let answer = new RTCSessionDescription({
            type: 'answer',
            sdp: sdptext,
        });
        peerConnection.setRemoteDescription(answer).then(function() {
            console.log('setRemoteDescription() succeeded.');
        }).catch(function(err) {
            console.error('setRemoteDescription() failed.', err);
        });
    } else {
        // Peer Connection が未生成の場合,SDP を Offer と見なす
        let offer = new RTCSessionDescription({
            type: 'offer',
            sdp: sdptext,
        });
        // Peer Connection を生成
        peerConnection = createPeerConnection();
        peerConnection.setRemoteDescription(offer).then(function() {
            console.log('setRemoteDescription() succeeded.');
        }).catch(function(err) {
            console.error('setRemoteDescription() failed.', err);
        });
        // Answer を生成
        peerConnection.createAnswer().then(function(sessionDescription) {
            console.log('createAnswer() succeeded.');
            return peerConnection.setLocalDescription(sessionDescription);
        }).then(function() {
            // setLocalDescription() が成功した場合
            // Trickle ICE ではここで SDP を相手に通知する
            // Vanilla ICE では ICE candidate が揃うのを待つ
            console.log('setLocalDescription() succeeded.');
        }).catch(function(err) {
            console.error('setLocalDescription() failed.', err);
        });
        document.getElementById('status').value = 'answer created';
    }
}

// チャットメッセージの送信
function sendMessage() {
    if ( !peerConnection || peerConnection.connectionState != 'connected' ) {
        alert('PeerConnection is not established.');
        return false;
    }
    let msg = document.getElementById('message').value;
    document.getElementById('message').value = '';

    document.getElementById('history').value = 'me> ' + msg + '\n' + document.getElementById('history').value;
    dataChannel.send(msg);

    return true;
}

</script>
<title>WebRTC Data Channel Demo &mdash; ja.tech.jar.jp</title>
</head>
<body>
    <h1>WebRTC Data Channel Demo</h1>

    <h2>シグナリング</h2>
    <p>状態: <input type="text" id="status" value="" readonly="readonly" /></p>

    <h3>SDPの生成</h3>
    <p>(手順1) ブラウザ1で Start を押し,SDP (offer) を生成する。</p>
    <button type="button" onclick="startPeerConnection()">Start</button>
    <h3>自端末のSDP (Read-only)</h3>
    <p>(手順2) ブラウザ1からこのSDP (offer) をコピーする。</p>
    <p>(手順4) ブラウザ2で生成したこのSDP (answer) をコピーする。</p>
    <textarea id="localSDP" cols="80" rows="5" readonly="readonly"></textarea>
    <h3>他端末のSDP (手動でセットする)</h3>
    <p>(手順3) ブラウザ2で,コピーしたブラウザ1のSDP (offer) を貼り付け Set を押すと,自端末のSDPに返答用 SDP (answer) が生成される。</p>
    <p>(手順5) ブラウザ1で,コピーしたブラウザ2のSDP (answer) を貼り付け Set を押す。</p>
    <textarea id="remoteSDP" cols="80" rows="5"></textarea>
    <button type="button" onclick="setRemoteSdp();">Set</button>

    <h2>データチャネルでの通信</h2>
    <form action="javascript:sendMessage()">
        <input type="text" id="message" size="30" value="" />
        <input type="submit" value="Send" />
    </form>
    <textarea id="history" cols="80" rows="10" readonly="readonly"></textarea>

</body>
</html>

解説

HTMLの説明と概要

HTMLの構成とプログラムの概要から説明します。このページは,ブラウザ1およびブラウザ2でこのページを開くことを想定します。まず,ページ上部のID status のテキスト入力要素は現在のWebRTCの処理・接続状況を表示するために使います。スクリプトの説明で後述しますが,ページ読み込み直後の初期状態では closed と表示します。その下のボタンやテキストエリアはVanilla ICEのシグナリングに使用します。今回はシグナリングを手動で行うため,テキストエリアにSDPを表示・入力します。さらにその下のデータチャネルでの通信のフォームでテキストを送信し,テキストエリアにチャット履歴を表示します。

実際のシグナリング手順は以下の通りです。

  1. ブラウザ1で Start ボタンを押下する。これにより,後述の startPeerConnection() 関数を呼び出し,ブラウザ1で RTCPeerConnection のインスタンスおよびデータチャネルを生成し,ICE処理を行います。ICE candidatesが揃うと,Offer SDPが取得できるので,これをID localSDP のテキストエリアに表示します。
  2. このOffer SDPをコピーします。
  3. ブラウザ2でこのページを開き,先ほどコピーしたOffer SDPをID remoteSDP のテキストエリアに貼り付けます。Set ボタンを押下すると setRemoteSdp() 関数が呼ばれ,RTCPeerConnection インスタンスを生成し,このOffer SDPを RTCPeerConnection.setRemoteDescription() で設定します。その後,RTCPeerConnection.createAnswer() により,Answer SDPを生成し,ブラウザ2のICEを開始します。ICE candidatesが揃い次第Answer SDPが取得できるため,これをID localSDP のテキストエリアに表示します。
  4. このAnswer SDPをコピーします。
  5. ブラウザ1の画面に戻り,コピーしたAsnwer SDPをID remoteSDP のテキストエリアに貼り付けます。Set ボタンを押下すると,setRemoteSdp() 関数が呼ばれます。この関数の中で,既に RTCPeerConnection インスタンスが作成済みの場合は,入力されたSDPがAnswer SDPであると見なし,RTCPeerConnection.setRemoteDescription() によりこのインスタンスにAnswer SDPとして設定します。これで接続が確立され,両方のブラウザで状態が connected になります。

接続が確立された後は,フォームのID message のテキストボックスにメッセージを打ち込み,Send ボタンを押下することでメッセージを他のブラウザに送信できます。

スクリプトの説明

スクリプトを上から説明していきます。

let peerConnectionConfig = {'iceServers': [{"urls": "stun:stun.l.google.com:19302"}]};

は,ICEサーバの設定です。RTCPeerConnection のインスタンス生成のときに引数として使います。

let dataChannelOptions = {
 ordered: false,
}

は,RTCPeerConnection.createDataChannel() でデータチャネルを生成する際に利用するオプションです。今回は ordered でメッセージの順序を保証をしない(アウトオブオーダーを許可する)ように設定しています(デフォルトは true)。その他のオプションは Mozzilaのドキュメント を参照すると良いです。

その下の

    // Peer Connection
    let peerConnection;

    // Data Channel
    let dataChannel;

RTCPeerConnection のインスタンスと RTCDataChannel のインスタンスを保存するための変数です。

// ページ読み込み時に呼び出す関数
window.onload = function() {
    document.getElementById('status').value = 'closed';
}

でページが読み込まれたときにID status のテキストボックスに closed と表示します。

その下の createPeerConnection() は,RTCPeerConnection のインスタンスを生成し,イベントハンドラを設定する関数です。ブラウザ1の処理とブラウザ2の処理で共通の処理を関数としてまとめています。以下,この関数の中身の説明をします。

    let pc = new RTCPeerConnection(peerConnectionConfig);

RTCPeerConnection インスタンスを新しく生成します。引数の peerConnectionConfig でICEサーバ(STUNサーバ)を設定しています。

    pc.onicecandidate = function(evt) {
        if ( evt.candidate ) {
            // 一部の ICE candidate を取得
            // Trickle ICE では ICE candidate を相手に通知する
            console.log(evt.candidate);
            document.getElementById('status').value = 'Collecting ICE candidates';
        } else {
            // 全ての ICE candidate の取得完了(空の ICE candidate イベント)
            // Vanilla ICE では,全てのICE candidate を含んだ SDP を相手に通知する
            // (SDP は pc.localDescription.sdp で取得できる)
            // 今回は手動でシグナリングするため textarea に SDP を表示する
            document.getElementById('localSDP').value = pc.localDescription.sdp;
            document.getElementById('status').value = 'Vanilla ICE ready';
        }
    };

では,ICE candidateを取得したときのイベントハンドラを設定しています。ICE candidate は evt.candidate に設定されており,これが空の場合,全てのICE candidatesを取得済みであることを表します。今回はVanilla ICEを採用するので,この evt.candidate が空になったときに,ID localSDP のテキストエリアにICE candidate入りのSDP(RTCPeerConnection.localDescription.sdp)を表示し,状態を Vanilla ICE ready に変更しています。

次に,RTCPeerConnection.connectionstatechange イベントハンドラで,ピアの接続状態が変化したイベントを処理するようにします。

    pc.onconnectionstatechange = function(evt) {
        switch(pc.connectionState) {
        case "connected":
            document.getElementById('status').value = 'connected';
            break;
        case "disconnected":
        case "failed":
            document.getElementById('status').value = 'disconnected';
            break;
        case "closed":
            document.getElementById('status').value = 'closed';
            break;
        }
    };

のように,接続状態に応じて,状態表示のテキストボックスの値を変更しています。

最後に,RTCPeerConnection.ondatachannel イベントハンドラで相手からのデータチャネル生成イベントを処理します。今回はデータチャネル生成オプションの negotiated をデフォルトの false に設定しているため,ブラウザ1でデータチャネルを生成すれば,ブラウザ2でも対応するデータチャネルが自動的に(in-bandで)生成されます。そのため,このイベントハンドラでデータチャネルのイベントハンドラを後述する setupDataChannel() で初期化しています。データチャネル生成オプションの negotiatedtrue にした場合は,両方のノードでデータチャネルを(out-of-bandで)生成する必要があります。

    pc.ondatachannel = function(evt) {
        console.log('Data channel created:', evt);
        setupDataChannel(evt.channel);
        dataChannel = evt.channel;
    };

次に,Start ボタンを押下したときに呼び出される startPeerConnection() 関数を定義します。の関数はブラウザ1(ピア接続のイニシエータ側)から呼び出すことを想定しています。

    // 新しい RTCPeerConnection を作成する
    peerConnection = createPeerConnection();

で,上記のイベントハンドラを設定済みの RTCPeerConnection のインスタンスを作成します。Offer SDPを生成するには少なくとも1つ以上のメディアトラックまたはデータチャネルが必要なため,データチャネルを作成します。

    dataChannel = peerConnection.createDataChannel('test-data-channel', dataChannelOptions);

test-data-channel という名前のデータチャネルを作成し,

    setupDataChannel(dataChannel);

で作成したデータチャネルに対しイベントハンドラを設定しています。

データチャネルを作成したら以下の手続きでOffer SDPを生成し,ICE処理を開始します。

    // Offer を生成する
    peerConnection.createOffer().then(function(sessionDescription) {
        console.log('createOffer() succeeded.');
        return peerConnection.setLocalDescription(sessionDescription);
    }).then(function() {
        // setLocalDescription() が成功した場合
        // Trickle ICE ではここで SDP を相手に通知する
        // Vanilla ICE では ICE candidate が揃うのを待つ
        console.log('setLocalDescription() succeeded.');
    }).catch(function(err) {
        console.error('setLocalDescription() failed.', err);
    });

RTCPeerConnection.createOffer() は成功するとOffer SDPを表す RTCSessionDescription インスタンスを引数とした関数を呼び出すので,RTCPeerConnection.setLocalDescription() でローカルSDPとして設定します。今回はpromiseの形でこの関数を定義していますが,MDM web docs: RTCPeerConnection.createOffer()にあるようにコールバック関数を定義することもできます。また,RTCPeerConnection.createOffer() を呼び出すと,ICE処理が開始されます。Trickle ICEの場合は,このICE candidatesを含まないSDPを相手に通知する必要がありますが,今回はVanilla ICEを採用しているため,この段階ではシグナリングはしません。

最後に,以下の行で状態表示を offer created に変更しています。

    document.getElementById('status').value = 'offer created';

次に,setupDataChannel() 関数の説明をします。ここまででこの関数を呼び出す場所が2箇所あり,そこで説明した通り,この関数では以下の通りデータチャネルの4つのイベントハンドラを設定します。

    dc.onerror = function(error) {
        console.log('Data channel error:', error);
    };
    dc.onmessage = function(evt) {
        console.log('Data channel message:', evt.data);
        let msg = evt.data;
        document.getElementById('history').value = 'other> ' + msg + '\n' + document.getElementById('history').value;
    };
    dc.onopen = function(evt) {
        console.log('Data channel opened:', evt);
    };
    dc.onclose = function() {
        console.log('Data channel closed.');
    };

RTCDataChannel.onerror はデータチャネルでエラーが発生したときに呼び出されます。今回はコンソールログにそのエラーを出力するようにしています。 RTCDataChannel.onmessage は,ピアからこのデータチャネルを通じてメッセージを受信したときに呼び出されます。ここでは,コンソールログにそのメッセージを出力するとともに,ID history のテキストエリアの先頭に other> というプレフィックスを付けて追加するようにしています。RTCDataChannel.onopen および RTCDataChannel.onclose はそれぞれデータチャネルが開かれたまたは閉じられたときに呼ばれます。コンソールログに出力しています。

次に,ID remoteSDP に手動でSDPをコピーして,Set ボタンを押下したときに呼び出される setRemoteSdp() 関数の説明をします。まず最初に

    let sdptext = document.getElementById('remoteSDP').value;

で入力されたSDPのテキストを取得します。次の処理はブラウザ1(Offer SDPを生成してAnswer SDPをここで設定する端末)かブラウザ2(Offer SDPをここで設定する端末)で処理が異なります。この判定を peerConnection 変数で行っています。peerConnection が空でなければ既に RTCPeerConnection インスタンスを生成済みであるブラウザ1であり,これが空であればブラウザ2と判定します。この判定は簡易的なもので厳密ではありませんが,サンプルのため今回はこの手法を使っています。

ブラウザ1側の処理は以下の通りです。

        // Peer Connection が生成済みの場合,SDP を Answer と見なす
        let answer = new RTCSessionDescription({
            type: 'answer',
            sdp: sdptext,
        });
        peerConnection.setRemoteDescription(answer).then(function() {
            console.log('setRemoteDescription() succeeded.');
        }).catch(function(err) {
            console.error('setRemoteDescription() failed.', err);
        });

入力されたSDPはAnswer SDPであるべきなので,type: answerRTCSessionDescription インスタンスを生成し,これを RTCPeerConnection.setRemoteDescription() で設定します。

ブラウザ2側の処理は以下の通りです。

        // Peer Connection が未生成の場合,SDP を Offer と見なす
        let offer = new RTCSessionDescription({
            type: 'offer',
            sdp: sdptext,
        });
        // Peer Connection を生成
        peerConnection = createPeerConnection();
        peerConnection.setRemoteDescription(offer).then(function() {
            console.log('setRemoteDescription() succeeded.');
        }).catch(function(err) {
            console.error('setRemoteDescription() failed.', err);
        });
        // Answer を生成
        peerConnection.createAnswer().then(function(sessionDescription) {
            console.log('createAnswer() succeeded.');
            return peerConnection.setLocalDescription(sessionDescription);
        }).then(function() {
            // setLocalDescription() が成功した場合
            // Trickle ICE ではここで SDP を相手に通知する
            // Vanilla ICE では ICE candidate が揃うのを待つ
            console.log('setLocalDescription() succeeded.');
        }).catch(function(err) {
            console.error('setLocalDescription() failed.', err);
        });
        document.getElementById('status').value = 'answer created';

ブラウザ2ではまだ RTCPeerConnection インスタンスを作成していないため,ここで createPeerConnection() 関数により作成します。入力されたSDPはOffer SDPであるべきなので,type: offerRTCSessionDescription インスタンスを生成し,RTCPeerConnection.setRemoteDescription() で設定します。次にこのSDPに対応するAnswer SDPを生成するために RTCPeerConnection.createAnswer() を呼び出します。この先の処理は上述の RTCPeerConnection.createOffer() の後の処理と同様です。最後に状態表示を answer created に変更しています。

ここまでが,WebRTCのピア確立の処理です。最後に,チャットのメッセージを送信する処理である sendMessage() 関数を以下の通り実装します。

    if ( !peerConnection || peerConnection.connectionState != 'connected' ) {
        alert('PeerConnection is not established.');
        return false;
    }
    let msg = document.getElementById('message').value;
    document.getElementById('message').value = '';

    document.getElementById('history').value = 'me> ' + msg + '\n' + document.getElementById('history').value;
    dataChannel.send(msg);

    return true;

処理内容は非常に単純で,ID message のテキストボックスの内容を取得し,次のメッセージのためにこれを空にしています。この取得したメッセージに me> プレフィックスを付けてID history のテキストエリアの先頭に追加します。さらに,生成済みのデータチャネルの RTCDataChannel.send() により,ピアにこのメッセージを送信しています。