2024年05月21日
みなさん、 JavaScript
の Promise
、それと async/await
構文はお使いでしょうか? 非同期処理をあたかも同期的に書けるこの機能、わたしはありがたく、便利に普段使いさせていただいています。
しかし、非同期処理が「失敗で解決」されてしまったなら、放ったらかしにしておくわけにはいきません。あるいは、「成功で解決したけど、その結果は仕様として不正だ」ということも起こり得ます。こうした「正しくない結果」の取り扱いを「エラーハンドリング」と呼びますが、まわりを見渡してみると、エラーハンドリングにあれこれ悩みながら記述されているコードをときおり見かけたりします。
こんかいは、 JavaScript
における「非同期処理のエラーハンドリング」について、一つの指針を示したいと思います。みなさんの考えるきっかけになれば幸いです。
まずは、例外の伝播(プロパゲーション)を理解しよう!
非同期処理が「失敗で解決」されると、なぜ失敗したのかを表す特別な値「例外 exception」が生まれます。生まれた「例外」は、その処理の呼び出し元へ、さらにその呼び出し元へ…と、処理をさかのぼるように受け渡され続けます。これが「例外の伝播(プロパゲーション)」という仕組みです。
例外は、どこまでさかのぼるの?
例外の伝播は、これ以上呼び出し元をさかのぼれなくなるまで続き、ここでやっと「エラー」として人間の目に留まります。ただしこの情報は、ブラウザーの開発ツールのコンソールを使わないとわかりません。利用者からはサイトやWebアプリが突然沈黙したように見えるため、困惑することでしょう。要するに、これはエラーハンドリングできていない状態なのです。
伝播を、ある「箇所」で止めたいときは?
例外の伝播はどんどんさかのぼって受け渡される、と書きましたが、それを止める方法があります。 try/catch
の catch
節を使えば、さかのぼってきた例外をそこで受け止め、さらなる伝播を止められるのです(これを「例外を握りつぶす」と呼ぶ人もいます)。
ここには「例外」そのものが、あたかも「引数」のようにわたってくるため、これを検証することでエラーの原因を調べ、しかるべきナビゲーションやデータ処理……すなわち、エラーハンドリング行うことができます。言い換えると一般的には、 catch
とはエラーハンドリングを行うための仕組みとも言えます。
伝播を止めない例と、止める例
それでは、実例で示してみましょう。下の例はふたつとも、同じ関数呼び出しをたどり、最終的に同じ「例外」を生み出し、その例外は関数の呼び出し経路を逆にたどるように伝播してゆきます。違いは、前者は最後まで伝播された例外を放置していますが、後側は catch
節で例外を受け止めていることです。
ブラウザの開発ツールのコンソールを開いて、それぞれのボタンを押してみてください。前者は、コンソールに例外が「エラー」として表示されるだけで、プログラムの挙動は「途中で止まってしまった」ように見えます。一方で後者は、 catch 節でのエラーハンドリングの準備ができているので、コンソールにはなにも表示されません(「例外を握りつぶす」という言い方は、これが原因かもしれません)。
See the Pen 例外の伝播 1 by isobeact (@rmzpacpm-the-builder) on CodePen.
前者のボタンを押すと、例外発生時にコンソールにこのように表示されるはずです。一般のユーザーさんはこんなところまで見ませんので、「なんだか知らないけど動かない」と困惑するでしょう。基本的に、エラーハンドリングはすべきものと考えてください。
意図的に例外を「投げる」とき
たとえば、サーバとの通信時、「通信という非同期処理」には成功しても、その内容が「DBでエラーが起きて、処理に失敗しちゃいました😅」で、ズッコケた経験はありませんか? わたしは日常茶飯事です。でもこれは、先ほどまでと意味合いがやや異なります。非同期処理そのものは「成功で解決」しているため、プログラムとしては「正しい」、つまり例外が生まれないのです。
となると、
内容がどうであれ、通信は成功で解決してるってことでしょ? だったら呼び出し元にそのまま返して、そっち側でよしなにしてもらえばいいじゃん
という声が聞こえてきそうですね。でも、通信の役割は「サーバからデータを得ること」。通信が成功してもその内容が「失敗😅」なら、それは「通信の失敗」と考えるべきでしょう。となれば、これも例外として取り扱えるなら、呼び出し元にとっても「成功で解決したのに、失敗判定をしなきゃいけないよ~」なんて仕事が不要になり、ハッピーなわけです。
こうした場合のために、「仕様としての失敗」のさい、意図的に例外を作成し、伝播させることができます。これを「例外を投げる」といい、JavaScriptでは文字通り throw
(投げる)という文で行います。throw
は return
と同様、関数ブロックを強制的に抜けるため、以降の処理は行われません。
async function callSomeAPI(url) {
// この処理は通信に異常があるとき `TypeError` という例外を発行する
const result = await fetch(url);
// この処理は受け取ったJSONが不正なとき `SyntaxError` という例外を発行する
const json = await result.json();
// サーバサイドの処理が成功したかどうかを判定
if (json.data.success === false) {
// 失敗であれば、例外を投げ、呼び出し元に伝播させる。
throw '失敗😅';
// これ以降の処理は行われない。
}
// 以降は成功時の処理…
// :
return listModel;
}
このように例外を投げてもらえば、呼び出し元では catch
節で通信処理のエラーハンドリングを集約できるという仕組みです。
async function list(param) {
/** @type {string} クエリストリング */
const queryString = Object.entries(param).map(([key, value]) => `${key}=${value}`).join('&');
try {
const listModel = await callSomeAPI(`https://api.beact.co.jp/v1/list?${queryString}`);
store.commit('list', listModel);
} catch(e) {
// エラーハンドリング
// 非同期通信が「成功で解決」されたうえで、レスポンスが `失敗😅` かどうかの判定
if (e === '失敗😅') {
openModal('ただいまデータベースに問題が起こっています。不具合情報を参照してください。');
return;
}
// 受信したデータが不正なJSONだったのかの判定
if (e instanceof SyntaxError) {
openModal('不正なデータを受信しました。しばらく時間をおいて再度お試しください。');
// ロガーには細かいデータを入れる
log({name: e.name, message: e.message});
return;
}
// 非同期通信が「失敗で解決されたのか」の判定
if (e instanceof TypeError) {
openModal('ただいまサーバに問題が起こっています。しばらく時間をおいて再度お試しください。');
// ロガーには細かいデータを入れる
log({name: e.name, message: e.message});
return;
}
// その他のエラー (AbortError など)
openModal('もう一度お試しください。');
}
}
……一見、なんとなく良さそうに見えます。でも、例外の種類の判定の部分、なんとなくモヤモヤしませんか?
投げる例外は、Errorオブジェクトにしよう!
モヤモヤの原因は、おそらくここです。
// 非同期通信が「成功で解決」されたうえで、レスポンスが `失敗😅` かどうかの判定
if (e === '失敗😅') {
openModal('ただいまデータベースに問題が起こっています。不具合情報を参照してください。');
return;
}
どうしてここだけ e
が文字列になってるのでしょうか?! 通信エラーは TypeError
かどうかで判定しているのに、ここだけチグハグなのは混乱の元です。
new Error() を使ってみる
というわけで、例外を投げる処理を、こう変えてみましょう。
async function callSomeAPI(url) {
const result = await fetch(url);
const json = await result.json();
// サーバサイドの処理が成功したかどうかを判定
if (json.data.success === false) {
// 例外を投げる場合は、 `new Error(メッセージ)` が望ましい
throw new Error('失敗😅');
// これ以降の処理は行われない。
}
// 以降は成功時の処理…
// :
return listModel;
}
では実際に、この Error
オブジェクトを throw
してみましょう。
See the Pen 例外の伝播 2 by isobeact (@rmzpacpm-the-builder) on CodePen.
驚きましたか? Error
オブジェクトを例外として投げるだけで、自動的に関数の呼び出された順番を逆にたどり、どのファイルの、何行目の、何文字目で呼ばれたのかを報告してくれるようになります! この情報を「スタックトレース」と呼びます。
デバッグには大変便利な情報なので、ぜひ、Error
オブジェクトを投げるようにしましょう!
エラーの「型」
副産物として、ハンドリング時にそのエラーの「型」も instanceof
演算子で判定することが可能になります。JavaScriptには様々なエラーの型があるので、エラーをナローイング(型による絞り込み)してハンドリング処理を変えるのに役立ちます。
でも、Errorオブジェクトだけじゃ区別できないよ!
はい。先ほど投げた例外は、単なる Error
オブジェクト。じつはこの Error
は、JavaScriptのすべてのエラー型のスーパークラスなのです。なので単なる Error
オブジェクトに頼りすぎると、
// TypeError が投げられたぞ!
catch(e) {
console.log(e instanceof Error); // TypeError は Error のサブクラスなので、 true になる!
}
が成り立つため、ナローイングが困難になってしまいます。せっかく投げるなら、自前で作ったエラーを投げたいですよね。それが、カスタムエラーです。
カスタムエラーはいいぞ(語彙力)
カスタムエラーは、Error
のサブクラスを定義することで、簡単に作ることができます。なのでまずは、ビルトインの Error
型の情報をおさらいしましょう(型を明示するため、TypeScriptで書きます)。
/**
* ES2022 以降の型定義 (モダンブラウザはすべて対応済み)
*/
class Error {
/** エラー名称 */
name = 'Error';
/** 人間が読めるエラー内容 */
message?: string;
/** エラーの理由 */
cause?: unknown;
constructor(message?: string, options?: {cause?: unknown}) {
this.message = message;
this.cause = options?.cause;
}
/**
* エラーがスローされた場所の適切なスタックトレースを維持する(V8でのみ使用可能)
*/
static captureStackTrace(targetObject: object, constructorOpt?: Function): void {
// 実装は不明
}
}
プロパティ cause は、どんなオブジェクトでも入れることができます。特定のエラーに必要な値を、お弁当のように、自由に運ばせられるわけです。
それでは、これを継承したサブクラスを作っていきましょう。
/**
* 先ほどの「成功で解決したけど内容は失敗」という時に投げるエラー
*/
class ServerSideError extends Error {
/**
* サーバサイドからのエラー
* @param {string} message エラーメッセージ
* @param {{cause: {url: string}}} options エラーの理由を表現する情報
*/
constructor(message, options) {
super(message, options);
// name はサブクラス自身の名前を明示的に設定
this.name = 'ServerSideError';
// エラーがスローされた場所の適切なスタックトレースを維持する(V8でのみ使用可能)
Error?.captureStackTrace(this, ServerSideError);
}
}
実際、カスタムクラスの作成はこれだけでOKです。必要に応じて、Errorを継承したカスタムエラークラスをいくらでも作ることができます。上記の例では、 cause
にアクセスURLを持たせるようにしてみました。
では、先ほどの例で、このクラスを早速使ってみましょう!
async function callSomeAPI(url) {
const result = await fetch(url);
const json = await result.json();
// サーバサイドの処理が成功したかどうかを判定
if (json.data.success === false) {
// ここで使います!
throw new ServerSideError(json.data.message, {url: url});
// これ以降の処理は行われない。
}
// 以降は成功時の処理…
// :
return listModel;
}
呼び出す側のコードは、こうなります。
async function list(param) {
/** @type {string} クエリストリング */
const queryString = Object.entries(param).map(([key, value]) => `${key}=${value}`).join('&');
try {
const listModel = await callSomeAPI(`https://api.beact.co.jp/v1/list?${queryString}`);
store.commit('list', listModel);
} catch(e) {
// エラーハンドリング
// 非同期通信が「成功で解決」されたうえで、レスポンスが `失敗😅` かどうかの判定
if (e instanceof ServerSideError) {
openModal('ただいまデータベースに問題が起こっています。不具合情報を参照してください。');
// ロガーには細かいデータを入れる(url も入れる)
log({name: e.name, message: e.message, url: e.cause.url});
return;
}
// 受信したデータが不正なJSONだったのかの判定
if (e instanceof SyntaxError) {
openModal('不正なデータを受信しました。しばらく時間をおいて再度お試しください。');
//ロガーには細かいデータを入れる
log({name: e.name, message: e.message});
return;
}
// 非同期通信が「失敗で解決されたのか」の判定
if (e instanceof TypeError) {
openModal('ただいまサーバに問題が起こっています。しばらく時間をおいて再度お試しください。');
// ロガーには細かいデータを入れる
log({name: e.name, message: e.message});
return;
}
// その他のエラー (AbortError など)
openModal('もう一度お試しください。');
}
}
と、エラーハンドリングを集約できた上に、追加のデータを得られるようになりました!
例外の「リスロー」(rethrow: 投げ直し)
例外は catch 節で捕まえられると、そこで伝播が止まってしまいます。つまり一般的には、エラーハンドリングを意図しない場合は try/catch
をすべきではありません(上記の例でも、通信の処理そのものでは try/catch
は記述してないことを確認してくださいね!)。
しかし現実には、そんな潔癖さが通用しないことがあります。エラーハンドリングしたくないのに、わざわざ catch
節の中で throw new Error(...)
をする事例です。このように、わざわざ受け止めた例外を再度伝播させるためにthrow
することを、リスローと呼びます。
Errorを投げてくれないライブラリを使うとき
第三者の作ったライブラリなどで、「Errorオブジェクトではないものを投げてくる処理」があったりします。
/**
* GoogleマップAPIのジオコーダーを使おう
*/
async function someRoutine(address) {
// throw されるのは、エラーではなく、原因を示す enum 構造体!
const result = await google.maps.geocode(address);
return result[0];
}
特に開発時、スタックトレースの恩恵を受けるためには、Errorを派生させたオブジェクトを投げてほしいところですよね。そういう「例外を変形して投げなおす」場合に、涙を飲んで try/catch を使うことがあります。
/**
* GoogleマップAPIのジオコーダーを使おう
*/
async function geocoding(address) {
try {
const result = await google.maps.geocode(address);
return result[0];
} catch(reason) {
// どうしてもErrorオブジェクトを投げさせたい場合はこうする。
throw new Error('Geocoder Failed.', {cause: reason});
}
}
この方法で重要なことは、Errorのコンストラクタの第2引数に {cause: 投げられた例外そのもの}
を定義することです。そうしないと、スタックトレースが失われてしまいます。
もちろん、カスタムエラーを定義すれば、よりコーディングの意図が伝わりやすくなるでしょう。
そこでナローイングしきれない例外を、更に呼び出し元に渡すとき
先ほどの例を挙げてみましょう。
async function list(param) {
//....
} catch(e) {
if (e instanceof ServerSideError) {
openModal('ただいまデータベースに問題が起こっています。不具合情報を参照してください。');
// ロガーには細かいデータを入れる(url も入れる)
log({name: e.name, message: e.message, url: e.cause.url});
return;
}
// 受信したデータが不正なJSONだったのかの判定
if (e instanceof SyntaxError) {
openModal('不正なデータを受信しました。しばらく時間をおいて再度お試しください。');
//ロガーには細かいデータを入れる
log({name: e.name, message: e.message});
return;
}
// 非同期通信が「失敗で解決されたのか」の判定
if (e instanceof TypeError) {
openModal('ただいまサーバに問題が起こっています。しばらく時間をおいて再度お試しください。');
// ロガーには細かいデータを入れる
log({name: e.name, message: e.message});
return;
}
// その他のエラーは、ここでは扱いきれない! 代わりに処理して!
throw e;
}
}
たとえば仕様として、「この関数の責務は、通信の成功失敗を扱う」と決めたとします。ならば、エラーハンドリングも、通信にまつわるものだけに限定すべきです。一つのcatchで、起こり得るすべてのエラーをハンドリングすることなど、現実には不可能ですし、意味のない努力と言えるでしょう。
「責務」という単位で処理を区切り、その責務におけるエラーハンドリングは確実に行い、それ以外は呼び出し元に任せるためにリスローする……。これを掘り下げると、全体の設計にまで話が及んでしまうので、機会を改めて論じることにしましょう。
こんかいの記事は、以上です。それでは、よきコーディングライフを!