【実務・中級編】実務中級者向け:VB.NETアプリケーションにおける「Application.ThreadException」と「AppDomain.CurrentDomain.UnhandledException」によるグローバル例外捕捉基盤 – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク

アプリケーションを「黙って死なせない」ための最終防衛ライン: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

”’

”’ UIスレッドで発生した未処理例外のハンドラー
”’

Private Shared Sub Application_ThreadException(sender As Object, e As ThreadExceptionEventArgs)
HandleFatalException(e.Exception, “UIスレッド(Application.ThreadException)”)
End Sub

”’

”’ 非UIスレッド/バックグラウンドで発生した未処理例外のハンドラー
”’

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

”’

”’ 最後の手段としてWindowsのイベントログにエラーを記録する
”’

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アプリケーションが、どんな過酷な環境でも「優雅に耐え抜く」堅牢なシステムへと進化することを期待しています。

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