Node.jsエラー処理システムを構築する方法

公開: 2022-03-11

エラーの処理に苦労している人もいれば、完全にエラーを見逃している人もいます。 エラーを適切に処理するということは、バグやエラーを簡単に見つけることで開発時間を短縮するだけでなく、大規模なアプリケーション向けの堅牢なコードベースを開発することも意味します。

特に、Node.js開発者は、さまざまな種類のエラーを処理しているときに、それほどクリーンではないコードで作業していることに気付くことがあり、それらを処理するためにどこにでも同じロジックを誤って適用します。 彼らは、「Node.jsはエラーの処理に苦手ですか?」と自問し続けます。 またはそうでない場合、それらをどのように処理するのですか?」 それらに対する私の答えは、「いいえ、Node.jsはまったく悪くありません。 それは私たちの開発者次第です。」

これが私のお気に入りのソリューションの1つです。

Node.jsのエラーの種類

まず、Node.jsのエラーを明確に理解する必要があります。 一般に、Node.jsエラーは、操作エラープログラマーエラーの2つの異なるカテゴリに分類されます。

  • 操作エラーは、結果が予想され、適切な方法で処理する必要がある実行時の問題を表します。 動作エラーは、アプリケーション自体にバグがあることを意味するわけではありませんが、開発者はそれらを慎重に処理する必要があります。 動作エラーの例には、「メモリ不足」、「APIエンドポイントへの無効な入力」などがあります。
  • プログラマーエラーは、記述が不十分なコードの予期しないバグを表しています。 これは、コード自体に解決すべきいくつかの問題があり、間違ってコーディングされていることを意味します。 良い例は、「未定義」のプロパティを読み取ろうとすることです。 この問題を修正するには、コードを変更する必要があります。 これは開発者が作成したバグであり、操作上のエラーではありません。

このことを念頭に置いて、これら2つのカテゴリのエラーを問題なく区別できるはずです。操作エラーはアプリケーションの自然な部分であり、プログラマーエラーは開発者によって引き起こされたバグです。 次の論理的な質問は、 「それらを2つのカテゴリに分けて処理することがなぜ有用なのか」です。

エラーを明確に理解していないと、エラーが発生するたびにアプリケーションを再起動したいと思うかもしれません。 何千人ものユーザーがアプリケーションを楽しんでいるときに「ファイルが見つかりません」エラーが原因でアプリケーションを再起動することは理にかなっていますか? 絶対違う。

しかし、プログラマーのエラーはどうですか? アプリケーションに予期しない雪玉効果をもたらす可能性のある未知のバグが発生した場合でも、アプリケーションを実行し続けることは理にかなっていますか? 繰り返しますが、絶対にありません!

エラーを適切に処理する時が来ました

非同期JavaScriptとNode.jsの経験があると仮定すると、エラーを処理するためにコールバックを使用するときに欠点が発生した可能性があります。 それらは、ネストされたエラーに至るまでエラーをチェックすることを強制し、コードフローを追跡することを困難にする悪名高い「コールバック地獄」の問題を引き起こします。

promiseまたはasync/awaitを使用することは、コールバックの良い代替手段です。 async/awaitの典型的なコードフローは次のようになります。

 const doAsyncJobs = async () => { try { const result1 = await job1(); const result2 = await job2(result1); const result3 = await job3(result2); return await job4(result3); } catch (error) { console.error(error); } finally { await anywayDoThisJob(); } }

Node.jsの組み込みのErrorオブジェクトを使用すると、ほとんどの開発者がエラーのルートを追跡するために依存しているStackTraceなどのエラーに関する直感的で明確な情報が含まれるため、良い習慣になります。 また、HTTPステータスコードやErrorクラスを拡張することによる説明などの追加の意味のあるプロパティにより、より多くの情報が得られます。

 class BaseError extends Error { public readonly name: string; public readonly httpCode: HttpStatusCode; public readonly isOperational: boolean; constructor(name: string, httpCode: HttpStatusCode, description: string, isOperational: boolean) { super(description); Object.setPrototypeOf(this, new.target.prototype); this.name = name; this.httpCode = httpCode; this.isOperational = isOperational; Error.captureStackTrace(this); } } //free to extend the BaseError class APIError extends BaseError { constructor(name, httpCode = HttpStatusCode.INTERNAL_SERVER, isOperational = true, description = 'internal server error') { super(name, httpCode, isOperational, description); } }

