S2Util 0.0.0 リリース

しました.これが最初のリリースとなります.


S2Util プロジェクトは,Seasar2 からスピンアウトしたプロジェクトで,Seasar2 に含まれていた様々なユーティリティクラスを単独のライブラリとして提供します. Seasar2 のユーティリティクラスの多くは J2SE1.4 を前提としていましたが,S2Util では Java5 以降のジェネリックスや可変長配列に対応するなどの変更が加えられています.

※ S2Util を利用するには Java6 が必要です.


■ダウンロードはこちらからどうぞ.


Maven2からのご利用はこちらを参照ください.

HandlerSocket の Node.js クライアント node-handlersocket リリース

先週,本家 (英語の) InfoQ や PublicKey で紹介されて話題となった HandlerSocket.

InfoQ
InfoQ: MySQL/HandlerSocket and VoltDB: Contenders to NoSQL
InfoQ
InfoQ: NoSQL への挑戦者 - MySQL/HandlerSocket と VoltDB
Publickey
NoSQLとしてMySQLを使うDeNAが、memcachedよりも高速な75万クエリ/秒を実現


この HandlerSocket は C++ で書かれたクライアントライブラリと,その Perl バインディングが提供されているのですが,同時にプロトコル仕様

が公開されていることもあって,既に第三者によって RubyPythonPHPJava などのバインディングあるいはネイティブ実装のクライアントが公開されています.
自分が見つけたのはこんな感じ.


そんなわけで (どんなわけで?),Node.js 向けの Pura JavaScript クライアントライブラリ,node-handlersocket を作ってみました.

JavaScsript 弱者 && できたてほやほやなので品質的には自信ないです.突っ込み歓迎です.


npm からインストールすることもできるはず.

npm install node-handlersocket


使い方はこんな感じ.

var hs = require('node-handlersocket');

var con = hs.connect();
con.on('connect', function() {
  con.openIndex('test', 'EMPLOYEE', 'PRIMARY', [ 'EMPLOYEE_ID', 'EMPLOYEE_NO',
    'EMPLOYEE_NAME' ], function(err, index) {
    index.find('=', 1, function(err, results) {
      console.log(results[0]);
      con.end();
    });
  });
});

他言語のクライアントは専ら本家の Perl クライアントと同じような API になっているようですが,Node.js は非同期な API にしなくちゃということで,独自の API になってます.
できるだけ Node.js の標準モジュールと同じような API にしたつもり.
以下簡単に API を紹介.


まずは HandlerSocket サーバに接続します.

var con = hs.connect();

デフォルトでは localhost の 9998 番ポートに接続します.変更する場合は

var con = hs.connect({ host : 'hostname', port : 9999 });

のようにします.host だけ,port だけの指定も可.
connect() の戻り値は Connection オブジェクトで,Node.js の EventEmitterインスタンスです.
そんなわけで (どんなわけで?),

con.on('connect', function() {
  ...
});

で接続の完了を待ちます.


次にインデックスをオープンします.

  con.openIndex('test', 'EMPLOYEE', 'PRIMARY', [ 'EMPLOYEE_ID', 'EMPLOYEE_NO',
    'EMPLOYEE_NAME' ], function(err, index) {
    ...
  });

openIndex()シグネチャ

  • openIndex(database, table, index, columns, callback)

となっていて,引数は順にデータベース名,テーブル名,インデックス名 (主キーは 'PRIMARY'),後で検索で取得したり更新対象となるカラム名の配列,コールバック関数です.
コールバック関数のシグネチャ

  • function(err, index)

で,インデックスのオープンが成功すると errnull となり,indexIndex オブジェクトが渡されます.
インデックスのオープンが失敗すると,err には Error オブジェクトが渡されます.


インデックスを取得すると検索や更新を行うことができます.

    index.find('=', 1, function(err, result) {
      ...
    });

検索を行う find()シグネチャ

  • find(op, keys, [limit, [offset] ], callback)

となっていて,引数は順にオペレータ,インデックス値の配列,取得する行数の上限 (デフォルトは 1),取得する際に読み飛ばす行数 (デフォルトは 0),コールバック関数です.
オペレータは '=''>''>=''<''<=' を指定することができます.'=''>''>=' を指定した場合の結果は昇順,'<''<=' を指定した場合の結果は降順になります.
コールバック関数のシグネチャ

  • function(err, results)

で,検索が成功すると errnull となり,results には openIndex()columns で指定したカラム値の配列 (レコード) からなる配列 (レコードの配列) が渡されます.検索結果が 0 件の場合,results は空の配列となります.
検索が失敗すると,err には Error オブジェクトが渡されます.


更新を行う update() と削除を行う remove() は検索と似ていて,それぞれのシグネチャ

  • update(op, keys, [limit, [offset] ], values, callback)

  • remove(op, keys, [limit, [offset] ], callback)


