NodejsのPromiseで再帰呼び出しを行う!JavaScriptの非同期処理がおもしろい。
こんにちは!
JavaScriptの週末プログラミングが面白すぎて、夜も眠れない、しずかなかずしです。
みなさんは、JavaScriptのPromiseという仕組みをご存知ですか??
元々は、サーバーサイドでJavaScriptを使う目的で登場したNodejs。このプラットフォームのために定義されたNodejsのAPIの数々は、なんでもかんでも非同期の形式なのです。
非同期、つまり、APIの一つの関数は、呼び出した直後には結果が得られないような形式。
プログラマーが関数呼び出しの結果を受け取るために、APIの関数呼び出し時に自前のコールバック関数を登録する必要があります。結果は、API内部からコールバック関数が呼び返されてはじめて得られる、という仕組み。
単にファイルをオープンするだけの処理も非同期。逆に、あえて同期(つまり、呼び出したら関数の戻り値として、結果が返る)関数は、関数名にSyncというキーワードが含まるれる、という徹底ぶり。
JavaScript固有の変数のスコープ(クロージャー)の考え方と、名前をつけなくても関数が定義できる「無名関数」という仕組みが、私のような古風なC言語世代にはとても斬新でした。
サーバーサイドで時間がかかる処理をなるべく待たせないという、このような設計上の工夫。これによって、パフォーマンスに対して有利でも、プログラム自体の見通しが悪くなることもしばしば。
そんな、見た目の悪い、コールバックスパゲッティコードを、わかりやすく書くことができる、目からウロコなテーマが、本日のネタ、Promiseなんです。
そして、週末プログラミングで、Promiseを使った関数を再帰的な呼び出しが必要になりました。
その結果のまとめ記事です。
非同期処理まとめ
まずは、非同期処理でコーティングするとどうなるのか、サンプルを使って見てみましょう。
サンプルとしては用意した課題は、非同期関数を3つ使った簡単なものです。
- ファイルa.txtを開く。開いた結果、file descriptor(fd)というファイル固有のIDを受け取る
- fdを使って、ファイルにhello world!を書く
- 開いたファイル(fd)を閉じる
1から3が全て非同期のAPIで実現するケースを考えます。Nodejsのfsオブジェクトを使ったものです。
実は、Nodejsには、これらをいっぺんにやってくれるAPIも用意されています(それも非同期)。ですが、ここではサンプルとして、あえて3つの非同期関数を連続して呼んでみます。
Callbackで普通に書く
上で述べた処理を、普通にコールバック関数を無名関数でコーティングすると、以下のようになります。
const fs = require('fs');
fs.open('a.txt', 'w', function(err, fd) {
if (err) {
console.log('cannot open file');
return;
}
fs.write(fd, 'Hello, world!', function(err) {
if (err) {
console.log('cannot write file');
return;
}
fs.close(fd, function(err) {
if (err) {
console.log('cannot close fd');
}
console.log('end!');
});
});
});
コールバック関数の中で次の処理、そのコールバックで次の処理と、関数がネストされていき、なんとも書きにくいし、読みにくいプログラムになってしまいます。
かんたんに中身の説明します。
まず、fs.open()でファイルを開きます。この関数は非同期なので、コールバック関数を引数に設定します。
コールバック関数のパラメータとしてfdが返ってくるので、それを使って、今度はfs.write()でテキストをファイル(fd)に書き込みます。この関数にも、コールバックを設定します。これは、データが書き終わった後に呼ばれるコールバックです。
その関数の中で、openしたfdを閉じるために、fs.closeを呼び出します。
この例は、処理が単純なのでまだ良いのですが、コールバック関数の中の処理が複雑になってくると、何してるのかわからなる事態は、容易に想像できます。
Promiseを使って書く
Promiseの場合は、コールバック関数を登録する代わりに、APIがPrimiseのオブジェクトを返します。
その戻りオブジェクトのthen()の中で、次の処理をするための関数を呼び出します。この関数もPromiseオブジェクトを返すので、さらに、続けてthen()を呼んで、といった感じで非同期の処理を順番に呼び出して行きます。
実際の関数ソースです。
my_open('a.txt').then(function(fd) {
return my_write(fd, 'Hellow, world!');
}).then(function(fd) {
return my_close(fd);
}).then(function() {
console.log('end!');
}).catch(function(err) {
console.log(err);
});
コールバックを使った場合と比較すると、とてもシンプルです。
なんでこんなかんたんに書けるのか?
これこそが、Promiseの仕業なのです!
それでは上記のコードで使っている、my_open, my_write, my_closeの実装を見てみましょう。
オリジナルのnodejsの、コールバック関数を引数に取るAPIを、それぞれpromiseで書き直したものがこれです。
const fs = require('fs');
function my_open(filename) {
return new Promise(function (resolve, reject) {
fs.open(filename, 'w', function(err, fd) {
if (err) reject('cannot open file');
else resolve(fd);
})
})
}
function my_write(fd, str) {
return new Promise(function (resolve, reject) {
fs.write(fd, str, function(err) {
if (err) reject('cannot write file');
else resolve(fd);
});
});
}
function my_close(fd) {
return new Promise(function(resolve, reject) {
fs.close(fd, function(err) {
if (err) reject('cannot close fd');
else resolve();
});
});
}
本当は、上記と同じ処理をするPromiseタイプのAPIが既にあればよいのです。しかし、コールバックタイプの関数でも、こんな感じで書き換え可能なのです。
やり方としては、Promiseオブジェクトのコンストラクタに関数を書き、その中でコールバックタイプの関数呼び出しを実行。戻ってくるコールバックが正常だった場合は、resolve()を呼び、エラーだったらreject()を呼ぶ、といった感じ。定形的でシンプルです。
再帰呼び出しでやりたいこと
さて、Promiseがわかったところで、実際に私がやりたかった非同期処理に話を移します。
ある木構造の上から下に向かって階層を順繰りにたどり、階層毎に一つの処理を実行するようなケースを考えます。木構造の上の要素が、その直後に階層的に下の要素の処理結果に影響する、そんなケースです。
そして、各階層はデータベースにアクセスする処理で、非同期に実行することが期待です。
このようなケースでは、各階層で行う処理は、対象となるデータが異なるものの、処理の中身は毎回一緒。そして、とある階層の処理を実行するためには、前の階層の処理結果を使用します。
条件が成立するまで、木構造の階層をたどって同じ処理を実行し続けます。
こういった処理を関数の再帰呼び出しで実行してしまおう、というのがお題。そして、関数は非同期に結果が返るので、Promiseの登場となるのです。
Promise再帰呼び出しコード例
上記のケースを想像しつつ、話を簡単にするために処理の流れだけを中心にコードを書きます。
単純化しているため、これだけのことなら、ループ回せば事足りると思うかも知れません。
しかし、本来は上述のように、一つ前の結果が次の処理の入力になる仕事をするのが目的です。そのつもりでコードを読んで下さい。
var fs = require('fs');
// prepare data
var test_array = ['Hello', 'World', '!', 'Hello, world!'];
// call recursive function with array
recursive_function(test_array).then(() => {
console.log('finish');
}).catch((e) => {
console.log('err: '+e);
})
// processing just one item on the array asynchronously.
function process_one_item(param) {
return new Promise((resolve, reject) => {
fs.writeFile('a.txt', param+'\n', {flag:'a+'}, (err) => {
if (err) reject(err);
else resolve();
});
});
}
// function that called recursively
function recursive_function(array) {
return new Promise((resolve, reject) => {
if (array.length<1) {
resolve();
} else {
process_one_item(array[0]).then(() => {
// call next cycle with shifted array
array.shift();
recursive_function(array).then(() => {
resolve();
});
}).catch((e) => {reject(e);});
}
});
}
配列test_arrayを文字列で初期化していますが、これが木構造をたどるためのキーになるデータだと思って下さい。
一応、こんな感じのコードで期待通りに動作して、目的は果たせています。
しかし、なんとなく、もっといい方法がありそうな気がしています。気がついたらまた記事にします。
ということで、本日はここまで。最後まで読んで頂き、ありがとうございました!
Auto Amazon Links: プロダクトが見つかりません。 http_request_failed: 有効な URL ではありません。 URL: https://ws-fe.amazon-adsystem.com/widgets/q?SearchIndex=All&multipageStart=0&multipageCount=20&Operation=GetResults&Keywords=B07TK8JSKL|4295001139|B07QWJ564W|B07W6ZT6D5|B07WFJY3CF|B00N2S4L02&InstanceId=0&TemplateId=MobileSearchResults&ServiceVersion=20070822&MarketPlace=JP Cache: AAL_9033efddfabb39aec73ce3282e87950d