HogeFugaHogera

IT系の備忘録とか、他徒然なるままに

Node.jsにおける非同期コードデザイン

Node.jsを調べ始めるとよく目に付く非同期という言葉。いまいち分からないこの言葉を理解するために、Asynchronous Code Design with Node.js (The Shine blog)を読んでみました。かなり分かり易い内容だったので、自分の為に訳したものを載せてみます。英語は不得手なため、誤訳等あればご連絡ください。

以下、訳文となります。


はじめに

Node.jsの非同期イベント駆動I/Oは、マルチスレッドエンタープライズアプリケーションサーバの伝統的な同期I/Oとは異なる高性能なものとして、現在多くの企業により評価されている。非同期は、企業の開発者が新たなプログラミングパターンを習得し、古いパターンを忘れなければならないことを、自然に意味している。彼らは思いっきり頭を切り替えなければならないし、電気ショックが必要かもしれない。この記事では、古い同期プログラミングパターンを、まったく新しい非同期プログラミングパターンに入れ替える方法を示す。

切り替えのはじまり (Start Rewiring)

Nord.jsを動かす為には、非同期手法の理解が必要不可欠である。非同期コードデザインは単純な事ではなく、いくつか学習が必要である。今こそ電気ショックの時である: 同期コードの例は、それを非同期コードにする手順を示すため対照的に、非同期コードと一緒に載せられる。全ての例は、Node.jsのfile-sytem(fs)モジュールを中心として動いており、それはこのモジュールが同期I/Oと非同期I/Oを含む唯一のものだからである。2つの異なる例を用いることで、頭の切り替えが始められる。

依存、非依存コード (Dependent and Independent Code)

コールバック関数は、Node.jsにおける非同期イベント駆動プログラミングの基本的な構成要素である。それらは非同期I/O処理に引数として渡される関数であり、処理終了時に一度だけ呼ばれる。コールバック関数は、Nodo.jsにおける独立したイベントなのである。

以下では、同期I/Oから非同期I/Oに変更する方法と、コールバックの使い方を示す。例では、同期であるfs.readdirSync()関数を使い現ディレクトリにあるファイルの名前を読み、コンソールにそのファイル名を出力し、現プロセスのプロセスIDを読み込む。

  • 同期
var fs = require('fs'),
    filenames,
    i,
    processId;

filenames = fs.readdirSync(".");
for (i = 0; i < filenames.length; i++) {
    console.log(filenames[i]);
}
console.log("Ready.");

processId = process.getuid();
  • 非同期
var fs = require('fs'),
    processId;

fs.readdir(".", function (err, filenames) {
    var i;
    for (i = 0; i < filenames.length; i++) {
        console.log(filenames[i]);
    }
    console.log("Ready.");
});

processId = process.getuid();

同期の例では、fs.readdirSync()I/O処理によってCPUが待機する。なので、これは書き換える必要のある処理である。Nodo.jsにおける非同期版のその関数は、fs.readdir()である。これはfs.readdirSync()と同じであるが、第二引数にコールバック関数を持つ。

コールバック関数パターンを使用する際のルールはこうなる: 同期関数を対応する非同期関数に交換し、同期呼び出し後に実行されるコードをコールバック関数の中に配置する。非同期例のコールバック関数内コードは、同期例のコードと正確に同じである。この例では、ファイルネームをコンソールに出力する。このコールバック関数は、非同期I/O処理から操作が戻った後に実行する。

同様に、ファイルネームの出力はfs.readdirSync()I/O処理の結果に依存しており、それによってファイル名一覧が出力される。プロセスIDの記憶は、そのI/O処理の結果とは独立している。なので、非同期コードとは異なる場所に移されるべきである。

ルールは、コーツバック関数内に依存したコードを移す事と、そここら独立したたコードを分離する事である。依存したコードはI/O処理が終了した後一度だけ実行され、対して独立したコードはI/O処理が呼ばれたら直ぐに実行される。

逐次処理 (Sequences)