となっていて,values 以外は find() と同じです.
values には openIndex()columns で指定したカラム値の配列を指定します.
コールバック関数のシグネチャ

  • function(err, rows)

で,検索が成功すると errnull となり,rows には更新または削除した行数が渡されます.
混信または削除が失敗すると,err には Error オブジェクトが渡されます.


挿入を行う insert()シグネチャ

  • insert(values, callback)

となっていて,values には openIndex()columns で指定したカラム値の配列を指定します.
コールバック関数のシグネチャは更新および削除と同じです.


ドキュメントはこれから頑張ります.誰か 翻訳 頼む
詳細は本家 HandlerSocket のドキュメントを参照してください.


他言語クライアントの場合,複数のリクエストをパイプライニングするために execute_multi メソッドがあったりしますが,node-handlersocket は全部非同期のため,コールバックされる結果を待たずに連続してメソッドを呼び出すだけでパイプライニングされます.
また,レスポンスが全部揃う前に個々のレスポンスごとにコールバックされるので効率面で多少有利かも.


API というかコールバックは標準の fs モジュールなどに合わせてあるので,制御フローを簡素化するライブラリと組み合わせることも簡単じゃないかと思います.
例えば Node.js のパッケージマネージャ npm の作者である Isaac Schlueter さんによる a tiny flow control library 「Slide」

と組み合わせると,

var hs = require('../lib/node-handlersocket'), asyncMap = require('slide/async-map');

var indexDefs = [
  ['test', 'EMPLOYEE', 'PRIMARY', [ 'EMPLOYEE_ID', 'EMPLOYEE_NO', 'EMPLOYEE_NAME' ]],
  ['test', 'DEPARTMENT', 'PRIMARY', ['DEPARTMENT_ID', 'DEPARTMENT_NO', 'DEPARTMENT_NAME']],
  ['test', 'ADDRESS', 'PRIMARY', ['ADDRESS_ID', 'STREET']]
];

var con = hs.connect();
con.on('connect', function() {
  asyncMap(indexDefs, function(indexDef, callback) {
    con.openIndex.apply(con, indexDef.concat(callback))
  }, function(err, indices) {
    ...
  });
});

という感じで,indexDefs に定義したインデックスが全部オープンし終わると,(最後の) コールバックに Index の配列が渡されるみたいな.この場合の openIndex() もパイプライニングされます.試してないけど. 試しました.


ということで,注目の HandlerSocket と注目の Node.js の組み合わせを楽しんでみてください.

Dolteng 0.41.0 リリース

しました.


■変更点

0.40.0 からの変更点は次のとおりです.

  • Bug
    • [DOLTENG-118] - Jar ファイルの MANIFEST.MF に記述されている Jar が同じディレクトリに存在しないと Database view で NullPointerException が発生する問題を修正しました.[Seasar-user:19575]
    • [DOLTENG-121] - Teeda の HTML テンプレートで class 属性に対するダイナミックプロパティを追加すると getXxxStyleClass() のようにならなければならないのに getXxxClass() となってしまう問題を修正しました.[Seasar-user:19949]
    • [DOLTENG-125] - Kuina-Dao の orderby 引数の型に OrderingSpec やその配列を使うとエラーマーカが出る問題を修正しました.[Seasar-user:20187]

  • Task
    • [DOLTENG-123] - Seasar2 のバージョンを 2.4.43 に更新しました.
    • [DOLTENG-124] - Teeda のバージョンを 1.0.13-sp10 に更新しました.
    • [DOLTENG-127] - S2Flex2のプロジェクトを作成する際に、Flex SDKFlex 2.0.1 Hotfix 3で作成するのをサポートから外しました。


■セットアップ手順はこちらからどうぞ.

Node.js 用の非同期処理を簡単にしてくれるライブラリ async.js

Node.js といえば非同期処理です.そして非同期処理と言えばコールバック.
そんなわけで (どんなわけで?),すぐにこんなコードになったりしがちですよね.

fs.writeFile(path, "hello", function (error) {
    fs.open(path, "a", 0666, function (error, file) {
        fs.write(file, "\nworld", null, "utf-8", function () {
            fs.close(file, function (error) {
                fs.readFile(path, "utf-8", function (error, data) {
                    var lines = data.split("\n");
                    sys.puts(lines[1]);
                });
            });
        });
    });
});

奥に向かって突き進むのが気持ちいい,そんな風に思っていた時期が僕にもありました...
でも,そのうちこんな冗談も言いたくなりますよね.

The last few lines of our #nodejs application - });});});});});});});});});}); our #clojure app looks like this - )))))))))))))))))))) #joke

こんな小さなサンプルでこれだと,現実的なアプリではどうなることやら,と心配で夜も眠れないので調べてみたら,いろいろなライブラリが当然のようにあったりするわけですね.

JSDeferred

