C# Tips | ログ・例外・診断:グローバル例外

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

はじめに:「グローバル例外」は“アプリ全体でただ一つの例外の窓口”を作ること

例外処理って、最初は「必要なところで try-catch 書けばいいでしょ?」で済ませがちですよね。
でもアプリが大きくなると、あちこちにバラバラな try-catch が増えていきます。

どこかでキャッチし忘れた例外
どの画面で落ちたのか分からないログ
API なのに、スタックトレース丸出しのレスポンス

こういう“バラバラ感”を一気に整えてくれる考え方が グローバル例外(グローバル例外ハンドリング) です。
ざっくり言うと、

「アプリ全体で、例外が最後に必ず通る“共通の窓口”を用意する」

という設計です。

ここでは、初心者向けに

グローバル例外ハンドリングの考え方
コンソールアプリ/バッチでのグローバル例外
ASP.NET Core でのミドルウェアによるグローバル例外
WPF などデスクトップアプリでのグローバル例外
小さなユーティリティとしてどうまとめるか

を、例題付きで丁寧に解説します。


グローバル例外ハンドリングとは何か

「どこで落ちても、最後はここに来る」という一点を決める

グローバル例外ハンドリングのイメージは、とてもシンプルです。

アプリのどこかで例外が発生する
その場で処理できなければ、上の層へ伝播していく
最終的に「アプリ全体でただ一つの“最後のハンドラ”」に到達する
そこで必ずログを書き、ユーザーやクライアントに統一された反応を返す

この「最後のハンドラ」が、グローバル例外ハンドラです。

重要なのは、ここでやることを決めておくことです。

ログを必ず残す
ユーザーには“安全なメッセージ”だけ返す
API なら、決めた形式のエラーレスポンスを返す
バッチなら、異常終了コードを返す

ここでの重要ポイントは、
グローバル例外は“最後の砦”であり、“好き勝手に何でもする場所”ではない
ということです。
やることはシンプルに、“記録”と“最低限の応答”に絞ります。


コンソールアプリ/バッチでのグローバル例外

Main の一番外側を「全例外の窓口」にする

コンソールアプリやバッチ処理では、Main がアプリの入口であり出口です。
ここをグローバル例外ハンドラとして使うのが、一番分かりやすい形です。

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#

ここでの重要ポイントは二つです。

一つ目は、「どこで落ちても、最後に必ずログが残る」状態を作れること。
二つ目は、「終了コードで“成功か失敗か”を OS やジョブ管理に伝えられる」ことです。

バッチ運用では、この終了コードが監視や再実行のトリガーになります。


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#

これで、

どのコントローラで例外が起きても
どのサービス層で取りこぼしても
必ずこのミドルウェアでログが書かれ、クライアントには統一された 500 エラーが返る

という状態になります。

ここでの重要ポイントは、
グローバル例外ハンドラが“ログの一貫性”と“レスポンスの一貫性”を両方担う
ということです。
API 利用者にとっても、ログを見る側にとっても、挙動が揃っているのは大きな安心材料になります。


WPF/WinForms などデスクトップアプリでのグローバル例外

アプリケーションレベルのイベントをまとめて“窓口化”する

デスクトップアプリでは、フレームワークがメッセージループを持っているため、
単に Maintry-catch しても、すべての例外を拾えるとは限りません。

そこで、アプリケーションレベルのイベントを使って「グローバル例外」を扱います。

WPF の例を見てみます。

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;
        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)を両方カバーすること。
ユーザーには簡潔なメッセージを出し、詳細はログに残すこと。

これで、「どの画面で落ちても、最後には必ずログが残り、ユーザーには最低限の案内が出る」状態を作れます。


グローバル例外ユーティリティを作る

「ILogger を渡すだけで“最後の砦”をセットアップできる」形にする

毎回同じようなイベント登録やミドルウェアを書くのは面倒なので、
小さなユーティリティとしてまとめておくと再利用しやすくなります。

例えば、「AppDomain レベルと Task の未処理例外をまとめてログする」ユーティリティです。

public static class GlobalExceptionRegistrar
{
    public static void Register(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>();
GlobalExceptionRegistrar.Register(logger);
C#

ここでの重要ポイントは、
“どのプロジェクトでも同じ品質のグローバル例外ログ”を簡単に用意できるようにしておく
ということです。
プロジェクトごとに書き方がバラバラだと、ログの見え方もバラバラになってしまいます。


グローバル例外に“頼りすぎない”ことも大事

グローバル例外は“保険”、メインの例外処理は各層で

グローバル例外ハンドラはとても強力ですが、
「全部ここで何とかすればいいや」と考えるのは危険です。

本来は、

リポジトリ層では、DB 例外を業務例外にラップする
サービス層では、業務的にリカバリできるものはここで処理する
UI/API 層では、ユーザーやクライアントにどう伝えるかを決める

という「意味のある例外処理」を各層で書いたうえで、
それでも取りこぼしたものを グローバル例外ハンドラでログと最終対応をする、という位置づけが健全です。

ここでの重要ポイントは、
グローバル例外は“最後の保険”であって、“例外処理の全部”ではない
という意識を持つことです。
保険があるからといって、わざと危ない運転はしないのと同じです。


まとめ:グローバル例外は“どんな落ち方をしても、最後にきちんと受け止める”ための設計

グローバル例外ハンドリングの本質は、

アプリのどこで例外が起きても
どんな経路で伝播しても
最後に必ず一箇所でログと最低限の応答を行う

という「ただ一つの窓口」を用意することです。

押さえておきたいポイントをコンパクトにまとめると、

コンソール/バッチでは Main の一番外側をグローバル例外ハンドラにする。
ASP.NET Core では例外ハンドリングミドルウェアを置き、全リクエストの最後の砦にする。
デスクトップアプリでは DispatcherUnhandledException と AppDomain.CurrentDomain.UnhandledException を組み合わせる。
共通ユーティリティとして登録処理をまとめておくと、どのプロジェクトでも同じ品質で使い回せる。
グローバル例外は“保険”であり、各層での意味ある例外処理とセットで考える。

ここまでイメージできていれば、
「落ちたけど何もログが残っていない」「画面ごとにエラーの出方がバラバラ」といった状態から抜けて、
“落ち方はバラバラでも、最後の受け止め方は一貫しているシステム”を設計できるようになります。

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