Generator in Practice

[1部]基本的なプロパティとRunner

はじめに

近年、JavaScriptプログラミングのパラダイムに大きな変化を与えたスペックは、まさにGeneratorでしょう。2016年2月に米国サンフランシスコで開かれたFORWARDJS4カンファレンスでES6とES7についてトピックがありました。(https://javascriptair.com/episodes/2016-02-10/

このとき、パネラーが自分の好きなスペックを話す機会がありましたが、JavaScript講師として有名なKyle Simpsonは、Generatorを挙げました。非同期プログラミングのパラダイムを変えたことがその理由でした。他のパネラーに比べて強い確信を持って長時間、説明していましたが、当時は特に気にかけずスルーしていました。

しかし、現在進行中のプロジェクトにGeneratorを適用してみると、純粋スクリプトまたはコールバック方式での実装が必要となるケースで、本当に効果を実感します。

この記事では、Generatorの基本的なスペックを説明するよりも、Generatorの特徴と実務上の活用方法について取り上げたいと思います。1部では、基本的なプロパティと、これを扱うためのRunnerについて紹介します。2部では、実際のプロジェクトの要求事項に基づいてCallback、Promise、Generatorでコードを作成して、比較解析、エラー処理、応用事例、適用方法について紹介します。
もしGeneratorの基本スペックが気になる場合は、以下の文書をご参照ください。

基本的なプロパティ

Generatorは、実行中に一時停止が可能な関数です。ES6で初めて導入され、2016/7/31時点では、Safariを除くモダンブラウザに対応しています。

Generatorが登場する前は、JavaScriptのプロセスを一時停止できる唯一の方法は、alert、confirm、promptを使用することでした。しかし、この方法では、ユーザーがシステムのダイアログボックスに応答しないプロセスを継続することができません。
一方、Generatorは一時停止と再実行をスクリプトで制御できます。Generator関数を実行すると、nextメソッドのiteratorが返却されます。返却されたiteratorのnextメソッドを実行すると、yieldキーワードに会うまでコードを実行し、一時停止します。再びnextを実行すると、次のyieldキーワードまでのコードを実行しますが、このプロセスを関数が終了するまで繰り返すことができます。さらにyield、nextが実行されるとき、互いにデータを送受信できます。

これらの特徴から、Lazy Evaluationなど多くのパターンを簡単に実装できるようになりましたが、やはり最大の変化は、非同期コードを同期コードのように作成できようになったという点でしょう。

Runner

Generator関数内のすべてのyieldキーワードに合わせてnextを作成し、直接実行する方法でも制御できます。しかし、一般的にはこのように使用せずに、返却されるiteratorに対して、終了するまで自動でnextを実行するオブジェクトを作成して利用しています。

このオブジェクトがRunnerです。Runnerに機能を追加して、Go言語のClojurescriptのCSPを真似たり、Coroutineという名前をつけて使用することもあります。しかし、基本的な原理は単純で、iteratorの終了時まで持続してnextを実行します。

簡単なサンプルとして、数字がyieldingされると、2倍に返却するRunnerを作ってみます。

function run(gen) {
  const iter = gen();

  (function iterate({value, done}) {
    if (done) {
      return value;
    }

    if (typeof value === 'number') {
      iterate(iter.next(value * 2));
    } else {
      iterate(iter.next(value));
    }
  })(iter.next());
}

上記のRunnerは、次のGeneratorを処理できます。

function* twoTimesNumber() {
  console.log(yield 10);
  console.log(yield 'hello');
}

run(twoTimesNumber);
// 20
// 'hello'

run(twoTimesNumber)を実行すると、プロセスの制御権がRunnerに移ります。Runnerはiteratorが終了するまで、すべてのyieldingを処理し、number型の値に会うと、実装したRunnerの動作「数字がyieldingされると、2倍」を実行します。

Promise Runner

GeneratorとRunnerで「非同期コードを同期コードのように作成できる」ことが分かるように、次のようなサンプルを作ってみました。
「Promiseをyieldingすると、結果が来るまで待ってから返却するRunner」を作成します。

function run(gen) {
  const iter = gen();

  (function iterate({value, done}) {
    if (done) {
      return value;
    }

    if (value.constructor === Promise) {
      value.then(data => iterate(iter.next(data)));
    } else {
      iterate(iter.next(value));
    }
  })(iter.next());
}

上記のRunnerは次のGeneratorを処理します。

function* getLatestData() {
  var promise = new Promise(resolve => {
    $.get({url: '/api/latest-data', success: function(data) {
      // Resolve Promise
      resolve(data);
    }}
  });

  const data = yield promise;

  console.log(data);
}
run(getLatestData);
// log data

jQueryのPromiseはネイティブPromiseではないため、残念ながら非同期コードの感覚を完全に取り払うことができません。しかし、非同期通信を行うAPI自体でネイティブPromiseを返してみると、どうなるでしょう?

function* getLatestData() {
  const data = yield axios.get('/api/latest-data');
  console.log(data);
}
run(getLatestData);
// log data

AxiosはPromise based HTTP clientです。axios.getメソッドは最初の引数のURLでGET要求を送信し、結果が得られるPromiseを返却します。

このネイティブPromiseをyieldingすると、Runnerがこれを受け取り、Promiseがresolveされるnextメソッドの値を載せて実行します。
結果として、getLatestDataは正常に動作します。次にコールバックのパターンと比べてみましょう。

function getLatestData() {
  $.get({
    url: '/api/latest-data',
    success: function(data) {
      console.log(data);
    }
  });
}
getLatestData();
// log data

サンプルコードは、1件の非同期通信を実行しています。サービスの要件を満たすため、2件のAPI通信を順次実行する必要があるとしたら、どうするのが良いでしょう?API 1を呼び出して応答を受信した後、API 2の要求を実行する必要がある、と仮定して考えます。

コールバックパターン

function getData() {
  // API 1
  $.get({
    url: '/api/a',
    success: function(data) {
      console.log('API1 done');

      // API 2
      $.get({
        url: '/api/b',
        success: function(data2) {
          console.log('API2 done');

          // Result
          console.log(data2);
        }
      });

    }
  });
}
getData();
// API1 done
// API2 done
// log data

Runnerパターン

function* getData() {
  // API 1
  let data = yield axios.get('/api/a');
  console.log('API1 done');

  // API 2
  data = yield axios.get('/api/b');
  console.log('API2 done');

  // Result
  console.log(data);
}
run(getData);
// API1 done
// API2 done
// log data

GeneratorとRunnerを使ったものの、非同期コードを同期コードのように作成して実装することができました。

結論

このように手軽に利用できる機能を、なぜここではiteratorを返してnextで制御するように作成したのでしょうか?このような要件を反映して、ES7スペックでは、async、awaitというキーワードが追加されました。*の代わりにasyncで宣言された関数では、awaitにPromiseが来ると、先に紹介したPromise Runnerと同じように動作します。

async function getData() {
  const data = await axios.get('/api/a');
}

GeneratorはLow Level APIです。Promiseの他にも、他の値を処理することもでき、コストのあるリソースを読み取るコードを必要な時点で実行するLazy evaluationにも応用ができます。最初、Generatorを使ってみたとき、async / awaitより廃れた感じを受けましたが、今ではRunnerと組み合わせて、より使いやすいパターンになっています。(モダンブラウザに対応している点も評価できます。async / awaitは、現在Microsoft Edgeの最新バージョンにのみ対応しています。)

[2部]実際の使用例

要件とAPIのリスト

TOASTドライブプロジェクトの仕様で、Generator適用効率が最も高いものを若干修正しました。ファイルまたはフォルダを選択して、別のフォルダに移動したときの仕様です。

以下は、Webベースのファイルシステム実装プロジェクトの一部機能である。

ファイルリストから選択した多数のファイルやフォルダを他のフォルダに移動できる。
このとき、移動先のフォルダに同じファイル名やフォルダ名が存在する場合、すべての件に対してユーザーに確認する。

例えば、「新しいフォルダ」という名前が重複した場合、「移動先のフォルダに同じ名前のフォルダがあります。
[新しいフォルダ(1)]に変更して移動しますか?」という確認用のデザインレイヤを表示する。

「確認」を押すと、新しい名前で移動させる。 このとき「以降すべての項目に適用」
チェックボックスを押すと、その後、重複するものはすべて「確認」処理する。

「キャンセル」を押すと、そのファイルに対して取り消す。 同じく「以降すべての項目に適用」
チェックボックスを押すと、その後、重複するものはすべて「キャンセル」処理する。

ユーザーがすべての重複に対して確認すると、すべて移動処理する。

サーバーから提供するAPIのリスト

GET  '/api/check-duplication'
[名前の重複チェック]
 - 特定フォルダのidと生成する名前を伝達
 - 重複した場合は使用可能な新しい名前とともに409応答
 - 可能な場合、200応答

POST '/api/move'
[ファイルとフォルダの移動]
 - 移動対象ファイルとフォルダのidリストと移動対象フォルダのid伝達
 - エラーがなければ、すべての移動を処理した後、200応答

[ファイルやフォルダの移動]APIで重複を一緒に処理するのが理想的ですが、この記事では重要な部分ではないので、当面は2つのAPIを使用します。

また、実際のAPIは、移動の結果をWebソケットを通じてクライアントに通知しますが、説明を簡略化するため、単一のHTTPリクエスト、レスポンスを利用します。

分析

まず、現在選択されたファイルやフォルダ(以下「オブジェクト」という)の名前と移動先のフォルダのidを参照して、[名前の重複チェック]APIを呼び出します。200応答を受信すると、[ファイルとフォルダの移動]APIを呼び出せばよいですが、重複エラーがある場合、すべての重複について、ユーザーの応答を収集する必要があります。

すべての名前の重複についてユーザーの応答を収集する際、window.confirmはプロセスが停止するため、すべての項目に対して簡単にユーザーの応答を収集できます。

しかし、「以降すべての項目に適用」というチェックボックスの機能をサポートするため使用できず、結局、別途「デザインレイヤ」を実装する必要があります。この「デザインレイヤ」で、ユーザーが応答するまで待つ行為も一種の非同期処理のため、実装しにくい部分です。

整理すると、「名前の重複確認」、「重複するすべての項目のユーザー応答を収集」、「オブジェクトの移動」の非同期処理を順次実行する必要があります。

Callback

要件の「デザインレイヤ」をコールバックパターンで実装すると、次の通りです。jsConfirmは、元のメッセージを受け取って表示すべきですが、重要ではないので処理したと考えます。

<div id="layer">
  <input type="checkbox" id="checkbox" />
  <button type="button" id="ok">ok</button>
  <button type="button" id="cancel">cancel</button>
</div>

<script>
const layer = $('#layer');
const checkbox = $('#checkbox');

/**
 * @param {string} msg - confirmに出力するメッセージ内容
 * @param {function} cb - callback関数
 */
function jsConfirm(msg, cb = () => {}) {
  checkbox
    .prop('checked', false);

  layer
    .show()
    .on('click', ev => {
      const target = $(ev.target);
      const checked = checkbox.prop('checked');

      if (target.is('#ok')) {
        layer.hide().off();
        cb({confirmed: true, checked});
      } else if (target.is('#cancel')) {
        layer.hide().off();
        cb({confirmed: false, checked});
      }
    });
}
</script>

次は重複について、繰り返しユーザーへ確認しなければなりません。まず、名前の重複データのMockを用意します。このオブジェクトは、[名前の重複チェック]APIの応答であると仮定したオブジェクトです。

// [名前の重複チェック]APIの応答データ(対象の名前、使用可能な名前)
const dupList = [
  ['foo', 'foo(2)'],
  ['bar', 'bar(2)'],
  ['baz', 'baz(2)']
];

単にユーザーの応答を積むため、コールバックを再帰的に構成すればよいでしょう。しかし、コールバックを直接作成するとハードコーディングになるので、動的な重複リストに対応できません。したがって、再帰を使用します。

// この変数に確認項目を集める。
let resolved = [];

function resolveDuplicates(idx) {
  const item = dupList[idx];

  jsConfirm(`msg${idx}`, ({confirmed, checked}) => {
    if (confirmed) {
      // A '確認'
      resolved.push(item);
    }

    idx += 1;

    if (checked) {
      if (confirmed) {
        // B '以降すべてに項目に適用', '確認'
        resolved = [...resolved, ...dupList.slice(idx)];
      } else {
        // C '以降すべてに項目に適用', 'キャンセル'
      }
      return;
    }

    if (!dupList[idx]) {
      // D すべての項目完了
      return;
    }

    resolveDuplicates(idx);
  });
}
resolveDuplicates(0);

まだAPIの呼び出し処理は始まってもいません。[名前の重複チェック]APIを先に呼び出した後、結果に基づいてresolveDuplicatesを実行するため、上記はすでにdepthが増加した状態であると予想できます。

また、コードのA、B、C、Dの部分でも、API呼び出しのコールバックが追加されるでしょう。関数を抽出する方法などのリファクタリングをするにしても、コールバックのパターンには限界があります。作成されたコードは分かりにくくメンテナンスが難しいです。

Promise

コールバックのネスト問題はPromiseを使うと、ある程度解決できます。Promiseとは未来の任意の値を受け取ることができるオブジェクトです。Kyle Simpsonはハンバーガーを注文して受け取る待機票だと例えましたが、面白いだけでなく正確な比喩と言えるでしょう。Promiseは、任意の1つの値が取得できることを保証するインターフェイスです。

「デザインレイヤ」の確認、キャンセルもこのPromiseを使用できます。「デザインレイヤ」がPromiseを返却するように実装してみます。この課題は、この記事の最後にGeneratorを使ったコードを作成するための基礎でもあります。

/**
 * @param {string} msg - confirmに出力するメッセージ内容
 * @returns {Promise}
 */
function jsConfirm(msg) {
  // Return Promise
  return new Promise((resolve, reject) => {
    checkbox
      .prop('checked', false);

    layer
      .show()
      .on('click', ev => {
        const target = $(ev.target);
        const checked = checkbox.prop('checked')

        if (target.is('#ok')) {
          layer.hide().off();
          resolve({confirmed: true, checked});
        } else if (target.is('#cancel')) {
          layer.hide().off();
          resolve({confirmed: false, checked});
        }
      });
  });
}

jsConfirmはPromiseオブジェクトを返却します。ユーザーの応答をオブジェクトにしてresolveを実行しています。Promiseの基本スペックについては、以下のリンクを紹介します。

これを基に重複チェックをPromiseにリファクタリングしてみましょう。リストについて順次jsConfirmを呼び出す必要があるため、Promiseを貼り付ける方法で実装します。この方法は、配列の要素に対してPromiseを順次処理する際に有用です。

let resolved = [];

dupList.reduce((promise, item, idx) => {
  return promise.then(() => {
    return jsConfirm().then(({confirmed, checked}) => {
      if (confirmed) {
        // A '確認'
        resolved.push(item);
      }

      idx += 1;

      if (checked) {
        if (confirmed) {
          // B '以降すべての項目に適用', '確認'
          resolved = [...resolved, ...dupList.slice(idx)];
        } else {
          // C '以降すべての項目に適用', 'キャンセル'
        }

        // Promiseチェーンを完全に脱却するため
        throw new Error('checked');
      }
    });
  });
}, Promise.resolve()).then(() => {
  // D すべての項目完了
  console.log(resolved);
});

前より見やすくなりましたか?個人的に大きく変わった点はないように見えます。再帰コードがなくなり、すべての項目の完了処理を最後のthenチェーンでできることと、catchを使用できるようになった程度ですね。catchはPromiseチェーンのどこからでも発生したエラーが集まり、ユーザーのエラー、XHRエラーなどを一か所で処理できます。

実際のところ、まだ見やすい方法ではありません。Generatorのスペックが登場するまでは、この方法が最善でしたが、現在はそうではありません。リファクタリングしてみましょう。

Generator

1部で使ったPromise Runnerと、前章で実装したPromise base jsConfirmをそのまま使います。

run(function*() {
  let resolved = [];

  for (let i = 0, len = dupList.length; i < len; i += 1) {
    const item = dupList[i];
    const {confirmed, checked} = yield jsConfirm();

    if (confirmed) {
      // A 確認
      resolved.push(item);
    }

    if (checked) {
      if (confirmed) {
        // B '以降すべての項目に適用', '確認'
        resolved = [...resolved, ...dupList.slice(i + 1)];
      } else {
        // C '以降すべての項目に適用', 'キャンセル'
      }

      break;
    }
  }

  // D すべての項目完了
  console.log(resolved);
});

Promise Runner関数でjsConfirmが返却されるPromiseの応答が来るまで一時停止できるようになりました。一般的なforループでも非同期処理ができます。

コードのコメント表記は、A、B、C、Dに分けていますが、結果的にDに収束するので、[ファイルとフォルダの移動]APIは、Dポジションで一括して要求できます。引き続きAPI呼び出しまで実装してみましょう。

function* moveObjects(destId, targetIdList) {
  // [名前の重複チェック]API呼び出し
  const dupList = yield axios.get('/api/check-duplicate?dest...');

  // [重複するすべての項目のユーザー応答収集]
  let resolved = [];
  for (let i = 0, len = dupList.length; i < len; i += 1) {
    const item = dupList[i];
    const {confirmed, checked} = yield jsConfirm();

    if (confirmed) {
      // A 確認
      resolved.push(item);
    }

    if (checked) {
      if (confirmed) {
        resolved = [...resolved, ...dupList.slice(i + 1)];
      }

      break;
    }
  }

  // [個体移動]API呼び出し
  yield axios.post('/api/move', {resolved, /* request data */});
}

エラー処理

要件をmoveObjects Generator関数1つで実装しました。Callback、Promiseよりもはるかに簡単に実装できましたね。エラー処理はtry…catchを使用すればよいでしょう。実行を保証するfinallyはおまけです。

function* moveObjects(destId, targetIdList) {
  try {
    /* 実装 */
  } catch (err) {
    // API呼び出しエラー処理
    // その他エラー処理
  } finally {
    // 必要なら実装(Reactの場合、以前の状態で復元など...)
  }
}

実際のエラー処理では、runコードは若干修正が必要です。1部で紹介したPromise Runnerを修正してみましょう。

function run(gen) {
  const iter = gen();

  (function iterate({value, done}) {
    if (done) {
      return value;
    }

    if (value.constructor === Promise) {
      value
        .then(data => iterate(iter.next(data)))
        .catch(err => iter.throw(err));  // ここが追加されたコード
    } else {
      iterate(iter.next(value));
    }
  })(iter.next());
}

エラー処理コードは、同じスコープコードまで有効です。非同期コードは、現在のスコープ以降のスコープ、またはフレームで実行されるため、非同期コードの外側でエラーをキャッチするには、直接伝達する必要があります。

try{
  jQuery.ajax({
    success: () => {
      // ここでエラーが発生しても
    }
  });
} catch (err) {
  // ここまでキャッチできない。
}

run内部のiterate実行タイミングとPromise処理コードがその例です。そこでPromiseのエラーを直接Iteratorのthrowに転送するように修正しました。これでエラー処理を含めてGeneratorを扱うことができるようになりました。

使ってみる

サービスのサポート範囲は、Chrome、Firefoxの最新バージョンであればすぐに使用できます。そうでない場合は、トランスコンパイラを使用する必要があります。2016/8/8時点で、GeneratorをES5基盤のコードに変換するトランスコンパイラはFacebookのRegeneratorのみです。Regeneratorはruntimeとtranspilerで構成されていますが、runtimeは圧縮時1KB未満なので負担がありません。

すでにサーバーに多くのJavaScriptコードがあり、このうちGeneratorを適用したいコードがある、と仮定しましょう。

// サービスコード
<script>
  // ネームスペース設定
  window.ne = {toastDrive: {}};
</script>
<script src="src/js/generators.js"></script>
<script>
  ne.toastDrive.logList(['a', 'b']);
</script>

下のgenerators.src.jsは、変換前にGenerator関数が集まっているファイルです。

// src/js/generators.src.js

function run(gen) {/* runner */}

ne.toastDrive.logList = function(dupList) {
  run(function*() {
    for (let item of dupList) {
      console.log(item);
      yield item;
    }
  });
};

Regeneratorを利用して、このファイルを変換します。

// regeneratorインストール
npm install -g regenerator

// --include-runtimeから依存モジュールを含めて変換
regenerator --include-runtime src/js/generators.src.js > src/js/generators.js

こうすると、既存のコードを維持しながら、必要な部分だけGeneratorを使用できます。Regenerator実行スクリプトを配布時に自動実行するように設定すると、手動で配布前に実行する必要がありません。

結論

Generatorを使うと「非同期コードを同期コードのように」作成できます。複雑な非同期フローを分かりやすく作成することができ、フローが複雑であればあるほど、高い効果が得られます。

Promise、Generator、BabelJSの概念と使い方を身につける必要がありますが、十分価値はあるでしょう。この記事で簡単に取り上げたCallback、Promise、Generatorの順序パターンの登場背景と短所、これらがもたらす利点を把握すれば、どのようなJavaScriptの要件も難なく解決できることでしょう。

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop