[JavaScript]配列中の大量データを非同期でゆっくり処理する

先日、クロネコヤマトの伝票番号から配送状況を取得するAPIを作りました。
このAPIですが、負荷軽減のため、呼び出し頻度が毎秒1回という制限を設けています。

制限があるのは良いとして、このAPIを使って複数(大量)のデータをJavaScriptで処理したい場合どうやって作ったらよいのだろうか? と思い、色々試行錯誤した事の過程と結果です。



長文になってしまったので、最初に目次を書いておきます。

  • その1:ループで処理する
  • その2:ループ内でスリープさせる
  • その3:setTimeout()で非同期処理させる
  • その4:非同期処理の関数に汎用性を持たせる
  • ところで、「毎秒1回」という呼び出し制限がない場合は?
  • その5:画面が固まることなく,大量データを素早く処理する
  • まとめ

結論だけ欲しい人は、最後の”まとめ”にある関数だけコピーすればOKです。


その1:ループで処理する

普通に考えると、当然for文等でループさせて処理を行います。

まぁ、以下のような感じのプログラムになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<div>
    テスト実行:<input id="btnInput1" type="button" onClick="btnInput1_Click();" value="開始" /
    <div id="result1"></div>
</div>
 
<script>
function btnInput1_Click() {
 
    var params = [ "000000000001",
                   "000000000002",
                   "000000000003",
                   "000000000004",
                   "000000000005",
                   "000000000006",
                   "000000000007",
                   "000000000008",
                   "000000000009",
                   // ... 1000件ぐらいの大量データ
                   "000000001000" ];
 
    // 指定された全伝票データの検索を行う
    for ( var i = 0; i < params.length; i++ ) {
        var slipNo = params[ i ];
        getDataDummy( slipNo );
    }
 
    return false;
}
 
function getDataDummy( inputStr ) {
    // WebAPIを使って伝票データを取得する関数...
    // 今回はテストなので、指定されたパラメータを画面に出力する処理に変更。
    p = document.getElementById( 'result1' );
    p.appendChild( document.createTextNode( "検索 key=" + inputStr ) );
    p.appendChild( document.createElement( "br" ) );
}
</script>



見たら分かるような処理ですが、最初なので解説します。

  • 画面のボタン:
     inputタグ(ボタン)のonClickでbtnInput1_Click()をコールしています。

  • btnInput1_Click()
     ここで、大量の伝票データの検索処理を行います。
     今回は説明のため、検索条件のKeyは配列に入れて有ります。
     (実際はtextarea等から取得する等をイメージしてください。)
     forループで、データ件数分検索処理(getDataDummy関数)をコールします。

  • getDataDummy()
     こちらも説明のため、実際の検索は行わず、検索キーを画面に表示させるだけになっています。
     ※実際の検索処理ロジックが知りたい場合は、この記事を参照してください。


ループさせているので、当然全データの処理を行うことが出来ます。
でもこのパターンだと、全力でAPIをコールすることになるので、”API呼び出しは毎秒1回以内で”という制限を満たせません。



sample1: ループで処理を行う
テスト実行:





その2:ループ内でスリープさせる


Cやjavaのプログラマからすると、前述の問題は「for文でウェイトが入っていないのが問題でしょ。」という話になります。ではsleep()を入れようか...ってことになるわけですが、残念ながらJavaScriptにはsleep関数が有りません。

なけりゃ作れば良いだけなので,さくっと自作します。

1
2
3
4
5
6
7
8
9
function sleep( msec ) {
    var start = new Date;
    while (1) {
        var cur = new Date;
        if ( msec <= cur.getTime() - start.getTime()) {
            break;
        }
    }
}


引数でスリープさせる時間を指定して、その分の時間が経過するまでループし続けて待つだけです。
(上記の処理だとビジーウェイトになるので良くないのですが、サンプルなのでそこはスルーします)


呼び元側は以下のようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function btnInput2_Click() {
    var params = [ "000000000001",
                   "000000000002",
                   // ... 1000件ぐらいの大量データ
                   "000000001000" ];
 
    for ( var i = 0; i < params.length; i++ ) {
        var slipNo = params[ i ];
        getDataDummy2( slipNo );
 
        sleep( 400 );  // 毎回0.4秒スリープ(本当は1secだけど、確認のため短めに変更) 
    }
    return false;
}