同期コードの標準的なパターンとは、逐次処理、すなわち、順番に全てが実行される幾つかのコードの行の事あり、なぜならば、一つ一つの行が、その前の行の処理結果に依存するからである。以下の例では、まず最初にファイルのアクセスモード(Unix chmodコマンドと同様)を変更し、ファイル名を変更し、それがシンボリックリンクなら、ファイル名が変更されたファイルかどうか確認する。確実に、このコードは順番にしか動かない。さもないと、モードが変更される前にファイル名が変更されるか、ファイル名が変更される前にシンボリックリンクのチェックがされてしまう。それは両方ともエラーが誘発される。なので、この順序は維持されなければならない。

  • 同期
var fs = require('fs'),
    oldFilename,
    newFilename,
    isSymLink;

oldFilename = "./processId.txt";
newFilename = "./processIdOld.txt";

fs.chmodSync(oldFilename, 777);
fs.renameSync(oldFilename, newFilename);

isSymLink = fs.lstatSync(newFilename).isSymbolicLink();
  • 非同期
var fs = require('fs'),
    oldFilename,
    newFilename;

oldFilename = "./processId.txt";
newFilename = "./processIdOld.txt";

fs.chmod(oldFilename, 777, function (err) {   
    fs.rename(oldFilename, newFilename, function (err) {
        fs.lstat(newFilename, function (err, stats) {
            var isSymLink = stats.isSymbolicLink();
        });
    });
});

同期例の逐次処理は、非同期コード上でネストされたコールバックに変換される。この例では,fs.chmod()コールバック内にネストされたfs.rename()コールバックの中にネストされた、fs.lstat()コールバックとなる。

並列化 (Parallelisation)

非同期コードは並列I/O処理にともて適している。すなわち、実行するコードはI/O呼び出しの返却中にブロックされない。複数のI/O処理は並列に開始される。以下の例では、ディレクトリ内全てのファイルの容量を、これらファイルの使用バイト数を合計するために ループ上で足し合わして算出している。同期コードでのループの繰り返し処理は、個々のファイルサイズを取得するためのI/O呼び出しが終わるまで待機する必要がある。

非同期コードは、結果を待たずに、ループ上で素早く連続して全ててのI/O呼び出しを行う。一つのI/O処理が完了した時は必ずコールバック関数が呼ばれ、全バイト数にファイルサイズを加算される。

唯一必要な事は、我々の処理が完了した事を判断する適切な停止条件を決める事であり、その条件により、全ファイルの合計バイトサイズの算出が完了となる。

  • 同期
var fs = require('fs');

function calculateByteSize() {
    var totalBytes = 0,
        i,
        filenames,
        stats;
    filenames = fs.readdirSync(".");
    for (i = 0; i < filenames.length; i ++) {
        stats = fs.statSync("./" + filenames[i]);
        totalBytes += stats.size;
    }
    console.log(totalBytes);
}

calculateByteSize();
  • 非同期
var fs = require('fs');

var count = 0,
    totalBytes = 0;

function calculateByteSize() {
    fs.readdir(".", function (err, filenames) {
        var i;
        count = filenames.length;

        for (i = 0; i < filenames.length; i++) {
            fs.stat("./" + filenames[i], function (err, stats) {
                totalBytes += stats.size;
                count--;
                if (count === 0) {
                    console.log(totalBytes);
                }
            });
        }
    });
}

calculateByteSize();

同期例は素直な例である。非同期バージョンは、まずディレクトリ内のファイルを読むために fs.readdir()が呼ばれる。コールバック関数内のfs.stat()は、それぞれのファイルに対して、そのファイルの統計値を得る為に呼ばれる。この部分は想定通りであろう。

興味深い事は、fs.stat()のコールバック関数内で起こり、そこでは合計バイト数が算出される。停止条件に使用されるのは、ディレクトリ内のファイル数である。 count変数はファイル数で初期化され、コールバック関数が呼ばれた回数分だけ減少する。全てのI/O処理が呼ばれるとカウントは0になり、全ファイルの合計バイト容量の計算が完了する。そして、バイト容量がコンソールに出力される。

非同期例は他にも興味深い特徴がある。それは、クロージャの使用である。クロージャとは関数内の関数であり、外側の関数で宣言された変数には、その関数が終了後でも、内側の関数からアクセスできる。fs.stat()のコールバック関数はクロージャであるから、fs.readdir()が終了して長時間たった後でも、そのコールバック関数内で宣言されたcount変数とtotalByte変数に、fs.stat()のコールバック関数からアクセスできる*1クロージャというものは、自分を覆うようにコンテキストを持つ。このコンテキスト内の変数は、その中の関数(クロージャ)内からアクセスできる変数となれる。

