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 — 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で Start ボタンを押下する。これにより,後述の
startPeerConnection()
関数を呼び出し,ブラウザ1でRTCPeerConnection
のインスタンスおよびデータチャネルを生成し,ICE処理を行います。ICE candidatesが揃うと,Offer SDPが取得できるので,これをIDlocalSDP
のテキストエリアに表示します。 - このOffer SDPをコピーします。
- ブラウザ2でこのページを開き,先ほどコピーしたOffer SDPをID
remoteSDP
のテキストエリアに貼り付けます。Set ボタンを押下するとsetRemoteSdp()
関数が呼ばれ,RTCPeerConnection
インスタンスを生成し,このOffer SDPをRTCPeerConnection.setRemoteDescription()
で設定します。その後,RTCPeerConnection.createAnswer()
により,Answer SDPを生成し,ブラウザ2のICEを開始します。ICE candidatesが揃い次第Answer SDPが取得できるため,これをIDlocalSDP
のテキストエリアに表示します。 - このAnswer SDPをコピーします。
- ブラウザ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()
で初期化しています。データチャネル生成オプションの negotiated
を true
にした場合は,両方のノードでデータチャネルを(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: answer
の RTCSessionDescription
インスタンスを生成し,これを 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: offer
の RTCSessionDescription
インスタンスを生成し,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()
により,ピアにこのメッセージを送信しています。