ぼくたちがフロントエンドエンジニアと呼ばれるまで

Node.jsでネスト地獄を脱出した話

連日の猛暑日で辛いですが、皆様よきNode.jsライフをお過ごしでしょうか。

リアルタイムで軽快に動作するNode.jsで開発していると暑さも吹き飛ぶような気持ちになると思いますが、ひとつだけとても厄介な問題がありますよね。

そう、ネスト地獄です。

今回はこのNode.jsに特徴的なネスト地獄(コールバック地獄)をどのように脱出したのかという話になります。

既に脱出済みの方は軽く読み流す程度で充分な内容となりますが、絶賛ハマり中の方には有意義な内容になれば幸いです。

*記事内に出てくる関数名、変数などはすべて仮です。

コールバックなんて怖くない!

Node.jsはノンブロッキングI/Oと言われる仕組みで動作します。一つのプロセスを開始したら、結果を待たずにどんどん次のプロセスを開始していく仕組みですね。

つまり、基本的には非同期処理で記述するように設計されています。ただし、それだとある処理の結果Aを利用して別のタスクBを呼び出したい、、というよくあるプログラムに対応できないですよね。

// 非同期に二倍するメソッド
function double(num) {
    setTimeout(() => {
        return num * 2;
    }, 1000);
}

var result = double(2);
console.log(result); // → 4...ではなく"undefined"?!

これが「非同期」たるゆえんです。
そこで出てくるのがJavascriptによく触れる方であればおなじみのコールバックという仕組みです。

// コールバックver
function double(num, callback) {
    setTimeout(() => {
        var result = num * 2;
        callback(result);
    }, 1000);
}

double(2, (result) => {
    console.log(result); // → 4 想定通り
});

このコールバック、非同期の効率性を担保しながら同期的な処理を行ないたい上では避けて通れない方法ですし、上記のような単純な処理であればまったく気になりません。

しかし、 少し複雑な処理を行なうと途端に読みづらくなります。

ネストが止まらない・・・地獄に堕ちるのか

例えば、同じ関数を利用して「64」を出力したいとして、5回繰り返せばいいことになります。
(実際の現場でも、やむを得ずそのような処理になることがあります。)

double(2, (result1) => {
    // 4
    double(result1, (result2) => {
        // 8
        double(result2, (result3) => {
            // 16
            double(result3, (result4) => {
                // 32
                double(result4, (finalAnswer) => {
                    console.log(finalAnswer) // 64!
                });
            });
        });
    });
});

うん、見づらい。

実際にはエラー処理などが必要になるので、以下のようになります。

// エラー判定付きのケース
function double(num, callback) {
    setTimeout(() => {
        var result;
        var error;

        // 数値以外の入力があればエラーを返す
        if (typeof num !== "number") {
            error = 'only num';
            callback(error, result);
            return;
        }

        // 正常
        result = num * 2;
        callback(error, result);
        
    }, 1000);
}

double(2, (error, result1) => {
    if (error) {
        console.error(error); // only num 
        return;
    }
    // 4
    double(result1, (error, result2) => {
        if (error) {
            console.error(error); // only num 
            return;
        }
        // 8
        double(result2, (error, result3) => {
            if (error) {
                console.error(error); // only num 
                return;
            }
            // 16
            double(result3, (error, result4) => {
                if (error) {
                    console.error(error); // only num 
                    return;
                }
                // 32
                double(result4, (error, finalAnswer) => {
                    if (error) {
                        console.error(error); // only num 
                        return;
                    }
                    console.log(finalAnswer) // 64!
                });
            });
        });
    });
});

もはや悪い冗談か何かでしょうか。

実際に、以下のようなデメリットが考えられます:
・エラー処理が煩雑、冗長。記入漏れとかありそう
→ バグの温床と化す

・あとから「やっぱresult3いらなくなったから削っといて」と言われても非常に修正しづらい
→ メンテナンス性が悪い

・インデントによりコーディング可能な領域がどんどん狭くなる
→ 読みづらい、書きづらい

…あぁ地獄だ。

このまま抜け出すことはできないのでしょうか。

自由になろう!