これは元々ブラウザの中で動く普通の JavaScript 向けに作られたようですが,Node.js でも使えます.
Deferred は先週書いたテスティングフレームワーク Vows に出てきた Promise と似てるというか,その応用という感じです.
Promise/Future は将来確定する値を取得できるようにするためのオブジェクトですが,Deferred はさらに,値が確定した時に実行する処理を登録しておくことができます.処理の実行をその時まで先送りするから Deferred なんですね.
んで,JSDeferred を使うと先のサンプルは次のように書くことができます.

var fs = require('fs'), sys = require('sys');
var Deferred = require('../lib/jsdeferred/jsdeferred').Deferred;
var path = "fileio.txt";
Deferred.define();

next(function() {
    var deferred = new Deferred();
    fs.writeFile(path, "hello", function(error) {
        deferred.call();
    });
    return deferred;
}).next(function() {
    var deferred = new Deferred();
    fs.open(path, "a", 0666, function(error, file) {
        deferred.call(file);
    });
    return deferred;
}).next(function(file) {
    var deferred = new Deferred();
    fs.write(file, "\nworld", null, "utf-8", function() {
        deferred.call(file);
    });
    return deferred;
}).next(function(file) {
    var deferred = new Deferred();
    fs.close(file, function(error) {
        deferred.call();
    });
    return deferred;
}).next(function() {
    fs.readFile(path, "utf-8", function(error, data) {
        var lines = data.split("\n");
        sys.puts(lines[1]);
    });
});

うーみゅ... 奥に向かって突き進むコードが普通に縦に落ちていくコードにはなりました.しかし,少し残念な感じがしないでもない...
これは JSDeferred が悪いわけではなく,ビルディングブロックである Deferred を直接使ってるからこうなっちゃってるんですね.この決まり切った処理をラップした関数を用意すれば,スッキリしたコードになるはずです.しかし,現時点ではそんなありがたいものはなさそう...
ないなら作ればいいじゃない,と王妃様に言われそうな気がしますが,JSDeferred の作者さんも Node.js を使っているようなので,待ってればいずれ出てくるんじゃないのかなぁと期待しておきます.

promised-io

こちらは promised-based なテスティングフレームワーク patr と同じ作者による promised-based な IO フレームワーク
Node.js の fs モジュールや http.Client のラッパーを用意してくれているようです.
しかし... ドキュメントが...
一応こちらにも情報があるのですが,

なかなか辛いものがあります.
その中に出てくる例.

var myFile = fs.open("my-file.txt", "a");
myFile.write("some data").then(myFile.close);

これだけ見ると,結構いい感じに見えます.
一見同期的なコードに見えますが,実際にはファイルの open() が完了してから write() が,それが終わってから close() が行われます.カッコいい.
のですが,もうちょっと何かやろうとすると行き詰まってしまいました.
例えば最初の例を promised-io でやろうとすると,

var sys  = require("sys");
var fs = require("../lib/promised-io/fs");
var path = "fileio.txt";

fs.writeFile(path, "hello").then(function() {
    var myFile = fs.open(path, "a", 0666);
    myFile.write("\nworld").then(myFile.close);
})

この時点でかっこよくない気がする上に,この先の書き方が分かりません.


promised-io はいくつかのクラスを提供するのですが,ここで出てくるのはそのうちの 3 つ,PromiseDeferred,そして File です.これらは,
Promise <|-- Deferred <|-- File
という継承関係にあります (たぶん,でも自分の JavaScript 力では怪しいのだ).
で,問題なのは Filewrite() が返すのが File ではなく Deferred っぽいこと.なので,

    myFile.write(...).read(...).close().then(...);

なんてできそうに見えないのですね.チェーンができない.
さらに,Deferredthen() が返すのは Deferred ではなく Promise で,こいつの then()

Promise.prototype.then = function(resolvedCallback, errorCallback, progressCallback){
	throw new TypeError("The Promise base class is abstract, this function must be implemented by the Promise implementation");
};

ってなってたりして,使い物にならなかったり.
要するに,JSDeferred でできているようなチェーンができない気がするわけです.
自分の勘違いという可能性も高いのですが (なんせ JavaScript では最下級戦士),ドキュメントもアレなので,これ以上関わるのは難しいと判断して次へ行きます.

async.js

そんなわけで (どんなわけで?),ようやく今日の主題,async.js です.前置き長すぎ.
async.js は先の二つとは随分異なった印象のライブラリで,元々は関数型言語に見られる無限リストや map などの関数を提供するものみたいです.
こいつを使うと例えば

async.range(1, null, 2)
  .print()
  .end();

これでコンソールに 1, 3, 5... と奇数が延々と出力されます.
最初の range(start, stop, step) はジェネレータと呼ばれ,これが後続の関数にデータを渡します.この例では print() です.後続の関数は自分の処理が終わったら次の関数に処理を渡します.つまりパイプラインを構成しているわけです.それが延々と続いて (この例では print() しかありませんが),最後に end() にたどり着くと,ジェネレータに戻って次の値が生成されて... を繰り返します.
おっと,うちの環境だと 4000 を越えたところで例外が.スタックオーバーフローみたいな?
Node.js ってエラーがあった時のメッセージが不親切で状況がまるで分からないことが多い気がします.でもまぁ,これはきっとスタックオーバーフローでしょう.


