StreamとPromiseの世界をひとつにする革命的な機能、AsyncIteratorを知っていますか?
自分はこちらのスライドで知りました。
https://speakerdeck.com/ajido/callback-to-promise-and-beyond
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:6
とnode:8
上で行ってテスト - BabelもしくはTypeScriptなどを利用して、下位バージョンでも動作する構文にトランスパイルする(使用するラインタイムに合わせて適当なターゲットを設定)
- 設定ファイルの参考は以下
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "6"
}
}
]
]
}
{
"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を読み込まなかった場合は以下のようなエラーが発生しますが、読み込ませると発生しません。
$ node lib/index.js
(node:12) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): TypeError: Symbol.asyncIterator is not defined.
$ 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-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なしで実行すると以下のようなエラーが発生します。
$ 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を実行することができるようになりました。
-
readable-stream
でStream APIに[Symbol.asyncIterator]
メソッドを追加する -
core-js
で[Symbol.asyncIterator]
を@@asyncIterator
に変換する - BabelやTypeScriptなどのトランスパイラで
for await...of
などの未対応の糖衣構文を下位バージョンでも実行できる構文に変換する
個人的には、今後のStreamはすべてAsyncIteratorで記述したいと思っているので、様々な環境で実行できるようになって満足です。
謝辞
GitHub Issuesにて質問に答えていただいたNode.jsコミッターのmcollinaさん、ありがとうございました!
https://github.com/nodejs/readable-stream/issues/400