これで確かに指定時間の周期で処理はコールされます。
ですが、残念ながら別の面でうまく動作しません。


実際に走らせて見ると分かるのですが、この関数が終了するまでの間、ブラウザ画面の再表示が行われないため画面が固まったように見えてしまいます。これは、JavaScriptの場合はスクリプトの処理がUIの更新処理と同じスレッドで走ることが原因です。

下のボタンで、プログラムを実行し振る舞いを確認してみてください。
sample2:スリープを入れて処理する(注意:実行すると4秒ほどブラウザが固まります)
テスト実行:



さらに、スクリプトの実行に長時間掛かる場合は、使い勝手が落ちるだけでなく、ブラウザより以下のような警告ダイアログが表示されてしまいます。




その3:setTimeout()で非同期処理させる


先ほどのプログラムでは、ブラウザによって一回のイベントハンドラ内で作業できる時間が制限されている以上、スリープを入れたところで意味が無いことが分かりました。

それではどうすべきかという話になるわけですが、先に結論を書いてしまうとJavaScriptでこの手の処理を行う場合は、setTimeout()を利用して非同期処理を行います。

言葉で説明しても分かり辛いので、まずはサンプルです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function btnInput3_Click() {
    var params = [ "000000000001",
                   "000000000002",
                   // ... 1000件ぐらいの大量データ
                   "000000001000" ];
 
    getDataMain3( params );
 
    return false;
}
 
var paramList3;
function getDataMain3( params ) {
    paramList3 = params.concat();   // 配列のコピーを作る
    var slipNo = paramList3.shift();
 
    getDataDummy3( slipNo );
    if ( paramList3.length > 0 ) {
        // 400mSec後に自身を呼び出す(本当は1secだけど、確認のため短めに...)
        setTimeout( "getDataMain3( paramList )", 400 );
    }
}
 
function getDataDummy3( inputStr ) {
    p = document.getElementById( 'result3' );
    p.appendChild( document.createTextNode( "検索 key=" + inputStr ) );
    p.appendChild( document.createElement( "br" ) );
}



前回までは、イベントハンドラ内でデータ取得関数をコールしていたのですが、今回は替わりにgetDataMainをコールしており、その中でgetDataDummy()を呼び出しています。

増えたgetDataMain3()ですが、何をしているか順を追って確認します。

paramList3 = params.concat();   // 配列のコピーを作る


ここでは、受け取った引数のコピーを作って別の変数にコピーをしています。
本来concat()メソッドは複数配列の結合をする処理なのですが、concat()では結合処理後に新しい配列を生成するという性質を利用し、渡された配列のコピーを生成しています。




var slipNo = paramList3.shift();


次に、コピーした配列からshift()関数で最初の要素を取り出しています。
同時に、paramList3から最初の要素は削除されます。
(先ほどコピーを作ったのは、ここで配列の中身を書き換えてしまうのが理由です)




getDataDummy3( slipNo );


取得した最初の要素に対し、データ取得処理をコールします。
(パラメータは、配列中の最初の要素です)




if ( paramList3.length > 0 ) {
    setTimeout( "getDataDummy3Main( paramList )", 400 );
}


shiftした後の配列要素数を調べることで、まだ処理すべきデータが残っているかを確認し、データがあれば、setTimeout関数で400mSec後に自分自身をコールします。
次に自身がコールされる時の引数は、渡された配列の2要素目以降です。
(shift後の配列なので最初の要素は無くなっていることに注意)


こうすると、関数内では最初の1要素だけ処理するで、OnClickイベントはすぐに終了します。
配列をshift()しているので、setTimeoutによって次回コールされたときは2要素目を処理します。
順々にshiftして行くことで、結果的に関数の呼出し毎に1データづつ、最後の要素まで処理できます。

この仕組では、1回のメソッド呼び出しが長時間占有することがない為、画面が操作できなくという問題も有りません。また、各データを処理する間隔は、setTimeoutで任意に指定が可能です。


下のサンプルを実行してどのような動きになるか、また、処理中にブラウザの操作が可能である事を確認してください。

sample3:setTimeoutで非同期処理を行う
テスト実行:




その4:非同期処理の関数に汎用性を持たせる

先ほどのプログラムで、とりあえずの目的を達成することは出来ました。
せっかく作った仕組みなので、もう少し汎用性をもたせるようにします。

先ほどの関数では、非同期実行の制御関数(getDataMain)からコールされる実処理(getDataDummy)が決め打ちになっているので、まずこれを解消させます。

