エラークラスを使用したNodeJSでのエラー処理の改善

公開: 2022-03-10
クイックサマリー↬この記事は、アプリケーションでのエラー処理を改善したいJavaScriptおよびNodeJS開発者を対象としています。 Kelvin Omereshoneが、 errorクラスパターンと、アプリケーション全体でエラーを処理するためのより優れた、より効率的な方法のためにそれを使用する方法について説明します。

エラー処理は、ソフトウェア開発の中で、実際に値するほどの注目を集めていない部分の1つです。 ただし、堅牢なアプリケーションを構築するには、エラーを適切に処理する必要があります。

エラーを適切に処理せずにNodeJSで問題を解決できますが、NodeJSの非同期性により、不適切な処理やエラーは、特にアプリケーションのデバッグ時に、すぐに苦痛を引き起こす可能性があります。

先に進む前に、エラークラスの利用方法について説明するエラーの種類を指摘したいと思います。

操作上のエラー

これらは、プログラムの実行時に発見されたエラーです。 操作エラーはバグではなく、データベースサーバーのタイムアウトや、ユーザーが入力フィールドにSQLクエリを入力してSQLインジェクションを試行することを決定した場合など、いくつかの外部要因の1つまたは組み合わせが原因で発生することがあります。

以下は、操作エラーのその他の例です。

  • データベースサーバーへの接続に失敗しました。
  • ユーザーによる無効な入力(サーバーは400応答コードで応答します)。
  • リクエストのタイムアウト。
  • リソースが見つかりません(サーバーは404応答コードで応答します);
  • サーバーは500応答で戻ります。

操作上のエラーの対応について簡単に説明することも注目に値します。

プログラマーエラー

これらはプログラムのバグであり、コードを変更することで解決できます。 これらのタイプのエラーは、コードが壊れた結果として発生するため、処理できません。 これらのエラーの例は次のとおりです。

  • 定義されていないオブジェクトのプロパティを読み取ろうとしています。
 const user = { firstName: 'Kelvin', lastName: 'Omereshone', } console.log(user.fullName) // throws 'undefined' because the property fullName is not defined
  • コールバックなしで非同期関数を呼び出すまたは呼び出す。
  • 数値が期待される場所に文字列を渡す。

この記事は、NodeJSでの操作上のエラー処理についてです。 NodeJSでのエラー処理は、他の言語でのエラー処理とは大きく異なります。 これは、JavaScriptの非同期性と、エラーのあるJavaScriptのオープン性によるものです。 説明させてください:

JavaScriptでは、 errorクラスのインスタンスだけがスローできるものではありません。 このオープン性が他の言語で許可されていない任意のデータ型を文字通りスローできます。

たとえば、JavaScript開発者は、次のように、エラーオブジェクトインスタンスの代わりに数値をスローすることを決定する場合があります。

 // bad throw 'Whoops :)'; // good throw new Error('Whoops :)')

他のデータ型をスローしても問題が発生しない場合がありますが、デバッグに必要なスタックトレースや、Errorオブジェクトが公開するその他のプロパティを取得できないため、デバッグが困難になります。

Errorクラスのパターンと、NodeJSでのエラー処理のはるかに優れた方法を確認する前に、エラー処理のいくつかの誤ったパターンを見てみましょう。

ジャンプした後もっと! 以下を読み続けてください↓

不正なエラー処理パターン#1:コールバックの誤った使用

実際のシナリオコードは、返されると期待される結果を取得するためにコールバックを必要とする外部APIに依存しています。

以下のコードスニペットを見てみましょう。

 'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder'); fs.writeFile('./writeFolder/foobar.txt', 'Hello World'); } write();

NodeJS 8以降までは、上記のコードは正当であり、開発者は単にコマンドを起動して忘れていました。 これは、開発者がそのような関数呼び出しへのコールバックを提供する必要がなかったため、エラー処理を省略できることを意味します。 writeFolderが作成されていない場合はどうなりますか? writeFileの呼び出しは行われず、それについては何もわかりません。 また、2番目のコマンドが再開されたときに最初のコマンドが終了していない可能性があるため、競合状態が発生する可能性があります。

競合状態を解決することから、この問題の解決を始めましょう。 これを行うには、最初のコマンドmkdirにコールバックを与えて、2番目のコマンドでディレクトリに書き込む前にディレクトリが実際に存在することを確認します。 したがって、コードは次のようになります。

 'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder', () => { fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); }); } write();

