非同期IOメソッドを使う

node.js の特徴はほとんどすべてのメソッドが非同期に実行されることです.とりわけ,IOを伴う処理はストリームという形で抽象化され、ストリームの開始/データの発着/ストリームの終了が非同期イベントとして扱うことになります [1]

この章では簡単なファイルダウンローダーの作成を通じてnode.jsの非同期IO処理方法を習得します.

1ファイルのダウンロード

まず,引数に与えられたファイルをダウンロードし,標準出力に出力するスクリプトを作成します.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
 * download1.js - 指定したURLからデータをダウンロードしながら標準出力に表示
 *
 * usage:
 *    node download1.js URL
 *
 */

// モジュール読み込み
var util = require('util'),
    url = require('url'),
    http = require('http');

function download(urlStr){
   var u = url.parse(urlStr);
   var client = http.createClient(u.port || 80, u.hostname);
   var request = client.request('GET',
                                u.pathname,
                                {
                                   host: u.hostname
                                });
   request.end(); // リクエストの送信終了
   // response イベントの非同期処理
   request.on('response', function(response){
      // ステータスコードとヘッダーの出力
      console.log(response.statusCode);
      for(var i in response.headers){
         console.log(i + ": " + response.headers[i]);
      }
      console.log('');
      // データ取得イベントの非同期処理
      response.setEncoding('utf8');
      response.on('data', function(chunk){
         // chunk は受信したデータ (デフォルトでUTF8 エンコード)
         util.print(chunk);
      });
      // レスポンス終了イベントの非同期処理
      response.on('end', function(){
         console.log('');
      });
   });
}

// 引数を指定して実行
download(process.argv[2]);

まず, require 関数を利用して必要なモジュールを取り込みます.モジュールには様々な関数が定義されています.

// モジュール読み込み
var util = require('util'),
    url = require('url'),
    http = require('http');

のように使います.このようにして取り込まれたモジュールは variable を通じて利用可能です.詳細は 標準ライブラリ の章で取り扱います.

次に、ダウンロードを実行する関数を定義します.