counttotalByte変数はクロージャに含まず、グローバル領域に作らざるえない。なぜならfs.stat()のコールバック関数が、変数を置くための任意のコンテキストを持たないからである。calculateBiteSize()関数は長い間終わったが、グローバルなコンテキスト自体は、ここでまでである*2。ここは、クロージャが解放される位置である。変数はこのコンテキスト内(グローバル領域)に設置できるので、関数の中からアクセス可能となる。

コードの再利用 (Code Reuse)

コードの断片は、JavaScriptにおいては関数内にそれらをラッピングして再利用できる。すると、これら関数はプログラム内の別の場所から呼び出す事ができる、その関数内でI/O処理が使われているなら、非同期コードに書き換える際に、リファクタリングが必要となる。

以下の同期例は、与えられたディレクトリ内のファイル数を返す関数countFiles()を示す。countFiles()は、ファイル数を特定するためにfs.readdirSync()I/O処理をを使用する。countFiles()それ自身は、2つの異なった入力パワメータで呼ばれる。

  • 同期
var fs = require('fs');

var path1 = "./",
    path2 = ".././";

function countFiles(path) {
    var filenames = fs.readdirSync(path);
    return filenames.length;
}

console.log(countFiles(path1) + " files in " + path1);
console.log(countFiles(path2) + " files in " + path2);
  • 非同期
var fs = require('fs');

var path1 = "./",
    path2 = ".././",
    logCount;

function countFiles(path, callback) {
    fs.readdir(path, function (err, filenames) {
        callback(err, path, filenames.length);
    });
}

logCount = function (err, path, count) {
    console.log(count + " files in " + path);
};

countFiles(path1, logCount); 
countFiles(path2, logCount);

fs.readdirSync()を非同期のfs.readdir()を置き換える事は、その囲んで切る関数countFiles()も非同期のコールバックをもつ関数にすることであり、なぜならcountFiles()の呼び出すコードが fs.readdir()の結果に依存するためである。そうであるから、全ての結果はfs.readdir()終了後に得る。この事は、countFiles()をコールバック関数を使えるように再構成をさせる。全体の制御フロ- は、下まで進むと突然フローの先頭に遷移する。その順序を、以下に示す。

  • 同期

    1. console.log()
    2. countFiles()
    3. fs.readdirSync()
  • 非同期

    1. countFiles()
    2. fs.readdir()
    3. console.log()

まとめ

この記事では非同期プログラミングにおける幾つかの基本的パターンを強調している。非同期プログラムに頭を切り替えることは、決して簡単な事ではなく、時間をかけて習熟できるものである。この複雑性が増すことの対価は、同時実行に対する劇的な進歩である。迅速かつ手軽にJavaScriptを使用することに加え、とりわけ新しく誕生した高い同時性のあるWeb 2.0 アプリケーションにおいて、Nodo.jsの非同期プログラミングはエンタープライズアプリケーションの市場における著しい可能性を持っている。

資料

*1:と書いてあるが、同期例ではcount変数とtotalByte変数がグローバル領域に宣言されている。直下のパラグラフで、グローバル領域に作らざるえないと書いてあるのだが、実際のところfs.readdir()のコールバック内に宣言しても作動する。原文のコメント欄に同じ様な質問はあるのだが、回答がない...。

*2:原文「The calculateBiteSize function has long ended, only the global context is still there.」has long endedの意味が取れないので直訳とした結果、まったく意味が分からない文章となった。多分、「グローバルなコンテキストが終わっても、calculateBiteSize()の実行は続いている」と言いたいのではと思う。そうなれば、この時点でクロージャが解放される=calculateBiteSize()が確保している変数が解放される。と言えるので、注釈1で示したfs.readdir()のコールバック内にcount変数とtotalByte変数を宣言した場合、fs.lstat()のコールバックが呼び出される時点で、両変数が既に解放されている可能性がある。しかし、私はJavaScriptの仕組みと、エンジンごとの実装に明るくないので、これはあくまで推論である。