アプリケーションを「黙って死なせない」ための最終防衛ライン:VB.NETにおけるグローバル例外捕捉(UnhandledException / ThreadException)の極意
開発プロジェクトを率いる中で、私は数多くの「謎の強制終了」に泣かされる開発者を見てきました。
「突然アプリが消えた。エラーメッセージすら出ない」
「ユーザーの入力データが保存されずに消去され、業務が止まった」
このような悲劇が起こる原因は明確です。アプリケーションの「最終防衛ライン」であるグローバル例外ハンドリングが設計されていない、あるいは誤って実装されているからです。
どれほどテストを重ねても、本番環境では「ネットワークの瞬断」「想定外のファイルロック」「未知のOSアップデート」といった予期せぬ例外(Unhandled Exception)が必ず発生します。プロフェッショナルが構築するシステムにおいて、例外を完全に防ぐことは不可能ですが、「エレガントにエラーを記録し、安全にシステムを閉じる(あるいは復旧する)」ことは100%可能です。
本記事では、VB.NET(Windows Forms / .NET Core / .NET Framework)における、グローバル例外捕捉基盤の構築手法を徹底的に解説します。コピペで動作し、かつ実戦に耐えうる堅牢なプロダクションコードも提示します。
—
1. なぜ「try-catch」だけでは不十分なのか?
多くの開発者は、個別のメソッド内に `Try…Catch` を記述することで満足してしまいます。しかし、これには以下の2つの致命的な限界があります。
1. 漏れの発生:すべてのメソッドに完璧な `Try…Catch` を埋め込むことは不可能です。特に、急ぎで追加された機能や外部ライブラリの呼び出し部分が「バグの温床」になります。
2. 非同期スレッドの壁:UIスレッド以外のバックグラウンドスレッド(`Task` や `Thread`)で発生した例外は、呼び出し元の `Try…Catch` をすり抜け、アプリケーションを即座にクラッシュさせます。
これを解決するのが、.NETが提供する「グローバル例外ハンドラー」です。
—
2. 2つの大動脈:`ThreadException` と `UnhandledException`
VB.NET(特にWinForms)において、捕捉すべきグローバル例外は主に2つのルートからやってきます。これを混同していると、特定のクラッシュを捕捉できずにアプリが「黙って消える」現象を防げません。
[アプリケーション内で発生した未処理例外]
│
├─► [UIスレッドで発生] ──────► Application.ThreadException イベント
│
└─► [非UI/バックグラウンド] ──► AppDomain.CurrentDomain.UnhandledException イベント
① Application.ThreadException
- 対象: メインのUIスレッド(画面の描画やボタンクリックイベントなど)で発生した未処理例外。
- 特徴: デフォルトでは、このイベントをハンドリングすると、アプリを即死させずに処理を継続するかどうかをユーザーに選択させることができます。ただし、実務においては「そのまま続行」させるとメモリや状態が不安定になり、データの二次破損を招くため、「ログを吐いて安全に終了させる」のが鉄則です。
② AppDomain.CurrentDomain.UnhandledException
- 対象: バックグラウンドスレッド、ThreadPool、または `Task` 内(※適切なAwaitがない場合など)で発生した未処理例外。
- 特徴: このイベントが発生した時点で、アプリケーションの強制終了は回避できません。(.NET 2.0以降の仕様)。ここでの役割は、「アプリを救うこと」ではなく、「死にゆくアプリの最後の断末魔(スタックトレースやメモリ状態)を、確実にログに書き残すこと」にあります。
—
3. 実務で陥る「設計の罠」と回避策
コードを書く前に、実務で必ず直面する3つの罠とその対策を頭に叩き込んでください。
罠①:ログ出力自体が例外を起こす(無限ループの恐怖)
エラーハンドラーの中で、ログファイルへの書き込みエラー(ディスクフル、書き込み権限なし等)が発生すると、再び例外イベントが走り、無限ループに陥ってプロセスがフリーズ・暴走します。
- 対策: ログ出力処理自体を極めてシンプルな `Try…Catch` で囲み、万が一の際は「Windowsイベントログ」や「標準エラー出力」、最終手段として「MsgBox」にフォールバックさせます。
罠②:非同期(Async/Await)の「Async Void」
VB.NETで `Async Sub`(C#の `async void` に相当)を使用すると、その内部で発生した例外は呼び出し元に伝播せず、ダイレクトに `AppDomain.UnhandledException` に送られます。
- 対策: イベントハンドラー以外の非同期処理は、必ず `Async Function … As Task` を使用し、例外を適切にタスク内にカプセル化してください。
罠③:排他制御(マルチスレッド競合)
複数のスレッドから同時に `UnhandledException` が呼ばれた場合、ログファイルへの同時書き込みが発生してファイルロックエラーになります。
- 対策: ログ書き込み処理には必ず `SyncLock`(C#の `lock`)をかけ、スレッドセーフに設計します。
—
4. 【完全版】実戦投入用グローバル例外ハンドラー
以下に、実務でそのまま利用できる、極めて堅牢な例外ハンドラーの設計を示します。
VB.NETの「アプリケーションフレームワーク」機能を使用せず、`Sub Main` からアプリケーションを明示的に起動する方式を採用しています。これが最も挙動を予測しやすく、プロフェッショナルに好まれる構造です。
実装手順
1. プロジェクトのプロパティを開き、「アプリケーションフレームワークを有効にする」のチェックを外します。
2. スタートアップオブジェクトを `Sub Main` に設定します。
3. 以下のコード(`Program.vb`)を作成します。
Imports System.IO
Imports System.Text
Imports System.Threading
Imports System.Windows.Forms
Public NotInheritable Class Program
‘ ログ書き込み時の排他制御用オブジェクト
Private Shared ReadOnly _logLock As New Object()
‘ ログファイルの出力先(実行フォルダ配下の “logs” フォルダ)
Private Shared ReadOnly LogDirectory As String = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, “logs”)
Private Shared ReadOnly LogFilePath As String = Path.Combine(LogDirectory, $”Crash_{DateTime.Now:yyyyMMdd}.log”)
”’
”’
Public Shared Sub Main()
‘ 1. Windows Forms の初期化
Application.EnableVisualStyles()
Application.SetCompatibleTextRenderingDefault(False)
‘ 2. グローバル例外イベントの購読(最終防衛ラインの構築)
‘ UIスレッド用
AddHandler Application.ThreadException, AddressOf Application_ThreadException
‘ UIスレッド以外の例外のキャッチ方法を「常にThreadExceptionイベントを発生させる」に設定
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException)
‘ バックグラウンドスレッド用
AddHandler AppDomain.CurrentDomain.UnhandledException, AddressOf CurrentDomain_UnhandledException
‘ 3. メインフォームの起動(あなたのアプリのメインフォームに置き換えてください)
Try
Application.Run(New MainForm())
Catch ex As Exception
‘ Application.Run 自体から漏れた致命的例外を最後の最後に救う
HandleFatalException(ex, “Application.Run 外部で発生した致命的なエラー”)
End Try
End Sub
”’
”’
Private Shared Sub Application_ThreadException(sender As Object, e As ThreadExceptionEventArgs)
HandleFatalException(e.Exception, “UIスレッド(Application.ThreadException)”)
End Sub
”’
”’
Private Shared Sub CurrentDomain_UnhandledException(sender As Object, e As UnhandledExceptionEventArgs)
Dim ex As Exception = TryCast(e.ExceptionObject, Exception)
Dim isTerminating As Boolean = e.IsTerminating
Dim context As String = $”非UIスレッド(AppDomain.UnhandledException)[プロセス終了フラグ: {isTerminating}]”
If ex IsNot Nothing Then
HandleFatalException(ex, context)
Else
‘ Exceptionオブジェクトではないものが投げられた場合(稀なケース)
HandleFatalException(New Exception($”不明なオブジェクトがスローされました: {e.ExceptionObject}”), context)
End If
End Sub
”’
”’
Private Shared Sub HandleFatalException(ex As Exception, context As String)
‘ 二重クラッシュ防止のための究極のセーフティ
Static isHandling As Boolean = False
If isHandling Then
‘ 既にエラー処理中に再度エラーが発生した場合は、即時Windowsイベントログに書いて終了
WriteWindowsEventLog(“例外処理中に別の例外が発生しました。強制終了します。”)
Environment.Exit(1)
End If
isHandling = True
Try
‘ 1. システム情報の収集と詳細ログの作成
Dim logMessage As String = BuildExceptionReport(ex, context)
‘ 2. ファイルへのログ書き込み
WriteLogToFile(logMessage)
‘ 3. ユーザーへの「優しい」通知
ShowFriendlyErrorDialog(ex)
Catch nestedEx As Exception
‘ ログ出力自体が失敗した場合はイベントログへ
WriteWindowsEventLog($”致命的エラー処理中にエラーが発生: {nestedEx.Message}{vbCrLf}{nestedEx.StackTrace}”)
Finally
‘ どんな状態であっても、システムを不安定なまま放置せず、安全にプロセスを終了させる
‘ (0以外のコードは異常終了を意味する)
Environment.Exit(1)
End If
End Sub
”’
”’
Private Shared Function BuildExceptionReport(ex As Exception, context As String) As String
Dim sb As New StringBuilder()
Dim now As DateTime = DateTime.Now
sb.AppendLine(“================================================================================”)
sb.AppendLine($”発生日時: {now:yyyy/MM/dd HH:mm:ss.fff}”)
sb.AppendLine($”カテゴリ: {context}”)
sb.AppendLine($”OSバージョン: {Environment.OSVersion}”)
sb.AppendLine($”CLRバージョン: {Environment.Version}”)
sb.AppendLine($”PC名: {Environment.MachineName} / 実行ユーザー: {Environment.UserName}”)
sb.AppendLine($”割り当てメモリ: {GC.GetTotalMemory(False) / 1024 / 1024:F2} MB”)
sb.AppendLine(“================================================================================”)
‘ 内部例外(InnerException)を再帰的に展開して記録
Dim currentEx As Exception = ex
Dim depth As Integer = 0
While currentEx IsNot Nothing
sb.AppendLine($”[Exception Level {depth}]”)
sb.AppendLine($”型 (Type): {currentEx.GetType().FullName}”)
sb.AppendLine($”メッセージ (Message): {currentEx.Message}”)
sb.AppendLine($”発生元 (Source): {currentEx.Source}”)
sb.AppendLine($”ターゲット (TargetSite): {currentEx.TargetSite}”)
sb.AppendLine(“— スタックトレース (Stack Trace) —“)
sb.AppendLine(currentEx.StackTrace)
sb.AppendLine(“——————————————————————————–“)
currentEx = currentEx.InnerException
depth += 1
End While
Return sb.ToString()
End Function
”’
”’
Private Shared Sub WriteLogToFile(message As String)
SyncLock _logLock
Try
‘ フォルダが存在しない場合は自動生成
If Not Directory.Exists(LogDirectory) Then
Directory.CreateDirectory(LogDirectory)
End If
‘ 追記モードで書き込み
Using writer As New StreamWriter(LogFilePath, True, Encoding.UTF8)
writer.WriteLine(message)
writer.Flush()
End Using
Catch
‘ ファイル書き込み失敗時は、上位に再スローしてWindowsイベントログに逃がす
Throw
End Try
End SyncLock
End Sub
”’
”’
Private Shared Sub ShowFriendlyErrorDialog(ex As Exception)
Dim userMsg As String =
$”申し訳ありません。システムで予期せぬエラーが発生したため、安全のためにアプリケーションを終了します。{vbCrLf}{vbCrLf}” &
$”【発生エラー】: {ex.Message}{vbCrLf}{vbCrLf}” &
$”詳細なログは以下に保存されました。開発担当者までご連絡ください。{vbCrLf}” &
$”[{LogFilePath}]”
MessageBox.Show(userMsg, “致命的なエラー”, MessageBoxButtons.OK, MessageBoxIcon.Error)
End Sub
”’
”’
Private Shared Sub WriteWindowsEventLog(message As String)
Try
Dim sourceName As String = “Application Error” ‘ 一般的なソース名を使用
‘ .NET Frameworkの場合、新規ソースの作成には管理者権限が必要なため、
‘ 既存の「Application」ログへ直接書き込むか、標準エラー出力へのフォールバックを試みる。
Console.Error.WriteLine(message)
Diagnostics.EventLog.WriteEntry(sourceName, message, Diagnostics.EventLogEntryType.Error)
Catch
‘ イベントログすら書き込めない場合は、もう何もできないため無視して終了する
End Try
End Sub
End Class
—
5. 運用フェーズで価値を生むための「+α」の知見
この防衛ラインを構築しただけで満足してはいけません。運用時に「本当に役立つ」ログにするためのプロのテクニックを2つ紹介します。
① PDB(プログラムデータベース)ファイルを配置せよ
せっかく取得したスタックトレースに、ソースコードの「行番号」が書いておらず、原因特定に難儀した経験はありませんか?
‘ 行番号が出ない残念なトレース
場所 AppNamespace.MainForm.Button1_Click(Object sender, EventArgs e)
‘ 行番号が出る美しいトレース
場所 AppNamespace.MainForm.Button1_Click(Object sender, EventArgs e) 場所 C:\Workspace\MainForm.vb:行 42
実行ファイル(`.exe`)と同じフォルダに、ビルド時に生成される `.pdb` ファイルを一緒にデプロイしてください。これがあるだけで、.NETランタイムはスタックトレースに正確なソースコードの行番号を出力してくれます。
② データベース(DB)へのログ保存は避けるべき
「エラーログをDBのテーブルに保存して、管理画面から見られるようにしよう」という設計をよく見かけますが、これは悪手です。
未処理例外が発生している局面では、「データベース接続が切断されている」「ネットワークがタイムアウトしている」可能性が極めて高いためです。最も信頼できるログ保存先は、常にローカルの物理ディスク(テキストファイル)です。
—
結び:例外設計は「システムの品格」を決める
優れたエンジニアと、そうでないエンジニアの境界線は、「正常系が動いたとき」ではなく、「異常系が発生したとき」の振る舞いに現れます。
アプリケーションが何の前触れもなく消滅するような不親切な設計は、ユーザーからの信頼を瞬時に失います。今回紹介したグローバル例外捕捉基盤を導入し、何が起きても「ログを完璧に残し、ユーザーに状況を伝え、安全に閉じる」という設計を徹底してください。
あなたの開発するVB.NETアプリケーションが、どんな過酷な環境でも「優雅に耐え抜く」堅牢なシステムへと進化することを期待しています。
