はじめに:「未処理例外捕捉」は“最後の砦”を用意すること
どれだけ丁寧に try-catch を書いても、どこかで必ず「取りこぼしの例外」が出ます。
想定していなかったバグ、ライブラリ内部の例外、テストでは通っていたけれど本番データで落ちるケース…。
そういう「どこにもキャッチされなかった例外」を、そのまま黙ってアプリを落とすのではなく、
ログに残す
最低限のメッセージをユーザーに伝える
必要なら異常終了コードを返す
といった“最後の対応”をする場所を用意するのが 未処理例外捕捉 です。
ここでは、初心者向けに
未処理例外とは何か
コンソールアプリ/WPF/ASP.NET Core などでの捕捉ポイント
ILogger と組み合わせた「最後の砦」ユーティリティ
どこまで未処理例外に頼ってよくて、どこからは各層で処理すべきか
を、例題付きでかみ砕いて説明します。
未処理例外とは何か
「どこにも catch されずに、アプリの外まで飛び出した例外」
C# では、例外が発生すると、まずそのメソッド内の try-catch を探します。
なければ呼び出し元へ、さらにその呼び出し元へ…と、上へ上へと伝播していきます。
どこにも catch がなかった場合、最終的に「ランタイム(.NET)」まで届きます。
これが 未処理例外(Unhandled Exception) です。
未処理例外が発生すると、通常は
アプリケーションが強制終了する
コンソールなら「未処理の例外が発生しました」と表示される
GUI アプリならエラーダイアログが出ることもある
という挙動になります。
ここでの重要ポイントは、
「未処理例外は“バグの最後の姿”であり、ここをログに残せるかどうかで調査のしやすさが大きく変わる」
ということです。
コンソールアプリ/バッチでの未処理例外捕捉
Main の一番外側で「全部まとめて受け止める」
コンソールアプリやバッチ処理では、Main メソッドが「アプリの入口かつ出口」です。
ここで try-catch を書いておくと、「どこにもキャッチされなかった例外」を最後にまとめて受け止められます。
using Microsoft.Extensions.Logging;
class Program
{
static int Main(string[] args)
{
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
});
var logger = loggerFactory.CreateLogger<Program>();
try
{
Run(args, logger);
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "未処理例外が発生しました。バッチを異常終了します。");
return 1; // 異常終了コード
}
}
static void Run(string[] args, ILogger logger)
{
// ここから先でキャッチされなかった例外は、Main の catch に届く
DoSomethingDangerous();
}
}
C#ここでの重要ポイントは、
「Main の一番外側で catch しておけば、“どこで落ちても最後にログが残る”状態を作れる」
「戻り値(終了コード)で、正常終了か異常終了かを OS に伝えられる」
という 2 点です。
バッチ運用では、この終了コードが監視やジョブ管理に使われます。
WPF/WinForms など GUI アプリでの未処理例外捕捉
アプリケーションレベルのイベントを使う
GUI アプリでは、Main の外側にフレームワークのメッセージループがあるため、
単に Main を try-catch しても、すべての例外を拾えるとは限りません。
そのため、アプリケーションレベルの「未処理例外イベント」を使います。
WPF の例(App.xaml.cs)
public partial class App : Application
{
private ILogger<App> _logger;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
_logger = loggerFactory.CreateLogger<App>();
this.DispatcherUnhandledException += App_DispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
}
private void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
_logger.LogError(e.Exception, "UI スレッドで未処理例外が発生しました。");
MessageBox.Show("エラーが発生しました。アプリを終了します。");
e.Handled = true; // ここで true にしないと、そのまま落ちる
Current.Shutdown(1);
}
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
if (e.ExceptionObject is Exception ex)
{
_logger.LogError(ex, "非 UI スレッドで未処理例外が発生しました。");
}
}
}
C#ここでの重要ポイントは、
「UI スレッドの未処理例外(DispatcherUnhandledException)と、その他のスレッドの未処理例外(AppDomain.CurrentDomain.UnhandledException)を両方ハンドルする」
「ユーザーには簡潔なメッセージを出し、詳細はログに残す」
という 2 点です。
GUI アプリでは、ユーザー体験も考えながら“最後の砦”を設計します。
ASP.NET Core での未処理例外捕捉
例外ハンドリングミドルウェアが「全リクエストの最後の砦」
Web アプリ/API では、リクエストごとにパイプライン(ミドルウェアのチェーン)が流れます。
この一番外側に「例外ハンドリングミドルウェア」を置くことで、
コントローラやサービス層でキャッチされなかった例外をまとめて捕捉できます。
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task Invoke(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "未処理例外。Path={Path}", context.Request.Path);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync("{\"message\":\"サーバー内部でエラーが発生しました。\"}");
}
}
}
C#登録は Program.cs などで行います。
app.UseMiddleware<GlobalExceptionMiddleware>();
C#ここでの重要ポイントは、
「どのコントローラで落ちても、必ずこのミドルウェアを通ってログが書かれる」
「クライアントには統一されたエラーレスポンスを返せる」
という点です。
API の利用者にとっても、ログを見る開発者にとっても、挙動が一貫します。
AppDomain.CurrentDomain.UnhandledException の使いどころ
「プロセス全体で捕まえきれなかった例外」を拾う最後のイベント
コンソールでも GUI でも、共通して使えるのが AppDomain.CurrentDomain.UnhandledException です。
AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
{
if (e.ExceptionObject is Exception ex)
{
logger.LogError(ex, "AppDomain レベルの未処理例外が発生しました。");
}
};
C#ただし、このイベントには注意点があります。
ハンドラが呼ばれたあと、プロセスは基本的に終了する
ここで「何とかして続行する」ことはほぼできない(してはいけない)
つまり、ここは
ログを残す
必要なら外部通知(メール・監視システムなど)を飛ばす
くらいにとどめておく場所です。
ここでの重要ポイントは、
「AppDomain の未処理例外イベントは、“本当に最後の最後に呼ばれる場所”であり、復旧ではなく“記録と通知”に徹するべき」
ということです。
未処理例外捕捉ユーティリティを作るイメージ
「ILogger を渡すだけで、最後の砦をセットアップできる」形にする
毎回同じようなイベント登録を書くのは面倒なので、
小さなユーティリティクラスにまとめておくと便利です。
public static class UnhandledExceptionHandler
{
public static void RegisterGlobalHandlers(ILogger logger)
{
AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
{
if (e.ExceptionObject is Exception ex)
{
logger.LogError(ex, "AppDomain レベルの未処理例外");
}
};
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
logger.LogError(e.Exception, "未観測の Task 例外");
e.SetObserved();
};
}
}
C#コンソールアプリなら Main でこう呼びます。
var logger = loggerFactory.CreateLogger<Program>();
UnhandledExceptionHandler.RegisterGlobalHandlers(logger);
C#ここでの重要ポイントは、
「“どのプロジェクトでも同じように未処理例外を拾える”仕組みをユーティリティ化しておくと、書き忘れが減る」
「Task の未観測例外(UnobservedTaskException)も一緒に拾っておくと、非同期の落ち方も追いやすくなる」
という点です。
「未処理例外に頼りすぎない」ことも大事
本来は「各層で意味のある例外処理」を書いたうえでの“保険”
未処理例外捕捉はとても大事ですが、
「とりあえず全部未処理例外で拾えばいいや」と考えるのは危険です。
本来は、
リポジトリ層では、DB 例外を業務例外にラップする
サービス層では、業務的にリカバリできるものはここで処理する
UI 層/API 層では、ユーザーやクライアントにどう伝えるかを決める
という「意味のある例外処理」を各層で書いたうえで、
それでも取りこぼしたものを 未処理例外捕捉でログに残す、という位置づけが健全です。
ここでの重要ポイントは、
「未処理例外捕捉は“保険”であって、“メインの例外処理”ではない」
という意識を持つことです。
保険は大事だけど、運転そのものを雑にしていい理由にはなりません。
まとめ:未処理例外捕捉は“どんな落ち方をしても、最後に足跡を残す”ための仕組み
未処理例外捕捉の本質は、
どこで例外が起きても
どんな経路で伝播しても
最終的に必ずログと最低限の対応を行う
という「最後の砦」をアプリに組み込むことです。
押さえておきたいポイントをまとめると、
コンソール/バッチでは Main の一番外側で try-catch してログ+終了コードを返す
GUI アプリでは DispatcherUnhandledException と AppDomain.CurrentDomain.UnhandledException を組み合わせる
ASP.NET Core では例外ハンドリングミドルウェアを置いて、全リクエストの最後の砦にする
AppDomain の未処理例外イベントは「復旧」ではなく「記録と通知」に徹する
ユーティリティ化しておくと、どのプロジェクトでも同じ品質の“最後の砦”を簡単に用意できる
未処理例外捕捉はあくまで保険であり、各層での意味ある例外処理とセットで考える
ここまでイメージできていれば、
「落ちたけど何もログが残っていない」という最悪パターンから抜けて、
“どんな落ち方をしても必ず足跡が残るシステム”を設計できるようになります。