やはりこのままではいけない。
まずは標準で搭載されているPromiseを試してみました。
Promiseについての詳細は公式の解説を読んでいただくとして、簡単にいうと結果を正常に受け取るとthen文で指定したメソッドが走り、失敗した場合にはcatch文が呼ばれるという決まりがあります。

// Promiseバージョン
function doublePromise(num) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            var result;
            var error;

            // 数値以外の入力があればエラーを返す
            if (typeof num !== "number") {
                error = 'only num';
                reject(error);
                return;
            }

            // 正常
            result = num * 2;
            resolve(result);

        }, 1000);
    });
}

doublePromise(2).then((result1) => {
    // 4
    return doublePromise(result1); 
}).then((result2) => {
    // 8
    return doublePromise(result2); 
}).then((result3) => {
    // 16
    return doublePromise(result3);
}).then((result4) => {
    // 32
    return doublePromise(result4);
}).then((finalAnswer) => {
    console.log(finalAnswer) // 64
}).catch((error) => {
    console.error(error); // only num
});

thenチェーンと呼ばれる書き方ですが、どれだけ非同期処理を重ねてもネストがこれ以上深くならないので安心です。
また、エラー処理をcatch文にまとめることができましたね!

ただし、見てもらうと分かるようにreturn文や括弧がやたら多く、読みにくさはあまり変わらないと思います。
まだ何かが足りない・・・

やはり世の中の人もそう思うのでしょう、GeneratorFunction(generator/yield)という仕組みがあるんです。
例によって詳しい話は他の記事や公式の解説をあたっていただきたいと思います。
ここではコード内の好きな位置でアクセルとブレーキを踏めるような仕組みと思っていただければOKです。
このGeneratorFunctionの仕組みを前述のPromiseと併用しやすいようにラップしてくれているのがcoと呼ばれるライブラリです。
これを使うと、、

co(function*() {
    // ↓順番に処理されていく
    var result1 = yield doublePromise(2); // 4
    var result2 = yield doublePromise(result1); // 8
    var result3 = yield doublePromise(result2); // 16
    var result4 = yield doublePromise(result3); // 32

    var finalAnswer = yield doublePromise(result4);
    console.log(finalAnswer) // 64

}).catch((error) => {
    console.error(error); // only num
});

yieldなどの見慣れない表現に慣れてさえしまえば、今までの方法に比べてかなり直観的なコードになったのではないかと思います。
(注意点として、yieldする関数はPromiseオブジェクトを返却するようにしておくこと)

ちなみに、nodeの最新バージョンではライブラリを使わずとも標準で同様の仕組みが搭載されています。
async/awaitという仕組みなので、そちらを用いた書き方は別途参照してください!

脱出成功、おめでとうございます!

結果として、
・エラー処理をcatch文一か所で済ますことができる
→ 処理の統一&記述漏れ防止。

・追加の処理も手続き型言語のようにyield文を一つ追加するだけで済む
→ 単純明快!

・インデントがすっきりして見通しが良くなる
→バグが混在しにくい

など、いいことづくめです。

改めまして、脱出おめでとうございます!

まとめと予告

単純な処理であれば従来のコールバックでもいいですが、場合によってはネストが深くなり非常に扱いづらい処理もありますね。
そういったときに試していただきたい方法の紹介でした。

第4回
Angular1 基本編
– はじめに(Angular2, Angular4がリリースされたのにAngular1を使っている経緯。Angular2への移行を見送った話)
– TypeScriptでコーディングする(静的型付けの重要性)
– TypeScriptの型定義ファイルについて(@typesパッケージを使用。@typeで提供されていないモジュールや自作した型定義についてはtypingsを使用)
– ライブラリーについて(jQueryとの共存)(基本はnpmリポジトリを使用。npmに存在せずbowerにのみ存在する場合のみbowerを使用)
– ディレクトリー構成(サンプル紹介)
– 自動コンパイルについて(gulpタスクの紹介)
– minify対策について(対策しないとエラーになる話)

この記事を書いた人・プロフィール
オミー
ニックネーム: オミー

フランス生まれ関西育ち。新卒で上京して営業会社に入社。2年ほど経ったあたりでIT業界に強く惹かれてカタリストシステムに入社。主にフロントエンド側の技術に興味があります。
最近は趣味をテニスにしようと考えている。