Feb 20, 2009

JavaScriptでsleepしたい、を実現する方法(require JavaScript 1.7)

中野さんが、JavaScriptにはsleep(一定時間待ってから次の処理に進むという命令文)が無いせいでテストを書くのに難儀したという話を書かれているけれども。まさにこれをどうにかしたくて、UxUは進化してきたようなものと言える。

知ってる人は知ってるだろうけど、Firefox/Thunderbirdアドオン向けの自動テスト実行ツール・UxUでは、テストを書く上でsleepに相当する機能を利用できる。これは、JavaScript 1.7でジェネレータ・イテレータ機能を実現するために追加されたyield式のトリッキーな使い方だ。

これを実現しているのは、lib/utils.jsdoIteration()と、test/test_case.jsrun()ということになる。ここから要点だけを取り出すと、以下のような事をしている。

まず、スクリプトの書き手は、「sleepを使いたい処理」を関数として定義する。この時、sleepの代わりにyieldを使う。

var task = function() {
  doSomething1();
  yield 1000;
  doSomething2();
  yield 1000;
  doSomething3();
}

次に、スクリプトの書き手は、この関数を後述するdoIteration()に渡す。すると、doIteration()がよしなに計らって、「doSomething1()を実行した後、1000ミリ秒待って、doSomething2()を実行し、また1000ミリ秒待って、doSomething3()を実行する」という風な形で先程の関数の内容を実行する。

この時のdoIteration()の内容は、以下のような感じ。

function doIteration(aTask) {
  if (typeof aTask == 'function') {
    // 渡されたのが関数だったら、まず、評価した返り値を得る。
    aTask = aTask();
  }
  if (!aTask ||
      !('next' in aObject) ||
      !('send' in aObject) ||
      !('throw' in aObject) ||
      !('close' in aObject) ||
      aObject != '[object Generator]') {
    // 渡されたオブジェクトまたは関数の返り値が
    // ジェネレータ・イテレータではない場合、何もしない。
    return;
  }

  // ここからがミソ!

  // 全部の処理が終わったかどうか、を示すオブジェクトを定義
  var finishFlag = { value : false, error : null };
  var last = 0; // スリープ開始時点の時刻を保持する変数
  var sleep = 0; // スリープの長さ(秒数)を保持する変数
  var timer = window.setInterval(function() {
    if (
        // スリープの長さがちゃんと指定されていて
        sleep > 0 &&
        // スリープ開始時点からの経過時間がスリープとして
        // 指定された時間未満であれば
        (Date.now() - last) < sleep
       ) {
      // ここで処理を終えて、100ミリ秒後まで待つ。
      return;
    }
    // スリープとして指定された時間が経過したので、処理を進める。
    try {
      // 次にyieldが登場するまでの間の処理を実行。
      sleep = aTask.next();
      // next()の返り値はyield式に渡された値。
      last = Date.now(); // スリープ開始時刻を保持して
      return; // 処理を一旦終えて100ミリ秒後を待つ。
    }
    catch(e if e instanceof StopIteration) {
      // 最後のyield式の後の内容が実行されて、定義された関数の内容が
      // 最後まですべて実行されると、StopIteration例外が発生する。
      // よって、処理完了とみなす。
      finishFlag.value = true;
    }
    catch(e) {
      // それ以外の未知の例外が発生した時は、処理中断とする。
      finishFlag.error = e;
    }
    // 100ミリ秒ごとの繰り返し処理を停止。
    window.clearInterval(timer);
  }, 100);
  return finishFlag;
}

UxUの内部でやってる事は基本的にはこういう事。ただ、実際にはもうちょっと使い勝手をよくするために細かい処理が加わってる。

doIteration()が中で何をやってるのかを知らなければ、パッと見は、sleepという命令文の名前がyieldに変わっただけのようにすら見えるんじゃないだろうか。そこが、このやり方の狙いだ。スクリプトの書き手はタイムアウトだのコールバックだのといった難しい事を何も考えなくても良くて、単に「sleep文に相当する機能が加わったJavaScript」として好きなように処理を書く事ができる。テストを書くための工数が大幅に削減される(かもしれない)ので、テストを書くのが苦にならず、ばりばりテストを書けるようになる(はず)。その結果、充実したテストのおかげでより安心して開発に専念できるようになる(はず)。という理屈です。

ちなみに、勘のいい人は気付くだろうけど、setInterval()に渡している関数の冒頭に以下の内容を挿入すれば、doIteration()をいくらでも入れ子にできるようになる。

    if (
        typeof sleep == 'object' &&
        (!sleep.value || !sleep.error)
       ) {
      return;
    }
var task = function() {
  doSomething1();
  yield 1000;
  yield doIteration(function() {
          doSomething2();
          yield 500;
          doSomething3();
          yield doIteration(function() {
            doSomething4();
            yield 100;
            doSomething5();
          });
        });
  doSomething6();
};
doIteration(task);

さらに、こんなこともできる。

var task = function() {
  doSomething1();
  yield doIteration(function() {
          var flag = { value : false; }
          frame.addEventListener('load', function() {
            frame.removeEventListener('load', arguments.callee, false);
            flag.value = true;
          }, true);
          frame.contentDocument
               .defaultView
               .location.href = 'http://www.example.com/';
          yield flag;
          // フレームの読み込みが終わったらここに進む
          doSomething2();
        });
  doSomething3();
};
doIteration(task);

この辺をもっと簡単に書けるようにヘルパーメソッドを色々と整備したのがUxUのテスト実行環境、と思ってもらえれば大体それで正解です。

25日追記。他にも色々やり方があるようです。(どっちもMozilla限定だけど)

エントリを編集します。

wikieditish message: Ready to edit this entry.











拡張機能