JavaScriptエンジニアなら知ってるよね? エラー処理のいい書き方、悪い書き方

2016/05/30

Camilo Reyes

681

Articles in this issue reproduced from SitePoint
Copyright © 2016, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2016, KADOKAWA CorporationJapanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

JavaScriptのエラー処理、ちゃんと書いていますか? エラーを無視せず、どこに問題があるのか、きちんと確認できるコードの書き方をデモで紹介。

この記事はTim SeverienMoritz Krögerが査読を担当しています。最良の記事を提供することができ、SitePointの査読担当者の皆さんに感謝します。

JavaScriptのエラー処理には危険が潜んでことを知っていますか? もしマーフィーの法則を信頼しているとしたら、不具合が生じる可能性が本当に高いです! この記事では、JavaScriptのエラー処理について考え、その落とし穴から便利な実践例までを説明します。さらに最後には、非同期コードとAjaxにも触れます。

JavaScriptはイベント駆動型プログラムで、プログラミングをより豊かなものにしてくれます。ブラウザーをイベント駆動型プログラムと考えると、発生するエラーは同一のものとなります。エラーが発生した際、あるポイントでイベントが発生します。理論上、JavaScriptでのエラーは単純なイベントともいえます。もし、そんなことは聞いたことがないと思っても、騙されたと思って続きを読んでみてください。この記事では、クライアントサイドのJavaScriptにのみ焦点をあてています。

この記事はExceptional Exception Handling in JavaScriptという記事の中で説明されたコンセプトに基づいて書かれています。つまり、「エラーが発生した場合、JavaScriptはコールスタックに対処しならがら、エラーを検索する」ということです。このことに得心がいかなければ、基本から勉強することをお勧めします。ここでは、エラー対処の応用編を説明します。

デモ

この記事で使用するデモはGitHubで入手可能です。デモを実行すると次のようなページが表示されます。

01

どのボタンをクリックしても「ボム」が起動します。このボムはTypeErrorとして作られたエラーをシミュレーションします。次のコードはこのようなユニットテストのモジュール定義です。

function error() {
    var foo = {};
    return foo.bar();
}

この関数はfooと呼ばれる空のオブジェクトを指定します。bar()の定義がどこにもないことに注意してください。では、この関数がユニットテストでボムを起動できるか確かめてみましょう。

it('throws a TypeError', function () {
    should.throws(target, TypeError);
});

このユニットテストはShould.jsでアサーションをMochaに記録しています。Should.jsがアサーションライブラリなのに対し、Mochaはテストランナーです。もし、よく分からなければ、APIsテストを参照してください。テストはit('description')から始まり、shouldのパスまたは失敗で終わります。ここで朗報ですが、ユニットテストはノード上で動作し、ブラウザーを必要としません。ユニットテストに注目するのは、JavaScriptでのキーコンセプトだからです。

上のように、error()は空のオブジェクトを定義してから、メソッドにアクセスしようとします。bar()error()が引き起こすエラーのオブジェクトに存在しないからです。心配しないでください。JavaScriptのような動的なプログラミング言語では、そういったことは普通に起こる場合があるのです。

悪いエラーハンドリング

悪いエラーハンドリングについて説明しましょう。実装されたデモからエラーハンドリングを取り出します。以下のコードで、ユニットテストの処理を見てみましょう。

function badHandler(fn) {
    try {
        return fn();
    } catch (e) { }
    return null;
}

この処理は、依存関係としてfnコールバックを受け取ります。そして、この依存関係は処理関数の内部に呼び出され、ユニットテストは、どのように使われるのか示します。

it('returns a value without errors', function() {
    var fn = function() {
        return 1;
    };
    var result = target(fn);
    result.should.equal(1);
});

it('returns a null with errors', function() {
    var fn = function() {
        throw Error('random error');
    };
    var result = target(fn);
    should(result).equal(null);
});

見ての通り、この処理は何か問題が生じた場合、nullを返し、fn()コールバックは正式なメソッドやボムを示します。以下のクリック処理が続きです。

(function (handler, bomb) {
    var badButton = document.getElementById('bad');

    if (badButton) {
        badButton.addEventListener('click', function () {
            handler(bomb);
            console.log('Imagine, getting promoted for hiding mistakes');
        });
    }
}(badHandler, error));