簡単にするためにいくつかのHTTPステータスコードのみを実装しましたが、後で自由に追加できます。

 export enum HttpStatusCode { OK = 200, BAD_REQUEST = 400, NOT_FOUND = 404, INTERNAL_SERVER = 500, }

BaseErrorやAPIErrorを拡張する必要はありませんが、ニーズや個人的な好みに応じて、一般的なエラーのために拡張してもかまいません。

 class HTTP400Error extends BaseError { constructor(description = 'bad request') { super('NOT FOUND', HttpStatusCode.BAD_REQUEST, true, description); } }

では、どのように使用しますか? これを投入するだけです:

 ... const user = await User.getUserById(1); if (user === null) throw new APIError( 'NOT FOUND', HttpStatusCode.NOT_FOUND, true, 'detailed explanation' );

一元化されたNode.jsエラー処理

これで、Node.jsエラー処理システムのメインコンポーネントである集中型エラー処理コンポーネントを構築する準備が整いました。

通常、エラーを処理するときにコードが重複する可能性を回避するために、一元化されたエラー処理コンポーネントを構築することをお勧めします。 エラー処理コンポーネントは、たとえば、システム管理者に通知を送信し(必要な場合)、Sentry.ioなどの監視サービスにイベントを転送してログに記録することにより、キャッチされたエラーを理解できるようにする役割を果たします。

エラーを処理するための基本的なワークフローは次のとおりです。

Node.jsでのエラー処理:基本的なワークフロー

コードの一部では、エラーがキャッチされてエラー処理ミドルウェアに転送されます。

 ... try { userService.addNewUser(req.body).then((newUser: User) => { res.status(200).json(newUser); }).catch((error: Error) => { next(error) }); } catch (error) { next(error); } ...

エラー処理ミドルウェアは、エラータイプを区別し、それらを一元化されたエラー処理コンポーネントに送信するのに適した場所です。 Express.jsミドルウェアでのエラー処理の基本を知っていると確かに役立ちます。

 app.use(async (err: Error, req: Request, res: Response, next: NextFunction) => { if (!errorHandler.isTrustedError(err)) { next(err); } await errorHandler.handleError(err); });

これで、すでにいくつかの機能を使用しているため、集中型コンポーネントがどのように見えるかを想像することができます。 それを実装する方法は完全にあなた次第であることに注意してください、しかしそれは次のように見えるかもしれません:

 class ErrorHandler { public async handleError(err: Error): Promise<void> { await logger.error( 'Error message from the centralized error-handling component', err, ); await sendMailToAdminIfCritical(); await sendEventsToSentry(); } public isTrustedError(error: Error) { if (error instanceof BaseError) { return error.isOperational; } return false; } } export const errorHandler = new ErrorHandler();

デフォルトの「console.log」の出力により、エラーを追跡することが困難になる場合があります。 むしろ、開発者が問題をすばやく理解して修正されていることを確認できるように、フォーマットされた方法でエラーを印刷する方がはるかに優れている可能性があります。

全体として、これにより開発者の時間が節約され、エラーを追跡し、可視性を高めることでエラーを処理しやすくなります。 ウィンストンやモーガンのようなカスタマイズ可能なロガーを採用することは良い決断です。

