DataChannel API使ってみたよ
前回と前々回でメディア接続とSTUNサーバ構築までできたので、DataChannel APIも試してみました。 個人的には一番使いたかったAPIですね。うまく使えばWebSocketに変わる便利な仕組みになるかも。 なお、peer接続などは前回のエントリを参考にしてください。
DataChannel APIとは
peerの接続を利用して、ファイルなどのデータを直接送受信できるAPIです。P2Pらしいことができますね。 DataChannelはRTCPeerConnectionクラスのインスタンスメソッドとして定義されていて、
var peer = new webkitRTCPeerConnection(setting);
var dataChannel = peer.createDataChannel('RTCDataChannel');
という感じで、createDataChannel()メソッドに接続ラベルを指定して生成できます。簡単ですね。
そう思っていた時期が僕にもありました。
使ってみると色々問題(と勘違い)があったのでまとめます。 なお、DataChannel APIはChromeではサポートされているとのことですが、私の環境ではCararyでもNotSupported Errorになったりしました。なんでだろう? MacのChromeだとダメで、UbuntuのChromeだと大丈夫だったりしたので調査が必要ですね…。
Offer前に生成しておかないといけない
Peer接続完了後にdataChannelを生成すると、ステータスが"connecting"のまま変化せず、データ送信ができません。 これは、dataChannelはSDPに乗せる必要があって(つまりNegotiationの対象)、createOfferでオファーを送る前に生成しておかないと、 SDPに含まれずいつまでたってもコネクションが開かないので注意が必要です。よって、
var peer = new webkitRTCPeerConnection(setting);
// 先に生成しておく
var dataChannel = peer.createDataChannel('RTCDataChannel');
peer.createOffer(function(sdp) {
// Offer send callback
});
とやらないとDataChannelは開きません。 RTCDataChannelクラスとかを別に用意してくれてたほうがわかりやすいと思うんですけどどうなんですかね:
var peer = new webkitRTCPeerConnection(setting);
var dataChannel = new RTCDataChannel(peer);
みたいな。まぁそういう実装になっているので仕方ないですね。 ともあれ、DataChannelを使う場合はpeer接続を開く前、と覚えておくと良いと思います。
送信側はこれでOKで、受信側、つまりAnswerを送信する側はondatachannelイベントを監視して、DataChannelの接続リクエストをハンドリングします。 これもSDPに乗ってくるので、createAnswerのタイミングでやるといいと思います。
peer.createAnswer(function(sdp) {
// AnswerのSDPをセット
peer.setLocalDescription(sdpundefined function() {
// do something
});
// DataChannelの接続を監視
peer.ondatachannel = function(evt) {
// evt.channelにDataChannelが格納されているのでそれを使う
dataChannel = evt.channel;
};
});
という感じですね。まとめると、
- 送信側(Offerを送る方)はOfferを送る前にcreateDataChannel()で接続を宣言する
- 受信側(Answerを送る方)はAnswer送信後にondatachannelイベントを監視する
とすることで、DataChannelが相互に接続されます。MediaStreamの場合と似てますね(addStream()とonaddstreamの関係)。 続いて実際にデータを送信するフェーズなのですが、ここにもハマったのでまとめておきます。
データ/ファイル送信
ここに一番ハマりました。DataChannel APIではファイルなども送信できる(String/ArrayBuffer/Blobが送信できる)んですね。 他のサイトでも、
var dataChannel = peer.createDataChannel('label');
...接続処理は省略
// データ送信
dataChanne.send(data);
ってなってて、あーinputとかの入力値を引数に渡してsend()メソッドで送信できるんだなーって思うんですが、これらのサンプルはチャットのような短いデータ送受信なら問題ないんですが、ファイル送信では問題が起きます。
UDPパケットの制限とストリーム
ファイル送信も同じようにFileReaderを使って送信するなら、下のようなコードになりますよね。ArrayBufferで送信するパターンです。
var fr = new FileReader();
fr.onload = function(evt) {
// 読み込み完了時に送信
dataChannel.send(evt.result);
};
// fileObjectはinput[type=file]のようなデータから取得するオブジェクト
fr.readAsArrayBuffer(fileObject);
これでOK…のように見えますが、100MBのような大きめのファイルを扱うと、データが壊れて受信できません。
ipv4のケースでのお話ですが、ipv4でのUDPデータグラムでは、一つのパケット送信におけるデータ長は64KBに策定されています。そのため、一度にsend()メソッドで送信すると、先頭の64KBしか送信できず、続くデータは破棄されてしまいます。
※ちなみにipv6ではジャンボグラム機能を使えばこの制限を越えられるようですが、そもそもipv6ならNAT Traversalの必要がなかったりしてアレなので割愛します。
なので、大きなファイルを送信する場合は、64KB程度でchunkにして連続して送信する必要があるんですね。私は擬似ストリームのような送受信クラスを作って対応しました。
function DataStream(connection) {
this.conn = connection;
this.callbacks = {};
this.chunk = null;
this.MAX_BYTES = 64 * 1024;
}
DataStream.create = function(connection) {
var instance = new DataStream(connection);
instance.init();
return instance;
};
DataStream.prototype.init = function() {
var that = this;
this.conn.onmessage = function(evt) {
var chunk = evt.dataundefined
uint8;
if ( chunk === "\0" ) {
this._dispatch('end'undefined this.chunk);
this.chunk = null;
return;
}
if ( chunk instanceof ArrayBuffer ) {
if ( this.chunk === null ) {
uint8 = new Uint8Array(chunk);
} else {
uint8 = new Uint8Array(this.chunk.byteLength + chunk.byteLength);
uint8.set(new Uint8Array(this.chunk)undefined 0);
uint8.set(new Uint8Array(chunk)undefined this.chunk.byteLength);
}
this.chunk = uint8;
} else {
if ( this.chunk === null ) {
this.chunk = chunk;
} else {
this.chunk += chunk;
}
}
this._dispatch('data'undefined chunk);
}.bind(this);
};
DataStream.prototype.on = function(typeundefined callback) {
if ( ! (type in this.callbacks) ) {
this.callbacks[type] = [];
}
this.callbacks[type].push(callback);
};
DataStream.prototype._sendChunk = function(chunk) {
this.conn.send(chunk);
};
DataStream.prototype._dispatch = function(typeundefined data) {
if ( type in this.callbacks ) {
this.callbacks[type].forEach(function(callback) {
callback(data);
});
}
};
DataStream.prototype.send = function(data) {
var that = thisundefined
index = 0undefined
size = this.getDataSize(data)undefined
maxBytes = this.MAX_BYTESundefined
chunk;
if ( size > maxBytes ) {
do {
if ( index + maxBytes > size ) {
chunk = data.slice(indexundefined size);
} else {
chunk = data.slice(indexundefined index + maxBytes);
}
this._sendChunk(chunk);
index += maxBytes;
} while ( size > index );
this._sendChunk("\0");
this._dispatch('sended');
} else {
this._sendChunk(data);
this._sendChunk("\0");
this._dispatch('sended');
}
};
DataStream.prototype.getDataSize = function(data) {
return data.byteLength || data.length;
};
ファイルデータを63KB程度のchunkにして分割して送信、復元させます。EventEmitterみたいな実装もやってます。で、データ終端はヌルバイトを送信してシグナリングし、(これでいいのかな…)分割されたデータはArrayBufferではなくArrayBufferViewのUint8Arrayで加工できるようにして送受信するようにもしています。
※ちなみにpeer.jsではutil.chunkで分割送信させているようでした。さすがですね。chunkedMTU: 16300として16300Byteで区切っている理由はなんだろう分からない…。
こうすることで、大きなファイルの送受信も可能になりました。また、通常のテキストデータ(チャット用途)とファイル送信用途の判別が手間なので、それぞれ専用のチャンネルを生成して、デュアルチャンネルで通信するようにしました。これは続くパケットメッセージの順序の保証も考慮しています。
UDPパケットの送信順と信頼性は
UDPのパケットはメッセージの順序を保証しないので、連続送信したメッセージを正しい順序で受け取れるのかなぁと思っていたのですが、SDPの中身を見ると、
s=sctp webrtc-datachannel
というようなDescriptionがあり(うろ覚え)、どうやらSCTPプロトコルで通信してくれているようでした。なので順序は保証されて、なおかつ輻輳制御もやってくれているのかな。これは嬉しいですね。
追記: SCTPでの転送はChromeのみで有効なようで、かつchrome://flagsで「SCTP データ チャネルを無効にする」の設定項目があり、これがアクティブになっていない場合のみSCTP転送が有効になるようです(デフォルトではSCTPで転送するっぽい)
まとめ
簡単に生成できて扱えるAPIは嬉しいですが、アプリケーション上位層で色々と管理しないといけない部分もあるので、必要に応じて内部の動きを調査しないといざという時に困りますね…。とても勉強になりました。
webkitの方はだいたいやったので、次はFirefoxの実装周りも調査してみないと。
とりとめないエントリで上手くまとまっていませんが、現場からは以上です。