function btnInput4_Click() {
    var params = [ "000000000001",
                   "000000000002",
                   // ... 1000件ぐらいの大量データ
                   "000000001000" ];
 
    // 配列のデータを非同期で実行する
    getDataMain4( params, getDataDummy4 );
 
    return false;
}
 
var paramList4;
var onProc4;
function getDataMain4( params, onProcess ) {
    onProc4 = onProcess;
    paramList4 = params.concat();   // 配列のコピーを作る
    var slipNo = paramList4.shift();
 
    onProcess( slipNo );
    if ( paramList4.length > 0 ) {
        setTimeout( "getDataMain4( paramList4, onProc4 )", 100 );
    }
}
 
function getDataDummy4( inputStr ) {
    // 今までと同じ処理なので省略.
}



Main側の第二引数で、実処理の関数を指定できるようにしました。



次に、onProcと、paramListがグローバル変数になっているのを止めます。

function btnInput5_Click() {
    var params = [ "000000000001",
                   "000000000002",
                   // ... 1000件ぐらいの大量データ
                   "000000001000" ];
 
    getDataMain5( params, getDataDummy5 );
    return false;
}
 
function getDataMain5( params, onProcess ) {
    var paramList = params.concat();    // 配列のコピーを作る
 
    var runAsync = function() {
        var slipNo = paramList.shift();
 
        onProcess( slipNo );
        if ( paramList.length > 0 ) {
            setTimeout( arguments.callee, 100 );
        }
    }
    runAsync();
}
 
function getDataDummy5( inputStr ) {
    // 今までと同じ処理なので省略.
}


getDataMain内で、ローカル関数のrunAsync()を作成することで、グローバル変数の生成を抑制しています。また、runAsync()はsetTimeoutで自分自身を呼び出す必要がありますが、自分自身のメソッドは"arguments.callee"で取得可能なので、setTimeoutの第一引数を変更しています。



さらに、runAsync()という変数自体も必要ないため、無名関数に置き換えてしまいます。

function btnInput6_Click() {
    var params = [ "000000000001",
                   "000000000002",
                   // ... 1000件ぐらいの大量データ
                   "000000001000" ];
 
    getDataMain6( params, getDataDummy6 );
    return false;
}
 
// 配列データを非同期で処理する
function getDataMain6( params, onProcess ) {
    var paramList = params.concat();    // 配列のコピーを作る
 
    (function() {
        var slipNo = paramList.shift();
 
        onProcess( slipNo );
        if ( paramList.length > 0 ) {
            setTimeout( arguments.callee, 100 );
        }
    })();
}
 
function getDataDummy6( inputStr ) {
    // 今までと同じ処理なので省略.
}





ループで処理していた場合と異なり、setTimeout方式だと、処理の終了が検出できなくなってしまいます。これを避けるために処理終了時のハンドラを設定できるようにします。

// 配列データを1秒周期で非同期処理する
function asyncProcArray( params, onProcess, onFinish ) {
    var paramList = params.concat();    // 配列のコピーを作る
 
    (function() {
        var slipNo = paramList.shift();
 
        onProcess( slipNo );
        if ( paramList.length <= 0 ) {
            onFinish();
            return;
        }
 
        setTimeout( arguments.callee, 1000 );
    })();
}



呼び元側は以下のような感じです。

function btnInput7_Click() {
    var params = [ "000000000001",
                   "000000000002",
                   // ... 1000件ぐらいの大量データ
                   "000000001000" ];
 
    asyncProcArray( params, getDataDummy7, finishProc7 );
    return false;
}
 
function getDataDummy7( inputStr ) {
    // 今までと同じ処理なので省略.
}
 
function finishProc7() {
    alert( "終了しました" );
}



これで、かなり汎用的になりました。



ところで、「毎秒1回」という呼び出し制限がない場合は?


前述の関数で、配列にセットされた大量データを、指定した時間周期でゆっくり処理させることが出来るようになりました。今回の問題に対しては前述の関数で十分ですが、大量データを非同期で素早く処理したい場合は、まだ問題があります。


何が問題なのかというと、前述のasyncProcArray()関数を使う場合、1要素処理するたびに必ず100ミリ秒待ことになります。この為、仮にデータが100件ある場合には最低でも10秒掛かってしまいます。これでは、例えばJavaScriptでゲームを作り100個のキャラクタをを同時に動かしたいといった場合、毎フレーム10秒掛かることになり、現実的な解決方法にはなりません。