カスタマイズされたウィンストンロガーは次のとおりです。

 const customLevels = { levels: { trace: 5, debug: 4, info: 3, warn: 2, error: 1, fatal: 0, }, colors: { trace: 'white', debug: 'green', info: 'green', warn: 'yellow', error: 'red', fatal: 'red', }, }; const formatter = winston.format.combine( winston.format.colorize(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.splat(), winston.format.printf((info) => { const { timestamp, level, message, ...meta } = info; return `${timestamp} [${level}]: ${message} ${ Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '' }`; }), ); class Logger { private logger: winston.Logger; constructor() { const prodTransport = new winston.transports.File({ filename: 'logs/error.log', level: 'error', }); const transport = new winston.transports.Console({ format: formatter, }); this.logger = winston.createLogger({ level: isDevEnvironment() ? 'trace' : 'error', levels: customLevels.levels, transports: [isDevEnvironment() ? transport : prodTransport], }); winston.addColors(customLevels.colors); } trace(msg: any, meta?: any) { this.logger.log('trace', msg, meta); } debug(msg: any, meta?: any) { this.logger.debug(msg, meta); } info(msg: any, meta?: any) { this.logger.info(msg, meta); } warn(msg: any, meta?: any) { this.logger.warn(msg, meta); } error(msg: any, meta?: any) { this.logger.error(msg, meta); } fatal(msg: any, meta?: any) { this.logger.log('fatal', msg, meta); } } export const logger = new Logger();

基本的に提供されるのは、複数の異なるレベルでフォーマットされた方法で明確な色でログを記録し、ランタイム環境に応じてさまざまな出力メディアにログインすることです。 これの良い点は、winstonの組み込みAPIを使用してログを監視およびクエリできることです。 さらに、ログ分析ツールを使用して、フォーマットされたログファイルを分析し、アプリケーションに関するより有用な情報を取得できます。 すごいですね。

これまでは、主に操作エラーの処理について説明してきました。 プログラマーのエラーはどうですか? これらのエラーに対処する最善の方法は、すぐにクラッシュし、PM2などの自動リスターターで正常に再起動することです。プログラマーエラーは、アプリケーションが間違った状態になって動作する原因となる可能性のある実際のバグであるため、予期しないものであるためです。思いがけない方法で。

 process.on('uncaughtException', (error: Error) => { errorHandler.handleError(error); if (!errorHandler.isTrustedError(error)) { process.exit(1); } });

最後になりましたが、未処理のプロミスの拒否と例外の処理について説明します。

Node.js / Expressアプリケーションで作業するときは、promiseの処理に多くの時間を費やしていることに気付くかもしれません。 拒否の処理を忘れた場合、未処理のPromiseの拒否に関する警告メッセージを確認するのは難しくありません。

警告メッセージはロギング以外にはあまり効果がありませんが、適切なフォールバックを使用してprocess.on('unhandledRejection', callback)にサブスクライブすることをお勧めします。

一般的なエラー処理フローは次のようになります。

 // somewhere in the code ... User.getUserById(1).then((firstUser) => { if (firstUser.isSleeping === false) throw new Error('He is not sleeping!'); }); ... // get the unhandled rejection and throw it to another fallback handler we already have. process.on('unhandledRejection', (reason: Error, promise: Promise<any>) => { throw reason; }); process.on('uncaughtException', (error: Error) => { errorHandler.handleError(error); if (!errorHandler.isTrustedError(error)) { process.exit(1); } });

まとめ

すべてを語り終えたら、エラー処理はオプションの追加ではなく、開発段階と本番環境の両方でアプリケーションの不可欠な部分であることを理解する必要があります。

Node.jsの単一コンポーネントでエラーを処理する戦略により、開発者は貴重な時間を節約し、コードの重複やエラーコンテキストの欠落を回避することで、クリーンで保守可能なコードを記述できます。

この記事を楽しんで読んでいただき、議論されたエラー処理ワークフローと実装がNode.jsで堅牢なコードベースを構築するのに役立つことを願っています。


Toptal Engineeringブログでさらに読む:

  • Promiseベースのエラー処理のためのExpress.jsルートの使用