node.jsでURLをGETしてファイルに保存する

node.jsでURLをGETしてファイルに保存してから何か処理する、という単純なことをしようとしたら案外ハマったのでメモ。
以下の httpGet 関数は、targetUrlをGETしてdestPathに保存してコールバックを呼びます。fn は err のみを引数に取る関数です。(最終版)

var http = require('http')
  , https = require('https')
  , url = require('url')
  , fs = require('fs');

function httpGet(targetUrl, destPath, fn) {
  var callee = arguments.callee;
  var opts = url.parse(targetUrl);
  var req = (opts.protocol.match(/https/) ? https : http).request({
    host: opts.hostname,
    port: opts.port,
    path: opts.pathname + (opts.search || ''),
    method: 'GET',
  });
  req.on('response', function(res){
    if(res.statusCode == 301 ||res.statusCode == 302) {
      callee(res.headers.location, destPath, fn);
    } else if(res.statusCode == 200) {
      var writableStream = fs.createWriteStream(destPath);
      writableStream.on('error', fn);
      writableStream.on('close', fn);
      res.on('data', function(chunk){
        writableStream.write(chunk, 'binary');
      });
      res.on('end', function(){
        writableStream.end();
      });
    } else {
      fn(new Error('statusCode is ' + res.statusCode));
    }
  });
  req.on('error', fn);
  req.end();
}

失敗1) リダイレクト処理は自前でやる必要あり?

最初、req.on('response', 〜) の中でいきなりファイル保存する処理を書いてたんだけど、301や302でリダイレクトが発生した場合に何も保存してくれない羽目になるので、自前で再帰処理を書いた。

失敗2) errorイベントはちゃんと拾おう(^^;

writableStream.on('error', fn); とか req.on('error', fn); とかちゃんとやってやらないと、fnを呼ぶ人が誰もいなってしまうので以降の処理が闇に消えてしまうことになる。
上から下へ処理が進んでいく言語だとエラー処理忘れは大抵が例外発生でプログラム停止など目に見える形で表れて気づくことが多いが、全てがコールバックで非同期な世界では何も起こらないという形で表れる(表れない)ので慣れないと問題を追うのが結構困難なことになってしまうので気を付けること。

失敗3) fnを呼ぶタイミングはwritableStreamのcloseイベントですべし

最初、終了処理(正常時のコールバック呼び出し)は、writableStream.on('close', fn); じゃなく、↓こんな風にストリームを end() しすぐにコールバックを呼び出すってしてたんだ。

res.on('end', function(){
  writableStream.end();
  fn();
});

でもこれだと、writableStream.end() が終わった瞬間にはまだファイルの書き込み中でクローズが終わってない場合があり、その時点でfnが呼ばれると、fn中で destPath のファイルを開いて何かする処理のところで、書き込み中の尻切れトンボなファイルを読み込んでしまって問題が起こることがしばしばあった。
データが小さかったりして運よくcloseが先に終わってくれると問題が起きないことも結構あり、適当に試験しただけだと問題に気がつかないことがあるので気を付けること。

失敗4) 再帰処理するときは arguments.callee 使うと便利

最初リダイレクト時の再帰で httpGet(res.headers.location, destPath, fn); と書いてたんだが、関数名を変えた場合に外見と中身の両方修正しないといけないので、arguments.callee を使うようにした。再帰の時はこれ便利ね、無名関数でも使えるし。