でも大丈夫です.
async.js には delay() という関数があって,これを挟むと後続の処理を setTimeout() に渡してくれます.これで一度 Node.js に制御を戻してあげればスタックオーバーフローにはならないはず.

async.range(1, null, 2)
  .print()
  .delay(0)
  .end();

うん,大丈夫っぽい.
さらに

// 偶数
async.range(0, null, 2)
  .print()
  .delay(0)
  .end();

// 奇数
async.range(1, null, 2)
.print()
.delay(0)
.end();

なんてやると,0, 1, 2, 3, 4, 5... という具合に両方の数列が交互に出力されます.同期的なパイプラインに見えるコードが実は非同期に実行されている... なんかカッコいい!!


で,今の delay() の挙動が結構重要で,これってコールバックされるまで後続の処理を先送りしてくれるわけです.要するに Deferred なんですね.つまり delay() は,JSDefferd の next() や promised-io の then() みたいなことをやってくれているということ.
ってことは,このパイプラインに I/O を含めることだってできるはず.


そんなわけで (どんなわけで?),async.js は Node.js 標準の fs モジュールライクなプラグインを用意してくれてます.
ただ,ちょっとだけ残念なことに,open() の引数の並びが Node.js のとは違っていたり,readFile()/writeFile() はあるのに read()/write() はなかったり.
そして凄く残念なことに,ネットワーク関係のプラグインはなかったり.うーみゅ...


とりあえず気を取り直して,async.js の fs-node.js というプラグインをちょっと修正してみました.

@@ -53,7 +53,7 @@ async.plugin({
         })
     },
 
-    open: function(flags, mode) {
+    open: function(mode, flags) {
         return this.$fileOp(function(file, next) {
             fs.open(file.path, mode || file.mode || "r", flags || file.flags || 0666, next)
         }, "fd")
@@ -124,17 +124,12 @@ async.plugin({
             if (!file.path)
                 return next("not a file sequence!")
             
-            if (encoding)
-                fs.readFile(file.path, encoding, readCallback)
-            else
-                fs.readFile(file.path, readCallback)
-            
-            function readCallback(err, data) {
+            fs.readFile(file.path, encoding, function readCallback(err, data) {
                 if (err) 
                     return next(err)
                 file.data = data
                 next()
-            }            
+            })
         })
     },
     
@@ -142,6 +137,20 @@ async.plugin({
         return this.$fileOp(function(file, next) {
             fs.writeFile(file.path, data || file.data, next)
         })
+    },
+
+    write: function(buffer, offset, length, position) {
+        return this.$fileOp(function(file, next) {
+            if (!Buffer.isBuffer(buffer)) {
+                buffer = new Buffer(buffer, length);
+                length = buffer.length;
+                position = offset;
+                offset = 0;
+            }
+            fs.write(file.fd, buffer, offset, length, position, function(err, result){
+                next(null, file);
+            });
+        });
     }
 }, {
     files: function(files, root) {
@@ -154,6 +163,10 @@ async.plugin({
             }
         }))
     },