ここで問題なのはnullを取得すると、エラーの特定ができなくなることです。このようなフェイルサイレント戦略は、悪いUXからデータの破壊にまでおよんでしまいます。厄介なのは何時間もかけて症状をデバッグしても、try-catchブロックを認識できないことです。このような処理はコード内のエラーを取り込んで、あたかもすべて問題がないかのように見せてしまうのです。コード品質を追求しない組織には向いているかもしれませんが、エラーが特定できないせいで何時間もデバックが必要になるでしょう。コールスタックが深くなる多層なソリューションでは、エラーの発生した箇所を特定するのは不可能でしょう。まれにtry-catchが有効な場合があるかもしれませんが、エラー処理に関する限りは悪いことでしかありません。

フェイルサイレント戦略は、より良いエラーハンドリングの妨げとなってしまいます。JavaScriptはこのような問題に対処する、さらに有効な方法を提供しています。

醜いエラーハンドリング

続いて、醜いエラーハンドリングについてです。ここでは、DOMに深く関わる事柄については触れないことにします。というのも、先の悪いエラーハンドリングと何ら違いはないからです。重要なのは、以下のように、ユニットテストにおいてひどい処理がエラーをどのように収拾するかということです。

function uglyHandler(fn) {
    try {
        return fn();
    } catch (e) {
        throw Error('a new error');
    }
}

it('returns a new error with errors', function () {
    var fn = function () {
        throw new TypeError('type error');
    };
    should.throws(function () {
        target(fn);
    }, Error);
});

悪いエラーハンドリングからは明らかな改善が見られます。エラーがコールスタックを呼び出すからです。良いところは、エラーはデバッグに非常に便利なスタックをアンワインドすることです。エラーが発生しても、インタープリターは別の処理を検索してスタックを移動します。こうして、コールスタックのトップでエラーに対処することが多くなります。私は、不幸にもこのひどい処理で、最初のエラーを見つけられませんでした。したがって、最初のエラーを特定するスタックをトラバースし直さなくてはいけませんでしたが、少なくともエラーが起きたことは認識できました。

醜いエラーハンドリングは有害ではありませんが、コードスメルを引き起こします。ブラウザーがこれに対処する機能を搭載しているか確認してみてください。

スタックのアンワインド

エラーをアンワインドする方法の1つに、コールスタックのトップにtry...catchを入れるというものがあります。例えば次を見てください。

function main(bomb) {
    try {
        bomb();
    } catch (e) {
        // Handle all the error things
    }
}

ここで考えて欲しいのは、ブラウザーはイベント駆動型プログラムなのかということです。答えはイエスです。JavaScriptにおけるエラーはイベントに他なりません。インタープリターは現在実行中のコンテキストとアンワインドを停止します。結果として、ユーザーが活用可能なonerrorグローバルイベントハンドラーが、次のようになります。

window.addEventListener('error', function (e) {
    var error = e.error;
    console.log(error);
});

このイベントハンドラーは実行中のコンテキストでエラーを認識します。エラーイベントは、何らかの種類のエラーとみなされ、さまざまなターゲットから実行されます。非常に画期的なこととして、そのイベントハンドラーはコード内でエラーハンドリングを集中します。他のイベントと同じく、特定のエラーを認識するために、デイジーチェーンハンドラーになります。これにより、SOLIDの原理に従う場合、エラーハンドラーは単一の目的になります。このようなハンドラーはいつでも登録できます。従って、インタープリターはできるだけ多くのハンドラーを循環し、コードベースはいたるところにあるtry...catchブロックから解放され、デバッグが容易になります。重要なのは、JavaScriptにおいてはエラーハンドリングをイベントハンドリングと同じように扱うことでしょう。

グローバルハンドラーでスタックをアンワインドする方法を見てきましたが、何が可能となるのでしょうか? つまり、コールスタックが常に利用可能になるということかもしれません。

スタックのキャプチャー

コールスタックはトラブルシューティングに非常に便利です。しかも、ブラウザーがトラブルを表示します。エラーオブジェクトのstackプロパティーは標準ではありませんが、最新のブラウザーでは使えます。

このプロパティーの便利なことは、エラーをサーバーに記憶するということです。

window.addEventListener('error', function (e) {
    var stack = e.error.stack;
    var message = e.error.toString();
    if (stack) {
        message += '\n' + stack;
    }
    var xhr = new XMLHttpRequest();
    xhr.open('POST', '/log', true);
    xhr.send(message);
});

