はじめに
この記事ではプロを目指す人のための例外処理(再)入門 / #rubykansai 2018-01-13で学習した内容を備忘録としてTypeScript版にしつつまとめたものになります。前編ではスライドの主に基礎に該当する部分を取り上げているので是非ご覧ください!
例外と例外処理についておさらい
例外(Exception)とはそれ以上プログラムを続行できない例外的な状況を指します。雑に言えばプログラムのエラーと言い換えてもいいでしょう。
一方、例外処理とは発生した例外を適切に処理することです。独学でのプログラミング学習や業務を経験したことがある方ならわかるかと思いますが、エラーと無縁でいられるプログラムはありません。エラーは必ずといって良いほど起こります。その上で安全なシステムを構築するには適切な例外処理を実装する必要があり、正しい例外処理を学ぶことは非常に重要と言えます。
function example() {
try {
// エラーが発生するかもしれないの処理
} catch (error) {
// エラーをキャッチして例外処理
console.error('エラーが発生しました:', error.message);
}
}
ハンマーを持つ人にはすべてが釘に見える
表題のこの言葉は英語のことわざで、一つの手段しか持っていないとその手段を使うと言う固定概念に飲まれてしまうという意味です。(詳細な意味は諸説あり)
このことは例外処理にも言えます。例外処理に関しても、こればかり使えば良いと言うわけではないということです。
本当にあった怖い話
元スライドにあったエピソードです
•とあるWebシステム(社内システム)を受け継いだ
• そのシステムは問題なく動いていた(ように見えた)
• システム改修のため現場の人と話す機会をもった
• 現場の人の使い方を見せてもらった
• 保存ボタンをクリック→「システムエラー: -1」
• 僕「えっ、何これ?🤨」
• 現場の人「これが出たらボタンを再クリックします」
イメージ的にはこんな感じのコードだったそうです。スライドのものをTypeScriptでExpressを使った場合の例に書き換えています。
import { Request, Response } from 'express';
import { Post } from './models/Post'; // Postモデルを適切にインポート
async function create(req: Request, res: Response): Promise<void> {
try {
// リクエストからデータを取得して新しいPostインスタンスを作成
const post = new Post(req.body);
// 保存処理を実行
await post.save();
// 保存成功後、詳細ページへリダイレクト
res.redirect(`/posts/${post.id}`);
} catch (error) {
// エラーをキャッチしてユーザーにエラーメッセージを表示
const errorMessage = error instanceof Error ? error.message : '不明なエラーが発生しました';
req.flash('alert', `システムエラー: ${errorMessage}`);
res.redirect('/posts/new'); // 新規作成ページにリダイレクト
}
}
export { create };
この例だとエラーが発生したらエラーコードを表示して終わってしまっています。また、ログにもなにも残るようになっていませんでした。現場では「エラーが出たら再クリック」をマニュアル化していたそうです。また、その後の調査では設計ミスでデッドロックが頻発してたことが発覚したらしいです。
他には以下のような例もありました。
このコードではtry-catchを使っているにも関わらず、例外処理が何も考えられていません。
その結果、postがnullのときにエラーが起きてしまいます。
class PostService {
private data: any | null = null; // インスタンス変数に相当
private errorMessage: string | null = null;
async findData(id: string): Promise<number> {
try {
// データベースやAPIからデータを取得
this.data = await Post.findById(id); // Postはデータベースのモデルと仮定
return 1; // 成功時は1を返す
} catch (error) {
// エラー発生時はエラーメッセージを保存して0を返す
this.errorMessage = error instanceof Error ? error.message : 'Unknown error';
return 0;
}
}
async saveData(data: any): Promise<number> {
//同じような処理が書かれている
}
}
(async () => {
const postService = new PostService();
const id = '123'; // サンプルID
// 一応戻り値は受け取るが、1か0のチェックはしない
const ret = await postService.findData(id);
// 何も考えずにデータを取ってくる
const post = postService.data;
// 取得に失敗したらpostはnullなので、ここでエラーが起きる
const title = post.title;
})();
この件の教訓と例外処理の基本的な考え方
スライドでは以下のようにまとめられています
• 例外処理は使い方を間違えると、害の方が大きくなる
• できることと、やっていいことは異なる
この言葉の通りで、上記のコードは一見例外処理をやっているように見えますが、例外を検知したあとに何もしていないだけです。その結果、正しくエラーを検知したり、処理できていません。try-catchでエラーを検出はできても、検出して正しく例外処理をしなければ意味がないのです。
また、例外処理の基本的な考え方として以下が紹介されています
•素人はrescueすんな!!
• エラーが出たらそこで〇ね!!
• 例外処理はフレームワークの共通処理にまかせろ!!
今回のように間違ったrescue(ralisのcatchに相当するもの)をやってしまうと悲劇を呼びます。なので安易にcatchを使うのはやめましょう。 また、エラーが出たらそれを検知して処理を適切に中止させましょう。また、フレームワークによっては例外処理を手厚くサポートしてくれている場合があります。その場合はフレームワークの共通処理に任せてしまいましょう。
例外処理を使っても許されるケース
他の仲間を道づれにしたくない場合
users.forEach(async (user) => {
try {
// 他のユーザーを道連れにしないように、エラーが発生しても続行
await sendMailTo(user);
} catch (e) {
console.error(`${e.constructor.name}: ${e.message}`);
console.error(e.stack);
}
});
一括処理をしたい際に一人のユーザの処理でエラーが出た際に他のユーザーへの処理を止めたくない場合には有効です
例外の有無でしか要件を満たせない場合
function validJsonFormat(text: string): boolean {
try {
// 実際にパースしてエラーが発生するかどうかチェック
JSON.parse(text);
return true;
} catch (e) {
if (e instanceof SyntaxError) {
return false;
}
throw e; // 他の予期しないエラーは再スロー
}
}
// 使用例
console.log(validJsonFormat('{ "a": 1 }')); // => true
console.log(validJsonFormat('{ "a": 1')); // => false
ライブラリ等を使っている場合に顕著ですが、実際に処理してみるまでエラーが起こるかどうかわからない場合もあります。その場合はtry-catchを使うのが有効です。
例外処理のバッドプラクティス
例外の握りつぶし
const user = buildUser(data);
try {
save(user);
} catch (error) {
// エラーを完全に無視
}
sendMailTo(user);
エラーをキャッチしても何もせずに処理を続行させることをエラーを握るとか握り潰すとかいいます。これをされるとエラーを補足できずに誰も調査できません。その結果、重大がバグを引き起こす可能性もあります。
この例は極端ですが戻り値が無視された場合など、結果的に無視されることもあります。
tryの範囲が無駄に広い
function convertHeiseiToDate(heiseiText: string): Date | null {
try {
const match = heiseiText.match(/平成(?<jpYear>\d+)年(?<month>\d+)月(?<day>\d+)日/);
if (!match || !match.groups) {
throw new Error("Invalid Heisei date format");
}
const jpYear = parseInt(match.groups.jpYear, 10);
const year = jpYear + 1988;
const month = parseInt(match.groups.month, 10);
const day = parseInt(match.groups.day, 10);
return new Date(year, month - 1, day); // JavaScriptのDateは月が0始まり
} catch {
// 無効な日付が渡された場合、nullを返す
return null;
}
}
このコードではtryでかなり広範囲を囲ってしまっています。そのため、どこで起きたエラーを補足したいのかわからなくなってしまっています。適切にエラーを抽出したい箇所をtryで囲むようにしましょう
例外処理をテストしない
例外処理をテストしていないと、例外処理部分のバグには当然気付けません。 これも重大なバグの原因になりますのでテストしておきましょう。
例外処理のベストプラクティス
原則としてtry-catchしない
try-catchは紹介した通りかなりバグの温床になりやすいです。 ですので仕方ない場合を除いてエラーをtry-catchするのをやめましょう。
ここからの内容はどうしてもtry-catchが必要な場合を想定します。
例外の情報をログに残す&通知する
try {
await save(user);
} catch (e: any) {
// ログに残す
logger.error(`${e.constructor.name} / ${e.message}`);
if (e.stack) {
logger.error(e.stack);
}
// 通知を送信する(Sentryを使用)
Sentry.captureException(e);
}
エラーは検知してなんぼです。 エラーが起きた際にはその原因を調査して問題があるなら修正する必要があります。逆にこれがないと重大なエラーが起きても一生気づかず、重大なトラブルにつながることも考えられるので気をつけましょう。
例外処理の対象範囲と対象クラスを絞り込む
function convertHeiseiToDate(heiseiText: string): Date | null {
const match = heiseiText.match(/平成(?<jpYear>\d+)年(?<month>\d+)月(?<day>\d+)日/);
if (!match || !match.groups) {
return null; // 正規表現にマッチしない場合
}
const jpYear = parseInt(match.groups.jpYear, 10);
const year = jpYear + 1988;
const month = parseInt(match.groups.month, 10);
const day = parseInt(match.groups.day, 10);
try {
// 無効な日付を検知するためのエラー処理
return new Date(year, month - 1, day); // JavaScriptのDateは月が0始まり
} catch (e) {
if (e instanceof RangeError) {
// 無効な日付の場合はnullを返す
return null;
}
throw e; // 他の例外は再スロー
}
}
このコードではtryの範囲が無駄に広いのコードを改修して、エラーを検知したいreturn new Date(year, month - 1, day);
の部分だけを抽出しています。これによって該当の行の想定されるエラーにだけ適切に対処できます。逆に他のエラーの場合は即終了させることでシステムにそれ以上の被害が出ないようにできます。
例外処理を使わずに済む方法があればそれを使うべき
function convertHeiseiToDate(heiseiText: string): Date | null {
const match = heiseiText.match(/平成(?<jpYear>\d+)年(?<month>\d+)月(?<day>\d+)日/);
if (!match || !match.groups) {
return null; // 正規表現にマッチしない場合
}
const jpYear = parseInt(match.groups.jpYear, 10);
const year = jpYear + 1988;
const month = parseInt(match.groups.month, 10);
const day = parseInt(match.groups.day, 10);
// 有効な日付かどうかを確認
if (isValidDate(year, month, day)) {
return new Date(year, month - 1, day);
}
return null; // 無効な日付の場合
}
条件分岐などをうまく使うと例外を使わずともどうにかなる場合もあるのでその場合は使わないようにしましょう
例外処理もテストする
例外処理をテストしないでも説明した通り例外にもテストは必要ですのでテストしましょう。再現が難しい場合はモックを活用してみてください
まとめ
以上、前編でした!
ここではスライドの基本的な部分を扱いましたが、後編では例外処理(主に設計)に関する高度なトピックとして紹介されている箇所をとりあげますので是非ご覧ください!
プロを目指す人のための例外処理(再)入門 をTypeScript版でまとめてみた 後編