+
+    file: function(file, root) {
+        return this.files([file], root);
+    },
     
     glob: function(pattern) {
         function fileSort(file1, file2) {

これを使うと,最初の例はこうなります.ジャジャーン.

var sys = require('sys'), fs = require('fs');
var async = require('../lib/async');
var path = 'fileio.txt';

async.file(path)
  .writeFile('hello')
  .open('a')
  .write('\nworld')
  .close()
  .readFile('utf-8')
  .get('data')
  .each(function(data) {
      var lines = data.split("\n");
      sys.puts(lines[1]);
  })
  .end();

おおぉぉぉ...
これです.まさに,これが望んでいたものそのものですよ!!
async.js△!!!!


この動作を簡単に説明すると,
最初の file() 関数はジェネレータで,ファイルのパスを作って後続に渡します.
次の writeFile() はそのファイルに文字列を書き込みます.
その次の open() はファイルをオープンし,ファイル記述子を後続へのデータに追加します.
write() はそのファイル記述子を使って文字列を書き込みます.
close() はファイルをクローズしてファイル記述子を削除します.
readFile() はジェネレータが設定したパスを使ってファイルを読み込み,結果をパイプラインを流れるオブジェクトの data プロパティに設定します.
get() はその data プロパティを取り出して後続に渡します.
each() はそれ (data) をコールバックで受け取って処理します.
という感じ.


ポイントは,open() とか write() という名前の関数は,実際には open()write() を行う関数を呼び出す関数を準備している (パイプラインを構築する) だけということ.そして,実際の関数は前段の処理が終わった後に非同期に呼び出されるということ.


途中に print() を入れると,パイプラインを流れるデータを表示してくれて便利です.
対応するソースと混ぜるとこんな感じになります.

//async.file(path)
{ path: 'fileio.txt', name: 'fileio.txt' }
//  .writeFile('hello')
{ path: 'fileio.txt', name: 'fileio.txt' }
//  .open('a')
{ path: 'fileio.txt', name: 'fileio.txt', fd: 5 }
//  .write('\nworld')
{ path: 'fileio.txt', name: 'fileio.txt', fd: 5 }
//  .close()
{ path: 'fileio.txt'
, name: 'fileio.txt'
, fd: undefined
}
//  .readFile('utf-8')
{ path: 'fileio.txt'
, name: 'fileio.txt'
, fd: undefined
, data: 'hello\nworld'
}
//  .get('data')
hello
world

ジェネレータの file() が作ったオブジェクトに open()fd を追加して,close がそれを消して,readFiledata を追加して,get() がその data だけを後続に渡している様子が分かります.


ちなみに,ジェネレータに複数のファイルのパスを受け取る async.js 本来の files() を使うと,

async.files(['file1.txt', 'file2.txt', 'file3.txt'])
  .writeFile('hello')
  .open('a')
  .write('\nworld')
  .close()
  .readFile('utf-8')
  .get('data')
  .each(function(data) {
      var lines = data.split("\n");
      sys.puts(lines[1]);
  })
  .end();

なんて風に複数のファイルに対する繰り返しも簡単.
さらに,glob() とか walkfiles() なんて便利そうなジェネレータも用意されているので,ファイルに対する操作に関してはそこそこ便利そうです.


ネットワーク関係のプラグインがないとか,ReadableStream にも対応してなさそうとか,エラーハンドリングはどうするとか,課題も多そうな async.js ですが,それでも Node.js でのプログラミングスタイルについて考えさせてくれるいい材料という感じがします.
http.Client のラッパーでも作りながらもう少し遊んでみようと思ってます.

Node.js 用のテスティングフレームワーク Vows その 3

昨日のエントリ「その 2」で ReadableStream からの 'data' イベントを行単位にコールバックしてくれる read-line モジュールを作成し,EventEmitter を使って擬似的にイベントを発生させるテストを Vows を使って書きました.
今回はその続きとして,本物のファイルを使ったテストを追加します.


ファイルを扱うには Node.js 標準のファイルシステムモジュールを使います.

今回はファイルをストリームとして読み込むので,「fs.ReadStream」を使います.

この fs.createReadStream(path) を呼び出すと ReadableStream が返ってきます.(それは EventEmitter でもあります).こいつを read-line に渡せばいいだけですね!
でもでも,擬似的なテストで使った自前の EventEmitter と違い,ReadableStream に対して emit() を呼び出すわけにはいきません.実際に ReadableStream からの読み込みが終了して,行ごとの配列が完成するまでは vow で検証することができないのです.どうしよう?


そこで「その 1」で紹介した「プロミス」を使ってみましょう.

プロミスを使うのは簡単で,トピックで (またしても!) EventEmitter を作って返せばいいだけです.そして ReadableStream がクローズしたらプロミス (EventEmitter のことです) にイベントを生成してあげれば vow が呼び出されて検証が行われます.


一つずつ順番にいきましょう.
まずはテスト用のファイルを用意します (test1.txt).

abc

ファイルシステムモジュールを使うので,

-var vows = require('vows'), assert = require('assert'), events = require('events');
+var vows = require('vows'), assert = require('assert'), events = require('events'), fs = require('fs');
 var read_line = require('./read-line');

が必要です.
「その 2」で作成した,EventEmitter を偽のストリームとして扱うコンテキストとは別に,本当のファイルを使ったテスト用のコンテキストを用意します.

                 }
             }
         }
+    },
+    '実際に読み込んだファイルが' : {
+        '1行のファイルの場合' : {
+
+        }
     }
 }).export(module);

子のコンテキストのトピックと vow はイメージしやすいので先に作成します.

     },
     '実際に読み込んだファイルが' : {
         '1行のファイルの場合' : {
-
+            topic : function(topic) {
+                return topic('test1.txt');
+            },
+            '1回だけコールバックされること' : function(topic) {
+                assert.length(topic, 1);
+            },
+            '行全体がコールバックされること' : function(topic) {
+                assert.equal(topic[0], 'abc');
+            }
         }
     }
 }).export(module);

