C# Tips | ログ・例外・診断:未処理例外捕捉

C# C#
スポンサーリンク

はじめに:「未処理例外捕捉」は“最後の砦”を用意すること

どれだけ丁寧に 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 の外側にフレームワークのメッセージループがあるため、
単に Maintry-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 の未処理例外イベントは「復旧」ではなく「記録と通知」に徹する
ユーティリティ化しておくと、どのプロジェクトでも同じ品質の“最後の砦”を簡単に用意できる
未処理例外捕捉はあくまで保険であり、各層での意味ある例外処理とセットで考える

ここまでイメージできていれば、
「落ちたけど何もログが残っていない」という最悪パターンから抜けて、
“どんな落ち方をしても必ず足跡が残るシステム”を設計できるようになります。

タイトルとURLをコピーしました