競合状態は解決しましたが、まだ完了していません。 最初のコマンドにコールバックを使用したとしても、 writeFolderフォルダーが作成されたかどうかを知る方法がないため、コードにはまだ問題があります。 フォルダが作成されていない場合、2番目の呼び出しは再び失敗しますが、それでもエラーは無視されます。 これを解決するには…

コールバックによるエラー処理

コールバックでエラーを適切に処理するには、常にエラーファーストのアプローチを使用する必要があります。 これが意味することは、返されたデータ(存在する場合)を使用する前に、まず関数から返されたエラーがあるかどうかを確認する必要があるということです。 これを行う間違った方法を見てみましょう:

 'use strict'; // Wrong const fs = require('fs'); const write = function (callback) { fs.mkdir('./writeFolder', (err, data) => { if (data) fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); else callback(err) }); } write(console.log);

上記のパターンは間違っています。呼び出しているAPIが値を返さない場合や、有効な戻り値として偽の値を返す場合があるためです。 これにより、関数またはAPIの呼び出しが成功したように見えても、エラーが発生する可能性があります。

上記のパターンは、使用法によってエラーが消費されるため、悪いパターンです(エラーが発生した場合でも、エラーは呼び出されません)。 また、この種のエラー処理パターンの結果として、コードで何が起こっているのかがわかりません。 したがって、上記のコードの正しい方法は次のようになります。

 'use strict'; // Right const fs = require('fs'); const write = function (callback) { fs.mkdir('./writeFolder', (err, data) => { if (err) return callback(err) fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); }); } write(console.log);

間違ったエラー処理パターン#2:Promiseの間違った使用

実際のシナリオPromisesを発見し、コールバック地獄のためにコールバックよりもはるかに優れていると考え、コードベースが依存する外部APIを約束することにしました。 または、外部APIまたはfetch()関数などのブラウザーAPIからpromiseを使用しています。