この子コンテキストのトピックが返すのがプロミスです.つまり,親コンテキストのトピックが返す関数の戻り値がプロミスであって欲しいということになります.
そんなわけで (どんなわけで?),親コンテキストのトピックはこうなるはずです.

         }
     },
     '実際に読み込んだファイルが' : {
+        topic : function() {
+            return function(fileName) {
+                var promise = new (events.EventEmitter);
+
+                return promise;
+            };
+        },
         '1行のファイルの場合' : {
             topic : function(topic) {
                 return topic('test1.txt');

そしてこの中で fs.createReadStream(path) を呼び出して,それを read-line の onDataByLine() に渡します.

         topic : function() {
             return function(fileName) {
                 var promise = new (events.EventEmitter);
+                var stream = fs.createReadStream(fileName);
+                stream.setEncoding('utf-8');
+                read_line.onDataByLine(stream, function(line) {
 
+                });
                 return promise;
             };
         },

ちなみに ReadableStream から文字列を読み込むには setEncoding() してあげないといけません.

read-line からコールバックされたら文字列を配列に貯め込みます.

     '実際に読み込んだファイルが' : {
         topic : function() {
             return function(fileName) {
+                var lines = [];
                 var promise = new (events.EventEmitter);
                 var stream = fs.createReadStream(fileName);
                 stream.setEncoding('utf-8');
                 read_line.onDataByLine(stream, function(line) {
-
+                    lines.push(line);
                 });
                 return promise;
             };

ファイルがクローズしたイベントを受け取らなくてはいけません.

                 read_line.onDataByLine(stream, function(line) {
                     lines.push(line);
                 });
+                stream.on('end', function() {
+
+                });
                 return promise;
             };
         },

ファイルがクローズしたならプロミスに通知して vow が呼び出されるようにします.

                     lines.push(line);
                 });
                 stream.on('end', function() {
-
+                    promise.emit('success', lines);
                 });
                 return promise;
             };

まとめると,親コンテキストのトピックはこうなりました.

        topic : function() {
            return function(fileName) {
                var lines = [];
                var promise = new (events.EventEmitter);
                var stream = fs.createReadStream(fileName);
                stream.setEncoding('utf-8');
                read_line.onDataByLine(stream, function(line) {
                    lines.push(line);
                });
                stream.on('end', function() {
                    promise.emit('success', lines);
                });
                return promise;
            };
        },

案外シンプルですね.非同期イベントを扱うテストって大変そうなイメージでしたが,これなら怖くないかも?
実行します.

$ vows read-line-test.js
·······························OK » 31 honored (0.007s)



テストデータを増やしてみます.
空のファイル (test0.txt) と,2〜4 行のデータを持ったファイルを作成します.
test2.txt (最後に改行なし)

abc
def

test3.txt (最後に改行あり)

abc

def

test4.txt (最後に改行あり)

abc

def

Eclipse のエディタなんかだと最後に改行があるとその下にもう一行あるかのように表示されるけど,そういうものなのかな? とりあえずここでは改行を行の終端として扱うことにしているので,その後に行はないものとして扱います.
最終形ということで,テストを増やした read-line-test.js 全体を掲載.

var vows = require('vows'), assert = require('assert'), events = require('events'), fs = require('fs');
var read_line = require('./read-line');