せっかくここまで考えたので、ついでに上記の問題も解決できるバージョンを作ってみます。


その5:画面が固まることなく,大量データを素早く処理する


簡単な解決案としては、一回あたりの処理量をもう多くするという案があります。例えば、毎回1データづつではなく5個づつ処理すれば待ちは1/5になります。
方法は簡単ですが、各データの処理負荷に応じて1サイクルの時間が変わってしまうためあまり良い方法ではありません。
また、データによって処理負荷が可変となる場合にも問題があります。


個々の要素に対する処理負荷に依存しない方が潰しがきくので、そこまで考慮のが以下の関数です。

function runAcyncArray( params, onProcess, onFinish ) {
    var paramList = params.concat();    // 配列のコピーを作る
 
    (function() {
        var startTime = new Date(); // 開始時刻を覚える
 
        //-----------------------------------
        // タイムアウトになるまで処理を行う
        //-----------------------------------
        while ( 1 ) {
            var curParam = paramList.shift();
 
            //----------------------
            // 配列を1要素処理する
            //----------------------
            onProcess( curParam );
 
            if ( paramList.length <= 0 ) {
                //--------------------------------------
                // 全要素処理を行った -> 終了処理
                //--------------------------------------
                onFinish( params );
                return;
            }
 
            if ( (new Date()) - startTime > 100 ) {
                //---------------------------------------
                // タイムアウト発生 -> 一旦処理を終わる
                //---------------------------------------
                break;
            }
        }
        //----------------------------------
        // ちょっと待って続きの処理を行う
        //----------------------------------
        setTimeout( arguments.callee, 40 );
    })();
}



上記プログラムでは100ミリ秒経過するまで処理を行い、規定時間が経過したら40ミリ秒後に続きのデータを処理させています。一度に行う処理時間は余り多いと画面を操作する際の遅延が目立ってしまうので、50~100ミリ程度が適切です。

また、再実行までのウェイトは20ミリ以下だと、画面を描画する側の処理が十分に走りきらない場合があり、こちらも使い勝手の低下につながります。ですので余裕を見て40ミリ秒とっています。

同様の非同期処理が複数同時に走る場合は、もう少し多めにウェイトを入れたほうが適切となります。




まとめ


まとめると、大量データをJavaScriptで処理する場合は、以下の点に注意する必要があります。
  • javascriptで大量データを処理する場合、画面応答性を考慮すると全データを一度に処理はできない。
  • setTimeoutを利用することで、UI描画に処理を明け渡しつつデータ処理を継続させることが出来る。



また、上記問題を解消する為に、汎用性のある関数を2つ作成しました。

一定周期ごとに、配列の各要素を処理したい場合

// 配列データを1秒周期で非同期処理する
function asyncProcArray( params, onProcess, onFinish ) {
    var paramList = params.concat();    // 配列のコピーを作る
 
    (function() {
        var slipNo = paramList.shift();
 
        onProcess( slipNo );
        if ( paramList.length <= 0 ) {
            onFinish();
            return;
        }
 
        setTimeout( arguments.callee, 1000 );
    })();
}




画面が固まることなく、出来る限り速く大量のデータを処理したい場合

function runAcyncArray( params, onProcess, onFinish ) {
    var paramList = params.concat();    // 配列のコピーを作る
 
    (function() {
        var startTime = new Date(); // 開始時刻を覚える
 
        //-----------------------------------
        // タイムアウトになるまで処理を行う
        //-----------------------------------
        while ( 1 ) {
            var curParam = paramList.shift();
 
            //----------------------
            // 配列を1要素処理する
            //----------------------
            onProcess( curParam );
 
            if ( paramList.length <= 0 ) {
                //--------------------------------------
                // 全要素処理を行った -> 終了処理
                //--------------------------------------
                onFinish( params );
                return;
            }
 
            if ( (new Date()) - startTime > 100 ) {
                //---------------------------------------
                // タイムアウト発生 -> 一旦処理を終わる
                //---------------------------------------
                break;
            }
        }
        //----------------------------------
        // ちょっと待って続きの処理を行う
        //----------------------------------
        setTimeout( arguments.callee, 40 );
    })();
}

関連記事

コメントを残す

メールアドレスが公開されることはありません。