最近では、NodeJSコードベースでコールバックを実際に使用するのではなく、promiseを使用しています。 それでは、サンプルコードをpromiseで再実装しましょう。

 'use strict'; const fs = require('fs').promises; const write = function () { return fs.mkdir('./writeFolder').then(() => { fs.writeFile('./writeFolder/foobar.txt', 'Hello world!') }).catch((err) => { // catch all potential errors console.error(err) }) }

上記のコードを顕微鏡下に置いてみましょう。fs.mkdirpromiseを別のpromiseチェーン( fs.mkdirの呼び出し)に分岐していることがわかります。そのpromise呼び出しも処理されていません。 あなたはそれを行うためのより良い方法を考えるかもしれません:

 'use strict'; const fs = require('fs').promises; const write = function () { return fs.mkdir('./writeFolder').then(() => { fs.writeFile('./writeFolder/foobar.txt', 'Hello world!').then(() => { // do something }).catch((err) => { console.error(err); }) }).catch((err) => { // catch all potential errors console.error(err) }) }

しかし、上記は拡張できません。 これは、呼び出すプロミスチェーンが増えると、プロミスが解決するために作成されたコールバック地獄に似たものになってしまうためです。 これは、コードが右にインデントし続けることを意味します。 私たちは私たちの手に地獄の約束を持っているでしょう。

コールバックベースのAPIの約束

ほとんどの場合、そのAPIのエラーをより適切に処理するために、コールバックベースのAPIを自分で約束する必要があります。 ただし、これは実際には簡単ではありません。 その理由を説明するために、以下の例を見てみましょう。

 function doesWillNotAlwaysSettle(arg) { return new Promise((resolve, reject) => { doATask(foo, (err) => { if (err) { return reject(err); } if (arg === true) { resolve('I am Done') } }); }); }

上記から、 argtrueでなく、 doATask関数の呼び出しでエラーが発生しなかった場合、このpromiseはハングアウトするだけで、アプリケーションのメモリリークになります。

Promisesで飲み込まれた同期エラー

Promiseコンストラクターの使用には、いくつかの問題があります。これらの問題の1つは次のとおりです。 解決または拒否されるとすぐに、別の状態を取得できなくなります。 これは、Promiseが取得できる状態が1つだけであるためです。つまり、promiseは保留中か、解決/拒否されます。 これは、約束にデッドゾーンを含めることができることを意味します。 これをコードで見てみましょう:

 function deadZonePromise(arg) { return new Promise((resolve, reject) => { doATask(foo, (err) => { resolve('I'm all Done'); throw new Error('I am never reached') // Dead Zone }); }); }

上記から、約束が解決されるとすぐに、次の行はデッドゾーンであり、到達することはありません。 これは、Promiseで実行される後続の同期エラー処理が飲み込まれ、スローされることはないことを意味します。

実際の例

上記の例は、不十分なエラー処理パターンを説明するのに役立ちます。実際に見られる可能性のある問題の種類を見てみましょう。

実世界の例#1 —エラーを文字列に変換する

シナリオAPIから返されるエラーは十分ではないと判断したため、独自のメッセージを追加することにしました。

 'use strict'; function readTemplate() { return new Promise(() => { databaseGet('query', function(err, data) { if (err) { reject('Template not found. Error: ', + err); } else { resolve(data); } }); }); } readTemplate();

上記のコードの何が問題になっているのか見てみましょう。 上記から、開発者は、返されたエラーを文字列「テンプレートが見つかりません」と連結することにより、 databaseGetによってスローされるエラーを改善しようとしていることがわかります。 このアプローチには多くの欠点があります。これは、連結が行われたときに、開発者が返されたエラーオブジェクトに対して暗黙的にtoStringを実行するためです。 このようにして、エラーによって返された追加情報を失います(スタックトレースに別れを告げます)。 したがって、開発者が現在持っているのは、デバッグ時に役に立たない文字列にすぎません。

より良い方法は、エラーをそのまま保持するか、作成した別のエラーでラップして、databaseGet呼び出しからスローされたエラーをプロパティとして添付することです。

実際の例2:エラーを完全に無視する

シナリオおそらく、ユーザーがアプリケーションにサインアップしているときに、エラーが発生した場合は、エラーをキャッチしてカスタムメッセージを表示したいのですが、デバッグ目的でログに記録することなく、キャッチされたエラーを完全に無視しました。

 router.get('/:id', function (req, res, next) { database.getData(req.params.userId) .then(function (data) { if (data.length) { res.status(200).json(data); } else { res.status(404).end(); } }) .catch(() => { log.error('db.rest/get: could not get data: ', req.params.userId); res.status(500).json({error: 'Internal server error'}); }) });

上記から、エラーは完全に無視され、データベースへの呼び出しが失敗した場合、コードはユーザーに500を送信していることがわかります。 ただし、実際には、データベース障害の原因は、ユーザーから送信された不正な形式のデータである可能性があります。これは、ステータスコード400のエラーです。

上記の場合、開発者としてのあなたは何が悪かったのかわからないので、デバッグの恐怖に陥ることになります。 500の内部サーバーエラーが常にスローされるため、ユーザーは適切なレポートを提供できません。 あなたはあなたの雇用主の時間とお金の浪費に等しい問題を見つけるのに何時間も浪費することになります。

実際の例3:APIからスローされたエラーを受け入れない

シナリオ使用しているAPIからエラーがスローされましたが、そのエラーを受け入れず、代わりに、デバッグ目的で使用できないようにエラーをマーシャリングおよび変換します。

以下のコード例を見てください。

 async function doThings(input) { try { validate(input); try { await db.create(input); } catch (error) { error.message = `Inner error: ${error.message}` if (error instanceof Klass) { error.isKlass = true; } throw error } } catch (error) { error.message = `Could not do things: ${error.message}`; await rollback(input); throw error; } }

上記のコードでは、ホラーのデバッグにつながる多くのことが起こっています。 見てみましょう:

  • try/catchブロックのラッピング:上記から、 try/catchブロックをラッピングしていることがわかります。これは非常に悪い考えです。 通常、 try/catchブロックの使用を減らして、エラーを処理する必要のあるサーフェスを縮小しようとします(DRYエラー処理と考えてください)。
  • また、改善を目的としてエラーメッセージを操作していますが、これもお勧めできません。
  • エラーがKlass型のインスタンスであるかどうかを確認しています。この場合、エラーisKlassのブールプロパティをtruevに設定しています(ただし、その確認に合格すると、エラーはKlass型になります)。
  • また、コード構造から、エラーがスローされたときにデータベースにヒットしなかった可能性が高いため、データベースのロールバックも早すぎます。

上記のコードを書くためのより良い方法は以下のとおりです。

 async function doThings(input) { validate(input); try { await db.create(input); } catch (error) { try { await rollback(); } catch (error) { logger.log('Rollback failed', error, 'input:', input); } throw error; } }

上記のスニペットで私たちが行っていることを分析しましょう:

  • 1つのtry/catchブロックを使用しており、catchブロックでのみ、別のtry/catchブロックを使用しています。これは、そのロールバック関数で何かが発生した場合のガードとして機能し、それをログに記録します。
  • 最後に、元の受信エラーをスローします。これは、そのエラーに含まれるメッセージが失われないことを意味します。

テスト

私たちは主にコードを(手動または自動で)テストしたいと思っています。 しかし、ほとんどの場合、私たちはポジティブなことをテストしているだけです。 堅牢なテストを行うには、エラーとエッジケースもテストする必要があります。 この過失は、バグが本番環境に移行する原因となり、デバッグに余分な時間がかかります。

ヒント常にポジティブなもの(エンドポイントからステータスコード200を取得する)だけでなく、すべてのエラーケースとすべてのエッジケースもテストするようにしてください。

実際の例4:未処理の拒否

以前にpromiseを使用したことがある場合は、おそらくunhandled rejectionsに遭遇したことがあります。

これは、未処理の拒否に関する簡単な入門書です。 未処理の拒否は、処理されなかった約束の拒否です。 これは、promiseが拒否されたが、コードは引き続き実行されることを意味します。

未処理の拒否につながる一般的な実例を見て​​みましょう。

 'use strict'; async function foobar() { throw new Error('foobar'); } async function baz() { throw new Error('baz') } (async function doThings() { const a = foobar(); const b = baz(); try { await a; await b; } catch (error) { // ignore all errors! } })();

上記のコードは一見、エラーが発生しにくいように見えるかもしれません。 しかし、よく見ると、欠陥が見え始めます。 説明させてください: aが拒否されるとどうなりますか? つまり、 await bに到達することはなく、未処理の拒否を意味します。 考えられる解決策は、両方のPromiseでPromise.allを使用することです。 したがって、コードは次のようになります。

 'use strict'; async function foobar() { throw new Error('foobar'); } async function baz() { throw new Error('baz') } (async function doThings() { const a = foobar(); const b = baz(); try { await Promise.all([a, b]); } catch (error) { // ignore all errors! } })();

これは、未処理のPromise拒否エラーにつながる別の実際のシナリオです。

 'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return foobar() } catch { // ignoring errors again ! } } doThings();

上記のコードスニペットを実行すると、未処理のpromise拒否が発生します。その理由は次のとおりです。明らかではありませんが、 try/catchで処理する前にpromise(foobar)を返します。 私たちがすべきことは、コードが次のようになるように、 try/catchで処理している約束を待つことです。

 'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return await foobar() } catch { // ignoring errors again ! } } doThings();

ネガティブなことのまとめ

間違ったエラー処理パターンと可能な修正を確認したので、次にErrorクラスパターンと、NodeJSでの間違ったエラー処理の問題をどのように解決するかを見ていきましょう。

エラークラス

このパターンでは、 ApplicationErrorクラスを使用してアプリケーションを開始します。これにより、明示的にスローするアプリケーションのすべてのエラーが、アプリケーションから継承されることがわかります。 したがって、次のエラークラスから始めます。

  • ApplicationError
    これは、他のすべてのエラークラスの祖先です。つまり、他のすべてのエラークラスはそれを継承します。
  • DatabaseError
    データベース操作に関連するエラーはすべて、このクラスから継承されます。
  • UserFacingError
    ユーザーがアプリケーションを操作した結果として生成されたエラーは、このクラスから継承されます。

errorクラスファイルは次のようになります。

 'use strict'; // Here is the base error classes to extend from class ApplicationError extends Error { get name() { return this.constructor.name; } } class DatabaseError extends ApplicationError { } class UserFacingError extends ApplicationError { } module.exports = { ApplicationError, DatabaseError, UserFacingError }

このアプローチにより、アプリケーションによってスローされたエラーを区別できます。 したがって、不正な要求エラー(無効なユーザー入力)または見つからないエラー(リソースが見つからない)を処理する場合は、基本クラスであるUserFacingErrorから継承できます(以下のコードのように)。

 const { UserFacingError } = require('./baseErrors') class BadRequestError extends UserFacingError { constructor(message, options = {}) { super(message); // You can attach relevant information to the error instance // (eg. the username) for (const [key, value] of Object.entries(options)) { this[key] = value; } } get statusCode() { return 400; } } class NotFoundError extends UserFacingError { constructor(message, options = {}) { super(message); // You can attach relevant information to the error instance // (eg. the username) for (const [key, value] of Object.entries(options)) { this[key] = value; } } get statusCode() { return 404 } } module.exports = { BadRequestError, NotFoundError }

errorクラスアプローチの利点の1つは、これらのエラーの1つ(たとえば、 NotFoundError )をスローすると、このコードベースを読んでいるすべての開発者が、この時点で何が起こっているかを理解できることです(コードを読んだ場合) )。

そのエラーのインスタンス化中にも、各エラークラスに固有の複数のプロパティを渡すことができます。

もう1つの重要な利点は、常にエラークラスの一部であるプロパティを持つことができることです。たとえば、UserFacingエラーを受け取った場合、statusCodeは常にこのエラークラスの一部であることがわかり、直接使用できるようになります。後でコードします。

エラークラスを利用するためのヒント

  • エラークラスごとに独自のモジュール(場合によってはプライベートモジュール)を作成して、アプリケーションにインポートしてどこでも使用できるようにします。
  • 気になるエラー(エラークラスのインスタンスであるエラー)のみをスローします。 このようにして、エラークラスが唯一の真実のソースであり、アプリケーションのデバッグに必要なすべての情報が含まれていることがわかります。
  • アプリケーションがスローする可能性のあるエラーに関する必要な情報がすべて1か所にあることがわかったので、抽象的なエラーモジュールがあると非常に便利です。
  • レイヤーのエラーを処理します。 どこでもエラーを処理する場合、追跡するのが難しいエラー処理への一貫性のないアプローチがあります。 レイヤーとは、データベース、エクスプレス/高速化/ HTTPレイヤーなどを意味します。

エラークラスがコードでどのように見えるかを見てみましょう。 エクスプレスの例を次に示します。

 const { DatabaseError } = require('./error') const { NotFoundError } = require('./userFacingErrors') const { UserFacingError } = require('./error') // Express app.get('/:id', async function (req, res, next) { let data try { data = await database.getData(req.params.userId) } catch (err) { return next(err); } if (!data.length) { return next(new NotFoundError('Dataset not found')); } res.status(200).json(data) }) app.use(function (err, req, res, next) { if (err instanceof UserFacingError) { res.sendStatus(err.statusCode); // or res.status(err.statusCode).send(err.errorCode) } else { res.sendStatus(500) } // do your logic logger.error(err, 'Parameters: ', req.params, 'User data: ', req.user) });

上記から、Expressがすべてのエラーを1か所で処理できるグローバルエラーハンドラーを公開することを活用しています。 エラーを処理している場所でnext()の呼び出しを確認できます。 この呼び出しは、 app.useセクションで定義されているハンドラーにエラーを渡します。 expressはasync / awaitをサポートしていないため、 try/catchブロックを使用しています。

したがって、上記のコードから、エラーを処理するには、スローされたエラーがUserFacingErrorインスタンスであるかどうかを確認するだけで、エラーオブジェクトにstatusCodeがあることが自動的にわかり、それをユーザーに送信します(必要な場合があります)クライアントに渡すことができる特定のエラーコードもあります)そしてそれはほとんどそれです。

また、このパターン( errorクラスパターン)では、明示的にスローしなかった他のすべてのエラーは500エラーです。これは、アプリケーションでそのエラーを明示的にスローしなかったことを意味する予期しないものであるためです。 このようにして、アプリケーションで発生しているエラーの種類を区別することができます。

結論

アプリケーションでの適切なエラー処理により、夜間の睡眠が改善され、デバッグ時間が節約されます。 この記事から得られる重要なポイントは次のとおりです。

  • アプリケーション用に特別に設定されたエラークラスを使用します。
  • 抽象エラーハンドラーを実装します。
  • 常にasync / awaitを使用してください。
  • エラーを表現力豊かにします。
  • ユーザーは必要に応じて約束します。
  • 適切なエラーステータスとコードを返します。
  • 約束のフックを利用してください。

もちろん、SmashingWorkshopsで新しい洞察を探求するSmashingCat。

便利なフロントエンドとUXビット。週に1回配信されます。

あなたがあなたの仕事をより良くするのを助けるためのツールで。 購読して、VitalyのスマートインターフェイスデザインチェックリストPDFを電子メールで入手してください。

フロントエンドとUX。 190.000人から信頼されています。