Node.js:async.forEachSeriesって配列の順番どおりに処理してくれるんじゃないの?

本当はうまくいってから書くつもりだったけど、どうにもうまくいかないので書くだけ書いておく。

簡単な説明

サーバからの指示でファイルをダウンロードしたり削除したりするプログラムを Node.js で書いている。けど、指示通りの順番で処理してくれないので困ってしまった。async.forEachSeries を試してみたけどダメだった(←今ここ)。

より細かい説明

サーバは http://localhost:8888/ で動いていて、/recent.json にアクセスすると指示を受け取ることができる。指示は JSON 形式で、次のようなもの。

[
  { "action": "new", "path": "Penguins.jpg" },
  { "action": "new", "path": "data/Desert.jpg"},
  { "action": "delete", "path": "Penguins.jpg"}
]

action がするべき処理を示していて、new は新しくファイルをダウンロード、delete はすでにダウンロードしたファイルを削除する。上の例では、

  1. Penguins.jpg をダウンロードして保存
  2. data/Desert.jpg をダウンロードして保存
  3. Penguins.jpg を削除

となって、結果として data/Desert.jpg だけが保存されるはず。

書いてみたプログラム replicate.js を載せるよ。

var http = require('http');
var fs = require('fs');
var path = require('path');
var mkdirp = require('mkdirp');

var host = "http://localhost:8888/";

var action = {
  new: getNewItem,
  delete: deleteItem
}

function replicate(host) {
  var req = http.get(host + "recent.json", function(res) {
    var body = "";
    res.setEncoding('utf8');
    res.on('data', function(chunk) {
      body += chunk;
    });
    res.on('end', function(res) {
      var items = JSON.parse(body);
      items.forEach(function(item) {
        action[item.action](item);
      });
    });
  }).on('error', function(err) {
    colsole.log("Error: " + err.message);
  });
}

function getNewItem(item) {
  console.log("New item: " + item.path);
  var filepath = "./storage/" + item.path;
  var dir = path.dirname(filepath);
  mkdirp(dir, function(e) {
    if (e) {
      console.log(e.message);
    } else {
      var itemUrl = host + item.path;
      var ws = fs.createWriteStream(filepath);
      var req = http.get(itemUrl, function(res) {
        res.pipe(ws);
        res.on('end', function() {
          ws.close();
        });
      });
      console.log("New: " + item.path + "...done.");
    }
  });
}

function deleteItem(item) {
  console.log("Delete item: " + item.path);
  var filepath = "./storage/" + item.path;
  fs.unlink(filepath, function(e) {});
  console.log("Delete: " + item.path + "...done.");
}

replicate(host);

たいして難しいことはしてない。変数 action にアクションを登録(9~12行目)しておいた上で、サーバから JSON で指示を受け取り(15行目)、パースして配列に直し(22行目)、配列の順番どおりに処理している(23~25行目)。ついでに各処理のはじめと終わりにコンソールに出力している。

さて、これを実行してみると、期待通りには動いてくれない。

^o^ > node replicate.js
New item: Penguins.jpg
New item: data/Desert.jpg
Delete item: Penguins.jpg
Delete: Penguins.jpg...done.
New: Penguins.jpg...done.
New: data/Desert.jpg...done.

処理の開始は配列の順番どおり(最初の3行)だけど、Delete が先に終わってしまって、New があとになっている。結果として、削除するはずの Penguins.jpg が残っている。

^o^ > tree /F storage
フォルダー パスの一覧:  ボリューム OS
ボリューム シリアル番号は FE2A-F7C6 です
C:\USERS\TAKATOH\DOCUMENTS\W\REPLICATE\STORAGE
│  Penguins.jpg
│
└─data
        Desert.jpg

原因はアクションの処理が非同期なせいだ。Array.forEach 自体は非同期じゃないらしいけど(だから指示の順番どおりにアクションが始まっている)、アクション自体が非同期なので、ひとつのアクションが終わらないうちに次のアクションが始まり、終わりの順番が入れ替わってしまっているんだ。
これは困った。ちゃんと指示通りに順番に処理してくれないと、上のように Penguins.jpg のダウンロードと削除が指示通りにならない。さて、どうしよう。

async.forEachSeriesを試す

いろいろググってみた結果、async というライブラリでフロー制御ができるらしい、というのがわかった。というわけで、async.forEachSeries を試してみた。
書き換えたのがこのコード。

var http = require('http');
var fs = require('fs');
var path = require('path');
var mkdirp = require('mkdirp');
var async = require('async');

var host = "http://localhost:8888/";

var action = {
  new: getNewItem,
  delete: deleteItem
}

function replicate(host) {
  var req = http.get(host + "recent.json", function(res) {
    var body = "";
    res.setEncoding('utf8');
    res.on('data', function(chunk) {
      body += chunk;
    });
    res.on('end', function(res) {
      var items = JSON.parse(body);
      async.forEachSeries(items, function(item) {
        action[item.action](item);
      });
    });
  }).on('error', function(err) {
    colsole.log("Error: " + err.message);
  });
}

function getNewItem(item) {
  console.log("New item: " + item.path);
  var filepath = "./storage/" + item.path;
  var dir = path.dirname(filepath);
  mkdirp(dir, function(e) {
    if (e) {
      console.log(e.message);
    } else {
      var itemUrl = host + item.path;
      var ws = fs.createWriteStream(filepath);
      var req = http.get(itemUrl, function(res) {
        res.pipe(ws);
        res.on('end', function() {
          ws.close();
        });
      });
      console.log("New: " + item.path + "...done.");
    }
  });
}

function deleteItem(item) {
  console.log("Delete item: " + item.path);
  var filepath = "./storage/" + item.path;
  fs.unlink(filepath, function(e) {});
  console.log("Delete: " + item.path + "...done.");
}

replicate(host);

書き換えたのは2行だけ。5行目で async を読み込み、24行目では items.forEach(function… の代わりに、async.forEachSeries(items, function… としている。これで、うまく動いてくれるだろうか。

^o^ > node replicate.js
New item: Penguins.jpg
New: Penguins.jpg...done.

ダメだーーー!
なんだかわからないけど、最初のアクションしか実行してくれない。ぜんぜん each じゃないじゃないか。どういうことだろう?