« Server.addHandlerの無くなったJetty7で複数のHandlerを追加する方法 | メイン | WebSocketRemoteのREADMEを英語で書いてみた »

JettyのWebSocketを使って大容量データを高速に送信するコツ

大容量と言っても1MB以下のデータですが...。
あくまでWebSocketRemote作成中に試行錯誤してうまくいった方法です。他に良策があるかもしれないですが、書き留めておきます。掲示しているWebSocketRemoteのコードはバージョン0.0.6時点のものです。
概要
現在のWebSocketの仕様では文字列しか送信できないようです。このため、画像データをBase64化して送っています。WebSocketRemoteの開発初期の実装では、一定時間間隔でデータを送信していましたが、一部のデータが欠損したり順番が入れ替わったような動きをしていました。どうも内部的にはデータを分割しているようです。(詳細な挙動は未調査です。)
また、WebSocketが高速なのは接続をはりっぱなしにするため、コストの高いコネクション処理が一度だけなのと、データ送信を複数同時に行なえるからだそうです。
データ順の入れ替わりや欠損への対応と、WebSocketの特性を活かすために、現在の実装は以下のような動作にしています。
サーバ側
・画像を分割して送信するようにしている(現在の実装では8192バイト長に分割)
・送信するデータにヘッダをつける(セパレータは「_」)
・ヘッダには、画像IDと分割したデータ数、何番目のデータなのかという情報を含める
・画像IDからデータの新旧を判断できるようにする(「new Date().getTime()」を16進数化したもの)
・データ送信は、一定の時間間隔ではなく、要求があったときのみ行なう
クライアント側
・WebSocketの接続が確立した時にサーバ側に画像データを要求する
・受け取った画像データは画像ID別に配列で保存する
・分割された画像データが全部揃った場合、データを結合し画面に反映させる
・分割された画像データが全部揃った場合、その画像データとそれよりも古いデータを破棄する
・画面描画を終えてから一定時間経過後(現状では100ミリ秒後)に、サーバ側に画像データを要求する
・データ欠損対応として、一定時間(3000ミリ秒)が経過しても分割データが揃わなかった場合は、画像データを再要求する
コード
具体的なコードを見ていきます。
サーバ側
クライアントからデータの送信要求があった時に実行される部分を下に示します。base64が送信する画像データをBase64化したバイト配列で、outbound(この場合、Outboundインターフェイスを実装したWebSocketConnectionクラスになります)が送信先のコネクションです。分割したデータをoutbound.sendMessageへ一気に流し込んでいます。
WebSocketのサンプル等でよく使われるのは「Outbound.sendMessage(byte frame,String data)」というメソッドですが、ここでは「sendMessage(byte frame,byte[] data, int offset, int length)」を使用しています。この部分を最初に実装した時には、Base64化した時に得られるバイト配列を文字列化して送信してたのですが、Jettyのコードを追ってみると、渡された文字列をバイト配列に変換し、送信処理をしていました。つまり「バイト配列=>文字列=>バイト配列」と無駄な処理となっていたため、送信データをバイト配列で組み立て送信するようにしました。
src/main/java/com/kanasansoft/WebSocketRemote/WebSocketRemote.javaの一部分
int sendSize = 8192;
int sendCount = base64.length / sendSize;
int remainder = base64.length % sendSize;
if(remainder!=0){
    sendCount++;
}
byte[] imageId = Long.toString(new Date().getTime(),16).getBytes();
byte[] sequenceCount = Integer.toString(sendCount, 16).getBytes();
for(int i=0;i<sendCount;i++){
    byte[] sequenceNumber = Integer.toString(i + 1, 16).getBytes();
    int restLength = base64.length-i * sendSize;
    int sendLength = sendSize<restLength?sendSize:restLength;
    byte[] sendData = new byte[imageId.length + sequenceNumber.length + sequenceCount.length + sendLength + 3];
    System.arraycopy(imageId, 0, sendData, 0, imageId.length);
    System.arraycopy(sequenceNumber, 0, sendData, imageId.length + 1, sequenceNumber.length);
    System.arraycopy(sequenceCount, 0, sendData, imageId.length + sequenceNumber.length + 2, sequenceCount.length);
    System.arraycopy(base64, i * sendSize, sendData, imageId.length + sequenceNumber.length + sequenceCount.length + 3, sendLength);
    sendData[imageId.length + 0] = separatorByte;
    sendData[imageId.length + sequenceNumber.length + 1] = separatorByte;
    sendData[imageId.length + sequenceNumber.length + sequenceCount.length + 2] =separatorByte;
    try {
        outbound.sendMessage((byte)WebSocket.SENTINEL_FRAME, sendData,0,sendData.length);
    } catch (IOException e) {
        e.printStackTrace();
    }
}
しかし、このままでは問題が発生します。送信したはずのデータが、かなりの頻度でクライアントに届きませんでした。大量のデータをJettyが捌ききれていないようでした。このため、サーバ作成時にバッファサイズを指定しました。バッファサイズ指定の部分以外は、前回のエントリーと同じです。
src/main/java/com/kanasansoft/WebSocketRemote/WebSocketRemote.javaの一部分
WSServlet wsServlet = new WSServlet(this);
ServletHolder wsServletHolder = new ServletHolder(wsServlet);
wsServletHolder.setInitParameter("bufferSize", Integer.toString(8192*256,10));
ServletContextHandler wsServletContextHandler = new ServletContextHandler();
wsServletContextHandler.addServlet(wsServletHolder, "/ws/*");
ちなみに、バッファサイズのデフォルトのサイズは8192バイトとなっています。これはJettyのWebSocketServletクラスのinitメソッド内で指定されています。実は、分割データのサイズを8192バイトとしたのは、バッファサイズの既定値に使われているのだから効率が良いのではと思い、色々と実験した結果でした。
クライアント側
WebSocketでデータを受信した時に実行される部分を示します。
receiveIndexesは、保持している画像の画像IDの配列です。receiveDataは分割された画像データを保持するオブジェクト(配列ではない点に注意)です。setImageRequestTimerは、送信要求タマーをクリアし、再度送信要求タイマーを設定する関数です。onMessageWebSocketでは、最初に3000ミリ秒を指定し、最後に100ミリ秒を指定ています。onMessageWebSocket内でreturnされずに最後まで処理が実行されるのは、分割されたデータが全部揃い画面に反映された時になるため、最後のデータ受信から3000ミリ秒経過した時にデータの再送信要求が発生します。この再送信が発生する前に何らかのデータを受信すると3000ミリ秒にリセットされ、画像データを画面に反映できた時には100ミリ秒にリセットするようになっています。分割データが全部揃った段階で古いデータは全て破棄していますので、遅れて届いた古いデータも次の新しいデータが揃った時に破棄されます。
src/main/resources/html/common.jsの一部分
function onMessageWebSocket(message){
    setImageRequestTimer(3000);
    var data=message.data;
    var splitData=data.split("_");
    if(splitData.length!=4){
        return;
    }
    var imageId=splitData[0];
    var sequenceNumber=parseInt(splitData[1],16);
    var sequenceCount=parseInt(splitData[2],16);
    if(receiveIndexes.indexOf(imageId)==-1){
        receiveIndexes.push(imageId);
        receiveIndexes.sort(function(a,b){return parseInt(a,16)-parseInt(b,16);});
        receiveData[imageId]={"data":[],"receiveCount":0,"sequenceCount":sequenceCount};
    }
    var receive=receiveData[imageId];
    receive.receiveCount++;
    receive.data[sequenceNumber]=splitData[3];
    if(receive.receiveCount!=receive.sequenceCount){
        return;
    }
    var currentPosition=receiveIndexes.indexOf(imageId);
    deleteIndexes=receiveIndexes.slice(0,currentPosition+1);
    receiveIndexes=receiveIndexes.slice(currentPosition+2);
    for(var i=0;i<deleteIndexes.length;i++){
        delete receiveData[deleteIndexes[i]];
    }
    var base64="";
    for(var i=1;i<receive.data.length;i++){
        base64+=receive.data[i];
    }
    remote.setAttribute("src","data:image/jpg;base64,"+base64);
    setImageRequestTimer(100);
}
処理全部が見たいという方は、WebSocketRemoteのソースをGitHubに公開していますので、そちらからダウンロードして下さい。

コメントを投稿

(いままで、ここでコメントしたことがないときは、コメントを表示する前にこのブログのオーナーの承認が必要になることがあります。承認されるまではコメントは表示されません。そのときはしばらく待ってください。)

Google

タグ クラウド