function download(urlStr){

URLを文字列で受け取ります.その後, url ライブラリを利用してURLを hostname, port, pathname などに parse し,http.clientRequest オブジェクト組み立てます [2]

   var u = url.parse(urlStr);
   var client = http.createClient(u.port || 80, u.hostname);
   var request = client.request('GET',
                                u.pathname,
                                {
                                   host: u.hostname
                                });
   request.end(); // リクエストの送信終了

request.end() を明示的に呼び出してリクエストの送信を終了します.今回は GET メソッドを使用するため, request オブジェクトが持つストリームに対しては書き込みを行いません [3]

レスポンスの処理は非同期で実行されるため,イベントハンドラを定義します.node.js の場合,イベントハンドラは次のように定義します.

object.on('eventName', function(arg1, arg2, ...){
});

http.clientRequset オブジェクトは,レスポンスの受信時に実行される response という名前のイベントを持っています.このイベントは http.clientResponse オブジェクトを引数に受け取ります.http.clientResponse オブジェクトはHTTPレスポンスヘッダーの値および,レスポンスを受け取るための各種イベントを持っています.

  • ‘data’ イベントはレスポンスボディを受け取る度に呼び出されます.
  • ‘end’ イベントはレスポンスが終了した場合に1度だけ呼び出されます.

通常,この二つを利用して正常処理を記述します.

   request.on('response', function(response){
      // ステータスコードとヘッダーの出力
      console.log(response.statusCode);
      for(var i in response.headers){
         console.log(i + ": " + response.headers[i]);
      }
      console.log('');
      // データ取得イベントの非同期処理
      response.setEncoding('utf8');
      response.on('data', function(chunk){
         // chunk は受信したデータ (デフォルトでUTF8 エンコード)
         util.print(chunk);
      });
      // レスポンス終了イベントの非同期処理
      response.on('end', function(){
         console.log('');
      });
   });

尚,現在の node.js の実装は次の文字コードのみをサポートしています.SJISやEUCを取り扱うことはできないので注意してください.

  • utf-8
  • ascii
  • base64 [4]

これでイベント定義は終了です.最後に,定義した download 関数を実行します.process 変数はクライアントJavaScriptの window オブジェクトのようなGlobalオブジェクトで,実行時のプロセス情報を保持しています. argv メンバーから引数情報を取得できます.

download(process.argv[2]);

実装したダウンロードスクリプトは次のようにして実行できます.

$ node download1.js http://www.yssk22.info/
200
server: CouchDB/1.0.1 (Erlang OTP/R13B)
content-type: text/plain;charset=utf-8
cache-control: must-revalidate
content-length: 40
date: Sun, 31 Oct 2010 02:30:53 GMT
x-varnish: 87555204
age: 0
via: 1.1 varnish
connection: close

{"couchdb":"Welcome","version":"1.0.1"}

Note

イベントはリクエストの終了後に定義していいの?

はい.同じイベントループに定義していれば確実に呼ばれます.例えば、次のように,リクエストの終了とイベントの定義までに長い時間がかかる場合でも(同じイベントループ内であれば)大丈夫です.

request.end();
for(var i=0; i<1000000000; i++){
   // nothing to do
}
request.on('response', function(response){
   // ...
});

一方,次のように、別のイベントループ内で定義する場合は,正しくイベントが呼ば れない場合があります.

request.end();
setTimeout(function(){
   request.on('response', function(response){
      // ...
   });
}, 0);
[1]標準モジュールの中には fs.writeSync(fd, buffer, offset, length, position) など xxxxSync という名前の同期式のIOメソッドもあります.
[2]少々面倒な作業なので,自分が使いやすい用にラッパーを用意しておくと良いでしょう.
[3]リクエストの送信内容を記述し終わったら.request.end() を常に呼び出す癖が必要です.
[4]いわゆる base64 形式のASCII文字列のエンコード/デコードが可能,という意味です.

複数ファイルの同時ダウンロード

ここまでは,HTTPレスポンスの処理方法をイベントハンドラとして定義するようになっただけで,目立った特徴はありません.しかし,この「イベントハンドラとして定義」するという行為そのものが node.js の優れた特長を引き出します.

download 関数の実行はイベントハンドラを定義するだけなので,すぐに終了します.そして定義されたイベントハンドラは,必要な時に必要に応じて実行されます.複数のURLに同時にリクエストを送るには download 関数を必要な回数呼び出すだけです.

download1.js を変更して,次のように呼び出せるようにしましょう.

$ node download2.js URL1 URL2 URL3 ...

簡単な方法は download 関数を argv の数だけ呼び出すことです.

var argv = process.argv;
for(var i=2, len=argv.length; i<len; i++){
    download(argv[i]);
}

たったこれだけでリクエストは平行実行されるようになります.唯一の欠点は,レスポンスの結果を標準出力に出すようにしてしまったことです.このままでは,様々なURLからレスポンスが届いた順にバラバラ表示されてしまいます.

一つの解決策はURL毎に別々のファイルに保存することですが,ここでは node.js のランタイムの特性を見るために,標準出力にレスポンスが終了した順に出力させる,という処理をすることにします.

この方法は簡単です. ‘data’イベントではなく ‘end’イベントの実行時にまとめて標準出力に出力すればよいのです.ファイル名は download2.js としましょう.

   request.on('response', function(response){
      // データ取得イベントの非同期処理
      var buff = '';
      response.setEncoding('utf8');
      response.on('data', function(chunk){
         // chunk は受信したデータ (デフォルトでUTF8 エンコード)
         buff += chunk;
      });
      // レスポンス終了イベントの非同期処理
      response.on('end', function(){
         // ステータスコードとヘッダーの出力
         console.log(response.statusCode);
         for(var i in response.headers){
            console.log(i + ": " + response.headers[i]);
         }
         console.log('');
         util.print(buff);
         console.log('');
      });
   });

‘end’イベントで何も気にせず標準出力にまとめて出力していますが,これは大丈夫でしょうか?

node.js は1スレッドで動きます.node.js は1スレッドで動きます.これは,イベントハンドラは必ず一時点で一つしか実行されないという重要なことを意味します.CPUが複数コア搭載されていても1つしか使いません.

それはスケールしないのでは? いいえ,あなたが複数のCPUを持っているなら node.js のプロセスを複数立ちあげてください [5] .CPU bound な処理を記述するために node.js を使用することは推奨されません.IO bound な処理をスケールさせるために必要な処理を簡略化できるのが node.js の特長です.

本題に戻ります.

イベントは1時点で1つのみが実行されます.従って,あるURLの’end’イベントが別のURLの’end’イベントに割り込んで実行されることはありません.イベント間のロックやレースコンディションなどを考慮する必要はありません.そんなつまらないことに悩まされるより,イベントハンドラでは「次に何をするのか」の検討に集中してください.今回の場合,「ダウンロードが終わったら標準出力に出力する」.これに専念すればよいのです.

この実装により次のような呼び出しが可能になりました.

$ node download2.js http://www.yssk22.info/ http://www.yssk22.info/ http://www.yssk22.info/ http://www.yssk22.info/ http://www.yssk22.info/ http://www.yssk22.info/
[5]将来には node.js ランタイム自身が複数プロセスにまたがって実行される実装を検討しているようです.

複数ファイルを同時ダウンロードして順番に書き出す

download2.js では出力する順番が保証されませんでした.Web のようなアプリケーションでは,FIFOである必要はなくリクエストが着た順に処理を開始し,処理が終了した順に返せばよいのです.ですが,もしかしたら node.js の処理においても,「すべてのIO処理が終わってから何かをする」といったことが必要になるかもしれません.ここではそのための実装のヒントを提示します.

「イベントは1スレッドで一時点で一つのみ実行される」ことが重要です.

download3.js を次のように実装すれ対応できます.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/**
 * download3.js - 指定したURLからデータをダウンロードしながら標準出力に表示
 *
 * usage:
 *    node download3.js URL URL URL ...
 *
 */

// モジュール読み込み
var util = require('util'),
    url = require('url'),
    http = require('http');


function download(urlStr, index){
   var u = url.parse(urlStr);
   var client = http.createClient(u.port || 80, u.hostname);
   var request = client.request('GET',
                                u.pathname,
                                {
                                   host: u.hostname
                                });
   request.end(); // リクエストの送信終了
   // response イベントの非同期処理
   request.on('response', function(response){
      // データ取得イベントの非同期処理
      var buff = '';
      response.setEncoding('utf8');
      response.on('data', function(chunk){
         // chunk は受信したデータ (デフォルトでUTF8 エンコード)
         buff += chunk;
      });
      // レスポンス終了イベントの非同期処理
      response.on('end', function(){
         notifyDone(index, response, buff);
      });
   });
}

// 引数を指定して実行
var argv = process.argv;
var len = len=argv.length;
for(var i=2; i<len; i++){
   download(argv[i], i-2);
}

// 全部の処理を待つための関数定義
var downloads = new Array(argv.length - 2),
    max = len - 2,
    done = 0;

// end イベントが発生する度に実行される終了通知関数
function notifyDone(index, response, body){
   downloads[index] = {
      response: response,
      body: body
   };
   done = done + 1;
   if( done == max ){
      // 全部終了していたら allDone を呼び出す
      allDone();
   }
}

// 全部の処理が終わったら実行される
function allDone(){
   for(var i in downloads){
      var r = downloads[i];
      console.log("---- [" + i + "] ");
      console.log(r.response.statusCode);
      for(var j in r.response.headers){
         console.log(j + ": " + r.response.headers[j]);
      }
      console.log('');
      util.print(r.body);
      console.log('');
   }
}

重要な実装は53行目以降にすべてあります.終了通知を受け取るための notifyDone で,終了時の http.clientResponse オブジェクトと実際のレスポンスボディを受け取ります.この通知を受け取ったら処理数 done をインクリメントし,実際のURLの数と比較します.実際のURL数と一致すれば,すべてが終了したと判断し,allDone関数を呼び出します.

リクエストはバラバラに並列実行されますが,イベントハンドラはマルチスレッドで動いているわけではないので,クリティカルセクションの考慮は不要です.IO待ちの分のオーバーヘッドだけが node.js のランタイムによって消費されるだけです.

この実装で不足しているのはErrorが発生したときの処理だけです.