vows.describe('read-line').addBatch( {
    '偽のEventEmitterを使って' : {
        topic : function() {
            return function(data) {
                var lines = [];
                var emitter = new (events.EventEmitter);
                read_line.onDataByLine(emitter, function(line) {
                    lines.push(line);
                });
                if (!Array.isArray(data)) {
                    emitter.emit('data', data);
                } else {
                    for ( var i = 0; i < data.length; ++i) {
                        emitter.emit('data', data[i]);
                    }
                }
                emitter.emit('end');
                return lines;
            };
        },
        '1回だけデータ受信イベントが発生し' : {
            'データがundefinedの場合' : {
                topic : function(topic) {
                    return topic(undefined);
                },
                '1回もコールバックされないこと' : function(topic) {
                    assert.length(topic, 0);
                }
            },
            'データがnullの場合' : {
                topic : function(topic) {
                    return topic(null);
                },
                '1回もコールバックされないこと' : function(topic) {
                    assert.length(topic, 0);
                }
            },
            'データが空文字列の場合' : {
                topic : function(topic) {
                    return topic('');
                },
                '1回もコールバックされないこと' : function(topic) {
                    assert.length(topic, 0);
                }
            },
            'データが改行を含まない文字列の場合' : {
                topic : function(topic) {
                    return topic('abcdef');
                },
                '1回だけコールバックされること' : function(topic) {
                    assert.length(topic, 1);
                },
                'データ全体を文字列としてコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abcdef');
                }
            },
            'データが改行で始まる文字列の場合' : {
                topic : function(topic) {
                    return topic('\nabc');
                },
                '2回コールバックされること' : function(topic) {
                    assert.length(topic, 2);
                },
                '最初に空行がコールバックされること' : function(topic) {
                    assert.equal(topic[0], '');
                },
                '次に改行の後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'abc');
                }
            },
            'データが改行で終了する文字列の場合' : {
                topic : function(topic) {
                    return topic('abcdef\n');
                },
                '1回だけコールバックされること' : function(topic) {
                    assert.length(topic, 1);
                },
                '改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abcdef');
                }
            },
            'データが途中に改行を含む文字列の場合' : {
                topic : function(topic) {
                    return topic('abc\ndef');
                },
                '2回コールバックされること' : function(topic) {
                    assert.length(topic, 2);
                },
                '最初に改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abc');
                },
                '次に改行の後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'def');
                }
            },
            'データが途中に改行を二つ含む文字列の場合' : {
                topic : function(topic) {
                    return topic('abc\ndef\nghi');
                },
                '3回コールバックされること' : function(topic) {
                    assert.length(topic, 3);
                },
                '最初に一番目の改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abc');
                },
                '次に一番目の改行と二番目の改行の間の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'def');
                },
                '最後に二番目の改行より後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[2], 'ghi');
                }
            }
        },
        '2回データ受信イベントが発生し' : {
            'どのデータにも改行が含まれない場合' : {
                topic : function(topic) {
                    return topic( [ 'abc', 'def' ]);
                },
                '1回だけコールバックされること' : function(topic) {
                    assert.length(topic, 1);
                },
                '一番目のデータと二番目のデータを連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abcdef');
                }
            },
            '最初のイベントにだけ改行が含まれる場合' : {
                topic : function(topic) {
                    return topic( [ 'abc\ndef', 'ghi' ]);
                },
                '2回コールバックされること' : function(topic) {
                    assert.length(topic, 2);
                },
                '最初に一番目のデータの改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abc');
                },
                '次に一番目のデータの改行より後と二番目のデータを連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'defghi');
                }
            },
            '二番目のイベントにだけ改行が含まれる場合' : {
                topic : function(topic) {
                    return topic( [ 'abc', 'def\nghi' ]);
                },
                '2回コールバックされること' : function(topic) {
                    assert.length(topic, 2);
                },
                '最初に一番目のデータと二番目のデータの改行より前を連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abcdef');
                },
                '次に二番目のデータの改行より後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'ghi');
                }
            },
            '一番目と二番目の両方のイベントに改行が含まれる場合' : {
                topic : function(topic) {
                    return topic( [ 'abc\ndef', 'ghi\njkl' ]);
                },
                '3回コールバックされること' : function(topic) {
                    assert.length(topic, 3);
                },
                '最初に一番目のデータの改行より前の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[0], 'abc');
                },
                '次に一番目のデータの改行より後と,二番目のデータの改行より前を連結した文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[1], 'defghi');
                },
                '最後に二番目のデータの改行より後の文字列がコールバックされること' : function(topic) {
                    assert.equal(topic[2], 'jkl');
                }
            }
        }
    },
    '実際に読み込んだファイルが' : {
        topic : function() {
            return function(fileName) {
                var lines = [];
                var promise = new (events.EventEmitter);
                var stream = fs.createReadStream(fileName);
                stream.setEncoding('utf-8');
                read_line.onDataByLine(stream, function(line) {
                    lines.push(line);
                });
                stream.on('end', function() {
                    promise.emit('success', lines);
                });
                return promise;
            };
        },
        '空のファイルの場合' : {
            topic : function(topic) {
                return topic('test0.txt');
            },
            '1回もコールバックされないこと' : function(topic) {
                assert.length(topic, 0);
            }
        },
        '1行のファイルの場合' : {
            topic : function(topic) {
                return topic('test1.txt');
            },
            '1回だけコールバックされること' : function(topic) {
                assert.length(topic, 1);
            },
            '行全体がコールバックされること' : function(topic) {
                assert.equal(topic[0], 'abc');
            }
        },
        '2行で最後の行が改行で終わらないファイルの場合' : {
            topic : function(topic) {
                return topic('test2.txt');
            },
            '2回コールバックされること' : function(topic) {
                assert.length(topic, 2);
            },
            '最初に1行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[0], 'abc');
            },
            '次に2行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[1], 'def');
            }
        },
        '空行を含めて3行で最後の行が改行で終わるファイルの場合' : {
            topic : function(topic) {
                return topic('test3.txt');
            },
            '3回コールバックされること' : function(topic) {
                assert.length(topic, 3);
            },
            '最初に1行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[0], 'abc');
            },
            '次に2行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[1], '');
            },
            '最後に3行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[2], 'def');
            }
        },
        '空行を含めて4行で最後の行が改行のみの空行で終わるファイルの場合' : {
            topic : function(topic) {
                return topic('test4.txt');
            },
            '4回コールバックされること' : function(topic) {
                assert.length(topic, 4);
            },
            '最初に1行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[0], 'abc');
            },
            '次に2行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[1], '');
            },
            '続けて3行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[2], 'def');
            },
            '最後に4行目の文字列がコールバックされること' : function(topic) {
                assert.equal(topic[3], '');
            }
        }
    }
}).export(module);

ついでに read-line.js も掲載.「その 2」の途中から修正してないけど.

exports.onDataByLine = function(stream, callback) {
    var line = '';
    stream.on('data', function(data) {
        if (data) {
            line += data;
            while (line.match(/\r?\n/)) {
                callback(RegExp.leftContext);
                line = RegExp.rightContext;
            }
        }
    });
    stream.on('end', function() {
        if (line) {
            callback(line);
        }
    });
};

