LoginSignup
7
5

More than 5 years have passed since last update.

Streamを撲滅するAsyncIteratorをNode.js v10未満で使う

Last updated at Posted at 2019-02-25

StreamとPromiseの世界をひとつにする革命的な機能、AsyncIteratorを知っていますか?

自分はこちらのスライドで知りました。
https://speakerdeck.com/ajido/callback-to-promise-and-beyond

Callbackを撲滅した後に _ Callback to Promise and beyond - Speaker Deck

Callbackを撲滅した後に _ Callback to Promise and beyond - Speaker Deck

AsyncIteratorは便利なのですが、残念なことにNode.js v10で導入された機能で、それ以上でしか使えません。

しかし、Google Cloud Functionsなど多くのPaaS / FaaSではまだNode.js v6やv8のランタイムしか使用できず、そのままではAsyncIteratorが使用できませんでした。

そこで、Node.js v10未満でもAsyncIteratorを使用できるようにする方法を考えます。

登場する単語などはJavaScriptのIterator / Generatorに関する記事を参照してください。
オススメは以下です。

前提

  • Node.js v6以上( readable-stream v3の対応に合わせて)
  • 実行はDockerの node:6node:8 上で行ってテスト
  • BabelもしくはTypeScriptなどを利用して、下位バージョンでも動作する構文にトランスパイルする(使用するラインタイムに合わせて適当なターゲットを設定)
  • 設定ファイルの参考は以下
.babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "6"
        }
      }
    ]
  ]
}
tsconfig.json
{
  "compilerOptions": {
    "lib": [
      "dom",
      "es6",
      "dom.iterable",
      "scripthost",
      "esnext.asynciterable"
    ],
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "outDir": "lib",
    "sourceMap": true,
    "strict": true,
    "target": "es2015"
  },
  "compileOnSave": true,
  "include": [
    "src"
  ]
}

ゴール

axios を使って画像をダウンロードし、AsyncIterator( for await...of )とAsync / Await で制御しつつ、Node.js v6以上で動作させる。

Polyfillを利用する

最初に思いつく対応策はPolyfillです。

Polyfillのパッケージとしては @babel/polyfill が有名です。
これは内部的には core-js に依存しており、実質同じものですのでどちらを利用しても構いません。

実際にPolyfillを利用することで、自分で実装したAsyncIteratorには対応することができます。

import "core-js/modules/es7.symbol.async-iterator";
// or
// import "@babel/polyfill";

function* IterableIterator() {
  yield 1;
  yield 2;
  yield 3;
}

(async () => {
  for await (const chunk of IterableIterator()) {
    console.log(chunk);
  }
})();

Polyfillを読み込まなかった場合は以下のようなエラーが発生しますが、読み込ませると発生しません。

Polyfillなし
$ node lib/index.js
(node:12) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): TypeError: Symbol.asyncIterator is not defined.
Polyfillあり
$ node lib/index.js
1
2
3

readable-stream を使用して標準Streamを置き換える

Polyfillを読み込ませることでGeneratorで作成した独自のIterableなIteratorを for await...of で制御することはできるようになりました。

しかし多くの場合、AsyncIteratorを使用したいのはStreamでの非同期処理をシンプルにしたいという動機かと思います。

AsyncIteratorを使用するためには、オブジェクトが [Symbol.asyncIterator] メソッドを持っている必要があります。
Node.jsのコアAPIであるStreamは、Node.js v10未満では [Symbol.asyncIterator] メソッドを持っていません。これが最大の問題です。

readable[Symbol.asyncIterator]()

この問題に対処するために readable-stream パッケージを使用します。

これは、Node.jsオフィシャルのGitHubで管理されているパッケージで、Node.jsのStreamをNode.jsのバージョンとは切り離して使用できるようにするものです。

これを普段 import * as stream from "stream"; とする代わりに import * as stream from "readable-stream"; と記述して使用します。

import "core-js/modules/es7.symbol.async-iterator";
// or
// import "@babel/polyfill";

// import * as stream from "stream";
import * as stream from "readable-stream";

const IterableIterator = new stream.Readable({
  read() {
    this.push("hello");
    this.push(null);
  }
});

(async () => {
  for await (const chunk of IterableIterator) {
    console.log(chunk.toString());
  }
})();

一見、この readable-stream だけあればPolyfillは必要ないように思えるかもしれませんが、そもそもNode.js v9未満では [Symbol.asyncIterator] などのメソッドに対応していないため、これを @@asyncIterator に変換しなければいけないそうです。

* すみません、ここは理解が曖昧です

実際Polyfillなしで実行すると以下のようなエラーが発生します。

Polyfillなし
$ node lib/index.js
(node:42) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): TypeError: Symbol.asyncIterator is not defined.

まとめると以下の表のようになります。
どちらも必要ということですね。

~ readable-stream: enable readable-stream: disable
core-js: enable / Node.js: >=10 👍 👎
core-js: disable 👎 👎

外部パッケージで readable-stream を使う

さて、ゴールとして実行したいのは axios パッケージを使用してダウンロードすることでした。
先ほどのように自分でStreamを作成する場合は readable-stream のAPIを使用すればAsyncIteratorを使用することができますが、外部パッケージの場合はどうしたらいいでしょうか。

最初はパッケージをフォークして、ソースコードを修正しなければいけないかと思ったのですが、もっと簡単な方法がありました。

AsyncIteratorを使用したいStreamに [Symbol.asyncIterator] があればいいので、 readable-stream から読み込んだ stream.Readable.prototype[Symbol.asyncIterator] を代入してあげれば readable-stream を使用してStreamを作成した場合と同様になります。

複雑なようですがコードは単純です。

yourReadableStream[Symbol.asyncIterator] = stream.Readable.prototype[Symbol.asyncIterator]

ゴールのコードの全体は以下のようになります。

import "core-js/modules/es7.symbol.async-iterator";
// or
// import "@babel/polyfill";

import * as fs from "fs";
import * as stream from "readable-stream";
import axios from "axios";
import * as mime from "mime";

const download = async (url: string) => {
  const response = await axios({
    method: "get",
    url,
    responseType: "stream"
  });

  response.data[Symbol.asyncIterator] =
    stream.Readable.prototype[Symbol.asyncIterator];

  const file = `file.${mime.getExtension(response.headers["content-type"])}`;
  const ws = fs.createWriteStream(file);

  for await (const data of response.data) {
    ws.write(data);
  }
};

(async () => {
  await download("https://avatars1.githubusercontent.com/u/3258736?v=4");
})();

まとめ

以下の手順で処理することで、Node.js v6 - v9でもAsyncIteratorを実行することができるようになりました。

  1. readable-stream でStream APIに [Symbol.asyncIterator] メソッドを追加する
  2. core-js[Symbol.asyncIterator]@@asyncIterator に変換する
  3. BabelやTypeScriptなどのトランスパイラで for await...of などの未対応の糖衣構文を下位バージョンでも実行できる構文に変換する

個人的には、今後のStreamはすべてAsyncIteratorで記述したいと思っているので、様々な環境で実行できるようになって満足です。

謝辞

GitHub Issuesにて質問に答えていただいたNode.jsコミッターのmcollinaさん、ありがとうございました!
https://github.com/nodejs/readable-stream/issues/400

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5