コードでは明らかではありませんが、このイベント処理はプロパティーとともに実行します。どの処理も、DRYコードを持つ単一の目的になります。ここで便利なのは、このようなメッセージがサーバー上で取得される仕組みです。

ノードにおいて、メッセージはこのように表示されます。

02

このメッセージはFirefox Developer Edition 46からのものです。適切なエラー処理では、何が問題であるかが驚くほど簡単に認識できます。ミスを隠す必要はありません。一目見れば、何が、どこでエラーを引き起こしたのかが分かるからです。このような透明性はフロントエンドコードのデバッグに非常に役に立ちます。メッセージは、あとあと復旧できるように永続的にストレージに蓄積され、どのような状況がどのようなエラーを引き起こすか理解を深められます。

コールスタックはデバッグに驚くほど便利なものであり、その潜在性をあなどってはいけません。

非同期処理

非同期処理の危険性について説明しましょう! JavaScriptは実行中のコンテキストから非同期コードを取り込みます。つまり、以下に示されるようなtry...catchステートメントには問題があるということです。

function asyncHandler(fn) {
    try {
        setTimeout(function () {
            fn();
        }, 1);
    } catch (e) { }
}

続きはこのユニットテストを見れば分かります。

it('does not catch exceptions with errors', function () {
    var fn = function () {
        throw new TypeError('type error');
    };
    failedPromise(function() {
        target(fn);
    }).should.be.rejectedWith(TypeError);
});

function failedPromise(fn) {
    return new Promise(function(resolve, reject) {
        reject(fn);
    });
}

エラーを特定するために、プロミス処理でラップしなければなりませんでした。良いとされるtry...catch周辺にコードブロックがありますが、対処されていないエラーが起きていることに注意しなければいけません。try...catchステートメントは単一の実行コンテキスト内でのみ動作します。エラーが発生するまでに、インタープリターはtry...catchから移動しました。同じ現象はAjaxコールでも発生します。そのため、2つの選択肢があります。その1つは非同期コールバック内で例外を特定することです。

setTimeout(function () {
    try {
       fn();
    } catch (e) {
        // Handle this async error
    }
}, 1);

このアプローチは有効かもしれませんが、もう少し改善できます。まず、try...catchブロックはあちらこちらで、もつれています。実際に1970年代のプログラコードを戻そうとしています。さらに、V8エンジンはtry…catch blocks inside functionsの使用を妨げます(V8はChromeやNodeで使用されるJavaScriptのエンジン)。2つ目の選択肢として、コールスタックのトップにそのようなブロックの入力が推奨されます。

このことから、ユーザーにどのようなメリットが生まれるのでしょうか。実行中のコンテキスト内で、グローバルエラー処理が動作すると言った理由がこれです。エラー処理をウィンドウオブジェクトへ追加するにしても、その必要はありません! DRYやSOLIDのままににしておいても、うまくいくなんて嬉しくないですか? グローバルエラー処理は便利ですっきりとしたコードを書く手助けになります。

次の図は、この例外処理がサーバー上で何を記録するのかの表示です。デモコードを使う場合、表示画面は使用するブラウザーによって多少異なるので注意してください。

03

この処理によりエラーは非同期コードとsetTimeout()で発生していることが分かりました。素晴らしいですね!

まとめ

エラー処理の分野では、少なくとも2つのアプローチがあります。1つはコード内でエラーを無視するフェイルサイレント手法です。もう1つはエラーが実行しているコンテクストを停止して元に戻す、フェイルファストアンワインド手法です。おそらくどちらが便利な手法でなぜそうなのかは明らかだと思います。私ならもちろん、エラーを無視しない方法を選択します。プログラム内で生じるアクシデントを理由に責める人などいません。いったん停止し、元に戻し、ユーザーに別の選択肢を示すのも良いでしょう。完璧などない世の中なので、重要なのはセカンドチャンスがあることではないでしょうか。エラーは必ず起こります。大切なのはそれをどう対処するかということです。

(原文:A Guide to Proper Error Handling in JavaScript

[翻訳:吉田健人]
[編集:Livit

Copyright © 2016, Camilo Reyes All Rights Reserved.

Camilo Reyes

Camilo Reyes

テキサス州ヒューストン在住の、夫であり、父であり、そしてソフトウェア技術者でもあります。とりわけJavaScript、C#、webbingに情熱を注いでいます。

Loading...