これっぽっちのテスト対象にあのテストの記述量はどうなのよ? って思わなくもないけど気にしない!! 気にしない!!
実行します.

$ vows read-line-test.js
············································OK » 44 honored (0.014s)

--spec します.

$ vows read-line-test.js --spec

 read-line

  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データがundefinedの場合
    ✓ 1回もコールバックされないこと
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データがnullの場合
    ✓ 1回もコールバックされないこと
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが空文字列の場合
    ✓ 1回もコールバックされないこと
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが改行を含まない文字列の場合
    ✓ 1回だけコールバックされることデータ全体を文字列としてコールバックされること
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが改行で始まる文字列の場合
    ✓ 2回コールバックされること最初に空行がコールバックされること次に改行の後の文字列がコールバックされること
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが改行で終了する文字列の場合
    ✓ 1回だけコールバックされること改行より前の文字列がコールバックされること
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが途中に改行を含む文字列の場合
    ✓ 2回コールバックされること最初に改行より前の文字列がコールバックされること次に改行の後の文字列がコールバックされること
  偽のEventEmitterを使って 1回だけデータ受信イベントが発生し データが途中に改行を二つ含む文字列の場合
    ✓ 3回コールバックされること最初に一番目の改行より前の文字列がコールバックされること次に一番目の改行と二番目の改行の間の文字列がコールバックされること最後に二番目の改行より後の文字列がコールバックされること
  偽のEventEmitterを使って 2回データ受信イベントが発生し どのデータにも改行が含まれない場合
    ✓ 1回だけコールバックされること一番目のデータと二番目のデータを連結した文字列がコールバックされること
  偽のEventEmitterを使って 2回データ受信イベントが発生し 最初のイベントにだけ改行が含まれる場合
    ✓ 2回コールバックされること最初に一番目のデータの改行より前の文字列がコールバックされること次に一番目のデータの改行より後と二番目のデータを連結した文字列がコールバックされること
  偽のEventEmitterを使って 2回データ受信イベントが発生し 二番目のイベントにだけ改行が含まれる場合
    ✓ 2回コールバックされること最初に一番目のデータと二番目のデータの改行より前を連結した文字列がコールバックされること次に二番目のデータの改行より後の文字列がコールバックされること
  偽のEventEmitterを使って 2回データ受信イベントが発生し 一番目と二番目の両方のイベントに改行が含まれる場合
    ✓ 3回コールバックされること最初に一番目のデータの改行より前の文字列がコールバックされること次に一番目のデータの改行より後と,二番目のデータの改行より前を連結した文字列がコールバックされること最後に二番目のデータの改行より後の文字列がコールバックされること
  実際に読み込んだファイルが 空のファイルの場合
    ✓ 1回もコールバックされないこと
  実際に読み込んだファイルが 1行のファイルの場合
    ✓ 1回だけコールバックされること行全体がコールバックされること
  実際に読み込んだファイルが 2行で最後の行が改行で終わらないファイルの場合
    ✓ 2回コールバックされること最初に1行目の文字列がコールバックされること次に2行目の文字列がコールバックされること
  実際に読み込んだファイルが 空行を含めて4行で最後の行が改行のみの空行で終わるファイルの場合
    ✓ 4回コールバックされること最初に1行目の文字列がコールバックされること次に2行目の文字列がコールバックされること続けて3行目の文字列がコールバックされること最後に4行目の文字列がコールバックされること
  実際に読み込んだファイルが 空行を含めて3行で最後の行が改行で終わるファイルの場合
    ✓ 3回コールバックされること最初に1行目の文字列がコールバックされること次に2行目の文字列がコールバックされること最後に3行目の文字列がコールバックされることOK » 44 honored (0.025s)

幸せな気分になりました♪
最後の二つのコンテキスト (ファイルが 3 行と4 行のやつ),表示の順番が記述の順番と異なっていることに注意.しかも,実行を繰り返すごとに異なってたりします.
これは,Vows がテスト (コンテキスト) を非同期に実行している証でしょう.表示順はテストの実行が完了する順番で,それはファイルの読み込みが完了する順番に依存しているわけです.Vows△!



説明を文字列で書く辺りとか記述量が多くなりそうなところで好き嫌い別れそうではありますが,Vows はなかなか素敵なテスティングフレームワークだと思います.
非同期イベントのテストも思ってた以上にあっさりと書けましたが,これはテスト対象であるトピックと,検証コードである vow を分けて記述しているおかげでしょう.そしてトピックが検証可能になった時,Vows が vow を呼び出してくれるわけです.まさにイベント駆動.
Node.js のテスティングフレームワークとしては他にも "Promise-based asynchronous test runner" なんていうカッコよさげな「patr」も興味深いのですが,

しばらくは浮気せずに Vows を足場に Node.js を楽しみたいと思ってます.