NodejsのPromiseで再帰呼び出しを行う!JavaScriptの非同期処理がおもしろい。

2019年12月13日プログラミング

 

こんにちは!

 

JavaScriptの週末プログラミングが面白すぎて、夜も眠れない、しずかなかずしです。

 

みなさんは、JavaScriptのPromiseという仕組みをご存知ですか??

 

元々は、サーバーサイドでJavaScriptを使う目的で登場したNodejs。このプラットフォームのために定義されたNodejsのAPIの数々は、なんでもかんでも非同期の形式なのです。

非同期、つまり、APIの一つの関数は、呼び出した直後には結果が得られないような形式。
プログラマーが関数呼び出しの結果を受け取るために、APIの関数呼び出し時に自前のコールバック関数を登録する必要があります。結果は、API内部からコールバック関数が呼び返されてはじめて得られる、という仕組み。

 

単にファイルをオープンするだけの処理も非同期。逆に、あえて同期(つまり、呼び出したら関数の戻り値として、結果が返る)関数は、関数名にSyncというキーワードが含まるれる、という徹底ぶり。

 

nodejs-fs-open-definition
nodejs v13のAPI documentより、fs.open()関数定義。

 

JavaScript固有の変数のスコープ(クロージャー)の考え方と、名前をつけなくても関数が定義できる「無名関数」という仕組みが、私のような古風なC言語世代にはとても斬新でした。

 

サーバーサイドで時間がかかる処理をなるべく待たせないという、このような設計上の工夫。これによって、パフォーマンスに対して有利でも、プログラム自体の見通しが悪くなることもしばしば。

 

そんな、見た目の悪い、コールバックスパゲッティコードを、わかりやすく書くことができる、目からウロコなテーマが、本日のネタ、Promiseなんです

 

そして、週末プログラミングで、Promiseを使った関数を再帰的な呼び出しが必要になりました。

その結果のまとめ記事です。

 

 

非同期処理まとめ

まずは、非同期処理でコーティングするとどうなるのか、サンプルを使って見てみましょう。

サンプルとしては用意した課題は、非同期関数を3つ使った簡単なものです。

 

  1. ファイルa.txtを開く。開いた結果、file descriptor(fd)というファイル固有のIDを受け取る
  2. fdを使って、ファイルにhello world!を書く
  3. 開いたファイル(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を文字列で初期化していますが、これが木構造をたどるためのキーになるデータだと思って下さい。

 

process_one_item()という関数が一つのデータを処理する非同期関数です。本来は、この中でデータベースにアクセスするような非同期の処理を実施します。このサンプルコードでは、単にファイルにテキストを追記する処理を非同期に実行しているだけです。
recursive_function()が、再帰的に呼び出す関数です。したがって、recursive_function()の中で、配列の次の要素に対して、同じ関数を呼び出しています。終了条件は、配列test_arrayを全部処理するとこ。そこで、resolve()します。

一応、こんな感じのコードで期待通りに動作して、目的は果たせています。
しかし、なんとなく、もっといい方法がありそうな気がしています。気がついたらまた記事にします。

 

ということで、本日はここまで。最後まで読んで頂き、ありがとうございました!