【テクニカル・上級編】Windows Formsにおけるカスタム例外ダイアログの設計:スタックトレースとシステム情報をクリップボードへワンタッチコピーする機能の実装 – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク

予期せぬシステムクラッシュが発生したとき、一般ユーザーから開発者や情報システム部門に届く「動かなくなりました」という一行のメールほど、無価値で現場を疲弊させるものはありません。

エンタープライズの現場で稼働するWindows Forms(WinForms)アプリケーションにおいて、例外(Exception)のハンドリングとその可視化は、システムのライフサイクルコストを決定づける極めて重要な設計要素です。標準の無骨なエラーダイアログや、ただスタックトレースを吐き出すだけの画面では、ユーザーは混乱し、開発者はデバッグに必要な情報を得られません。

本稿では、OSバージョン、ハードウェアスペック、メモリ使用状況、そして正確なコールスタックを瞬時に収集・整形し、クリップボードへ安全にワンタッチコピーできる「極限のカスタム例外ダイアログ」の設計・実装について解説します。

.NET Frameworkの歴史的経緯から、Win32 API P/InvokeによるOSの正確な判定、STA(Single Threaded Apartment)におけるクリップボード競合対策、メモリ最適化まで、実戦で培った知見をすべて開示します。

—

1. 例外ダイアログに求められる「情報の品格」と設計思想

優れた例外ダイアログは、「ユーザーへの配慮」と「開発者への精密な報告」を完全に両立させなければなりません。

なぜ標準の例外ダイアログでは不十分なのか?

1. OSバージョンの嘘: `.NET Framework` の `Environment.OSVersion` は、アプリケーションマニフェスト(`app.manifest`)が適切に設定されていない場合、Windows 10や11上で動作していても `6.2(Windows 8相当)` を返します。これでは正確な環境特定が不可能です。
2. クリップボード操作の不安定さ: マルチスレッド環境や非同期タスク(`Task` / `Async-Await`)から発生した例外時、UIスレッド(STA)以外からクリップボードにアクセスしようとすると `ThreadStateException` でさらにクラッシュします。また、他プロセスがクリップボードをロックしている場合の競合ハンドリングも必要です。
3. 報告フォーマットの欠如: 生のスタックトレースをそのまま見せられても、情シスや開発者はメールやRedmine、Jira等への転記に苦労します。Markdown形式やキー・バリュー形式で美しく構造化されたデータとしてコピーさせるのがプロの設計です。

—

2. 精密なシステム情報の収集:Win32 API P/Invokeの活用

まずは、環境情報を正確に抜くための「情報収集エンジン」を構築します。

特に、Windows 10/11やWindows Server 2016/2019/2022の正確なビルド番号を取得するため、`ntdll.dll` の `RtlGetVersion` を呼び出します。これにより、マニフェストの有無に関わらず、カーネルから直接正しいOSバージョンを取得できます。

OSバージョン取得用構造体とAPI定義

Imports System.Runtime.InteropServices

Public Module SystemInfoCollector


Private Structure OSVERSIONINFOEX
Public dwOSVersionInfoSize As Integer
Public dwMajorVersion As Integer
Public dwMinorVersion As Integer
Public dwBuildNumber As Integer
Public dwPlatformId As Integer

Public szCSDVersion As String
Public wServicePackMajor As Short
Public wServicePackMinor As Short
Public wSuiteMask As Short
Public wProductType As Byte
Public wReserved As Byte
End Structure


Private Function RtlGetVersion(ByRef lpVersionInformation As OSVERSIONINFOEX) As Integer
End Function

”’

”’ カーネルから直接、正確なOSバージョン文字列を取得します。
”’

Public Function GetExactOSVersion() As String
Dim osInfo As New OSVERSIONINFOEX()
osInfo.dwOSVersionInfoSize = Marshal.SizeOf(osInfo)

Try
If RtlGetVersion(osInfo) = 0 Then
Dim osName As String = “Windows (Unknown)”

‘ 主要なバージョンのマッピング
Select Case osInfo.dwMajorVersion
Case 10
‘ Windows 11もMajorVersionは10を返すため、ビルド番号で判定
If osInfo.dwBuildNumber >= 22000 Then
osName = “Windows 11”
Else
osName = “Windows 10”
End If
Case 6
Select Case osInfo.dwMinorVersion
Case 3 : osName = “Windows 8.1 / Server 2012 R2”
Case 2 : osName = “Windows 8 / Server 2012”
Case 1 : osName = “Windows 7 / Server 2008 R2”
Case 0 : osName = “Windows Vista / Server 2008″
End Select
End Select

Return $”{osName} [Ver: {osInfo.dwMajorVersion}.{osInfo.dwMinorVersion}.{osInfo.dwBuildNumber}]”
End If
Catch
‘ P/Invoke失敗時のフォールバック
End Try

Return Environment.OSVersion.ToString()
End Function
End Module

—

3. クリップボードへの堅牢な書き込み(STA・リトライ制御)

Windowsのクリップボードは、OS全体で共有されるシングルリソースです。他アプリケーション(Excelやエクスプローラー、セキュリティソフト等)がクリップボードを監視・ロックしている瞬間に書き込みを行うと、例外 `ExternalException` (CLIPBRD_E_CANT_OPEN) がスローされます。

これに対処するため、指数バックオフを伴うリトライ処理を実装します。また、呼び出し元スレッドがSTA(Single Threaded Apartment)であることを保証するための調停も行います。

Imports System.Threading
Imports System.Windows.Forms

Public Module ClipboardSafeExecutor

”’

”’ クリップボードへのテキストコピーを安全に実行します(競合リトライ、STAスレッド保証)。
”’

Public Function TrySetText(ByVal text As String, Optional ByVal maxRetries As Integer = 5) As Boolean
If String.IsNullOrEmpty(text) Then Return False

‘ 現在のスレッドがSTAでない場合、STAスレッドを生成して実行
If Thread.CurrentThread.GetApartmentState() <> ApartmentState.STA Then
Dim result As Boolean = False
Dim t As New Thread(Sub()
result = ExecuteSetTextWithRetry(text, maxRetries)
End Sub)
t.SetApartmentState(ApartmentState.STA)
t.Start()
t.Join()
Return result
Else
Return ExecuteSetTextWithRetry(text, maxRetries)
End If
End Function

Private Function ExecuteSetTextWithRetry(ByVal text As String, ByVal maxRetries As Integer) As Boolean
Dim delay As Integer = 50 ‘ 初期ウェイト(ミリ秒)
For i As Integer = 1 To maxRetries
Try
Clipboard.Clear()
Clipboard.SetText(text, TextDataFormat.UnicodeText)
Return True
Catch ex As ExternalException
If i = maxRetries Then Return False
Thread.Sleep(delay)
delay = 2 ‘ 指数バックオフ
End Try
Next
Return False
End Function
End Module

—

4. カスタム例外ダイアログ(VB.NETフォーム)の完全実装

それでは、美しく実用的なカスタム例外ダイアログの実装に入ります。
このダイアログは、デザイナーで作成することも可能ですが、今回は「既存のプロジェクトに1ファイル追加するだけで即座に稼働する」よう、コードビハインドだけでコントロールを動的にレイアウトする設計をとっています。

これにより、保守開発においてフォームのリソース管理やデザイナーのバージョン競合に悩まされることがなくなります。

`CustomExceptionForm.vb`

Imports System.Diagnostics
Imports System.Drawing
Imports System.IO
Imports System.Text
Imports System.Windows.Forms

Public Class CustomExceptionForm
Inherits Form

Private ReadOnly _exception As Exception
Private _systemInfoText As String

‘ UIコントロールの動的定義
Private lblHeader As Label
Private txtMessage As TextBox
Private tabControl As TabControl
Private tabStackTrace As TabPage
Private tabSysInfo As TabPage
Private txtStackTrace As TextBox
Private txtSysInfo As TextBox
Private btnCopy As Button
Private btnClose As Button
Private picIcon As PictureBox

Public Sub New(ByVal ex As Exception)
_exception = ex
InitializeComponent()
GenerateSystemInfo()
End Sub

Private Sub InitializeComponent()
‘ フォーム基本設定
Me.Text = “アプリケーション・エラー”
Me.Size = New Size(620, 480)
Me.MinimumSize = New Size(500, 400)
Me.StartPosition = FormStartPosition.CenterScreen
Me.MaximizeBox = False
Me.MinimizeBox = False
Me.ShowInTaskbar = True
Me.Font = New Font(“Segoe UI”, 9.0!, FontStyle.Regular, GraphicsUnit.Point)
Me.BackColor = Color.FromArgb(245, 245, 245)

‘ ヘッダーアイコン(システム警告アイコンの流用)
picIcon = New PictureBox() With {
.Location = New Point(20, 20),
.Size = New Size(48, 48),
.SizeMode = PictureBoxSizeMode.StretchImage,
.Image = SystemIcons.Error.ToBitmap()
}

‘ メッセージヘッダー
lblHeader = New Label() With {
.Location = New Point(80, 20),
.Size = New Size(500, 23),
.Font = New Font(“Segoe UI”, 11.0!, FontStyle.Bold, GraphicsUnit.Point),
.Text = “予期しないエラーが発生しました。”
}

‘ ユーザー向け簡易説明
txtMessage = New TextBox() With {
.Location = New Point(80, 45),
.Size = New Size(500, 45),
.Multiline = True,
.ReadOnly = True,
.BorderStyle = BorderStyle.None,
.BackColor = Color.FromArgb(245, 245, 245),
.Text = “お手数ですが、以下のエラー情報を「クリップボードにコピー」し、システム管理者または開発担当者へご報告ください。”
}

‘ タブコントロール
tabControl = New TabControl() With {
.Location = New Point(20, 100),
.Size = New Size(560, 280),
.Anchor = AnchorStyles.Top Or AnchorStyles.Bottom Or AnchorStyles.Left Or AnchorStyles.Right
}

‘ タブ1: スタックトレース
tabStackTrace = New TabPage(“エラー詳細”)
txtStackTrace = New TextBox() With {
.Dock = DockStyle.Fill,
.Multiline = True,
.ScrollBars = ScrollBars.Both,
.ReadOnly = True,
.Font = New Font(“Consolas”, 9.0!, FontStyle.Regular, GraphicsUnit.Point),
.WordWrap = False,
.Text = GetExceptionDetailText(_exception)
}
tabStackTrace.Controls.Add(txtStackTrace)

‘ タブ2: システム環境情報
tabSysInfo = New TabPage(“システム環境情報”)
txtSysInfo = New TextBox() With {
.Dock = DockStyle.Fill,
.Multiline = True,
.ScrollBars = ScrollBars.Both,
.ReadOnly = True,
.Font = New Font(“Consolas”, 9.0!, FontStyle.Regular, GraphicsUnit.Point),
.WordWrap = False
}
tabSysInfo.Controls.Add(txtSysInfo)

tabControl.TabPages.Add(tabStackTrace)
tabControl.TabPages.Add(tabSysInfo)

‘ コピーボタン(ワンタッチコピー)
btnCopy = New Button() With {
.Text = “📋 情報をコピー”,
.Location = New Point(320, 395),
.Size = New Size(130, 32),
.Anchor = AnchorStyles.Bottom Or AnchorStyles.Right,
.Cursor = Cursors.Hand,
.FlatStyle = FlatStyle.System
}
AddHandler btnCopy.Click, AddressOf BtnCopy_Click

‘ 閉じるボタン
btnClose = New Button() With {
.Text = “閉じる”,
.Location = New Point(460, 395),
.Size = New Size(120, 32),
.Anchor = AnchorStyles.Bottom Or AnchorStyles.Right,
.FlatStyle = FlatStyle.System
}
AddHandler btnClose.Click, Sub(sender, e) Me.Close()

‘ コントロールの配置
Me.Controls.AddRange(New Control() {picIcon, lblHeader, txtMessage, tabControl, btnCopy, btnClose})
End Sub

”’

”’ システムおよび実行環境情報をバックグラウンドで組み立てます。
”’

Private Sub GenerateSystemInfo()
Dim sb As New StringBuilder()
Dim proc As Process = Process.GetCurrentProcess()

sb.AppendLine(“=== SYSTEM INFORMATION ===”)
sb.AppendLine($”Timestamp : {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}”)
sb.AppendLine($”OS Version : {SystemInfoCollector.GetExactOSVersion()}”)
sb.AppendLine($”OS 64-bit : {Environment.Is64BitOperatingSystem}”)
sb.AppendLine($”Process 64-bit : {Environment.Is64BitProcess}”)
sb.AppendLine($”Machine Name : {Environment.MachineName}”)
sb.AppendLine($”User Domain/Name : {Environment.UserDomainName}\{Environment.UserName}”)
sb.AppendLine($”CLR Version : {Environment.Version}”)
sb.AppendLine($”Working Set (RAM): {proc.WorkingSet64 / 1024 / 1024:N0} MB”)
sb.AppendLine($”System Uptime : {TimeSpan.FromMilliseconds(Environment.TickCount):\.d\ \d\a\y\s\,\ \h\h\:\m\m\:\s\s}”)
sb.AppendLine()
sb.AppendLine(“=== APPLICATION INFORMATION ===”)
Dim mainAssembly = System.Reflection.Assembly.GetEntryAssembly()
If mainAssembly IsNot Nothing Then
sb.AppendLine($”Entry Assembly : {mainAssembly.GetName().Name}”)
sb.AppendLine($”App Version : {mainAssembly.GetName().Version}”)
sb.AppendLine($”Base Directory : {AppDomain.CurrentDomain.BaseDirectory}”)
End If
sb.AppendLine()
sb.AppendLine(“=== SCREEN RESOLUTION ===”)
For i As Integer = 0 To Screen.AllScreens.Length – 1
Dim s = Screen.AllScreens(i)
sb.AppendLine($”Screen {i} : {s.Bounds.Width}x{s.Bounds.Height} (Primary: {s.Primary})”)
Next

_systemInfoText = sb.ToString()
txtSysInfo.Text = _systemInfoText
End Sub

”’

”’ 例外オブジェクトから再帰的にエラー詳細テキストを生成します。
”’

Private Function GetExceptionDetailText(ByVal ex As Exception) As String
Dim sb As New StringBuilder()
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}”)
If currentEx.TargetSite IsNot Nothing Then
sb.AppendLine($”Method : {currentEx.TargetSite.DeclaringType.FullName}.{currentEx.TargetSite.Name}”)
End If
sb.AppendLine(“Stack Trace :”)
sb.AppendLine(currentEx.StackTrace)
sb.AppendLine()

currentEx = currentEx.InnerException
depth += 1
End While

Return sb.ToString()
End Function

”’

”’ Markdown形式で整形された完全なレポートを組み立てます。
”’

Private Function BuildReport() As String
Dim sb As New StringBuilder()
sb.AppendLine(“”)
sb.Append(_systemInfoText)
sb.AppendLine(“”)
sb.AppendLine()
sb.AppendLine(“

EXCEPTION DETAILS”)

sb.AppendLine(“”)
sb.AppendLine(txtStackTrace.Text)
sb.AppendLine(“”)
Return sb.ToString()
End Function

Private Sub BtnCopy_Click(ByVal sender As Object, ByVal e As EventArgs)
Dim reportText As String = BuildReport()

If ClipboardSafeExecutor.TrySetText(reportText) Then
Dim originalText As String = btnCopy.Text
btnCopy.Text = “✔️ コピー完了!”
btnCopy.Enabled = False

‘ 1.5秒後にボタン表示を戻すタイマー
Dim t As New Timer() With {.Interval = 1500}
AddHandler t.Tick, Sub()
btnCopy.Text = originalText
btnCopy.Enabled = True
t.Stop()
t.Dispose()
End Sub
t.Start()
Else
MessageBox.Show(Me, “クリップボードの確保に失敗しました。他のアプリケーションがクリップボードを使用中である可能性があります。”, “コピーエラー”, MessageBoxButtons.OK, MessageBoxIcon.Warning)
End If
End Sub

Protected Overrides Sub Dispose(ByVal disposing As Boolean)
If disposing Then
‘ WinFormsコントロールとリソースの明示的解放
If picIcon IsNot Nothing Then picIcon.Dispose()
End If
MyBase.Dispose(disposing)
End Sub
End Class

—

5. アプリケーション全体への組み込み:グローバル例外ハンドリング

カスタム例外ダイアログは、単に `Try-Catch` で囲まれた局所的な場所だけで使うものではありません。アプリケーション内で発生した「すべての未処理の例外」を漏らさずキャッチするセーフティネットとして機能させる必要があります。

Windows Formsアプリケーションの起動点である `Program.vb`(または `ApplicationEvents.vb`)で、以下の3つの未処理例外イベントを確実にハンドリングします。

1. `Application.ThreadException`: メインUIスレッド上で発生した未処理例外を捕捉します。
2. `AppDomain.CurrentDomain.UnhandledException`: バックグラウンドスレッドなど、UIスレッド以外で発生した未処理例外を捕捉します。
3. `TaskScheduler.UnobservedTaskException`: `Task`(非同期処理)の内部で発生し、待機(Wait / Await)されなかった例外を捕捉します。

グローバルハンドラーの実装例 (`Program.vb`)

Public Class Program

Public Shared Sub Main()
Application.EnableVisualStyles()
Application.SetCompatibleTextRenderingDefault(False)

‘ 1. UIスレッドの未処理例外ハンドラーを登録
AddHandler Application.ThreadException, AddressOf Application_ThreadException

‘ 2. UIスレッド以外の未処理例外ハンドラーを登録
AddHandler AppDomain.CurrentDomain.UnhandledException, AddressOf CurrentDomain_UnhandledException

‘ 3. 非同期タスク内の未処理例外ハンドラーを登録
AddHandler TaskScheduler.UnobservedTaskException, AddressOf TaskScheduler_UnobservedTaskException

‘ アプリケーションのメインフォーム起動
Application.Run(New MainForm())
End Class

Private Shared Sub Application_ThreadException(ByVal sender As Object, ByVal e As ThreadExceptionEventArgs)
ShowExceptionDialog(e.Exception)
End Sub

Private Shared Sub CurrentDomain_UnhandledException(ByVal sender As Object, ByVal e As UnhandledExceptionEventArgs)
Dim ex As Exception = TryCast(e.ExceptionObject, Exception)
If ex IsNot Nothing Then
ShowExceptionDialog(ex)
End If
End Sub

Private Shared Sub TaskScheduler_UnobservedTaskException(ByVal sender As Object, ByVal e As UnobservedTaskExceptionEventArgs)
ShowExceptionDialog(e.Exception)
‘ 例外がプロセスをクラッシュさせるのを防ぐため、処理済みとする
e.SetObserved()
End Sub

Private Shared Sub ShowExceptionDialog(ByVal ex As Exception)
‘ ロギング(NLog, log4net 等を用いてファイルに書き出しを推奨)
‘ LogError(ex)

‘ スレッドセーフにフォームを起動する
If Application.OpenForms.Count > 0 Then
Dim activeForm = Application.OpenForms(0)
If activeForm.InvokeRequired Then
activeForm.Invoke(Sub() ShowForm(ex))
Else
ShowForm(ex)
End If
Else
ShowForm(ex)
End If
End Sub

Private Shared Sub ShowForm(ByVal ex As Exception)
Using dialog As New CustomExceptionForm(ex)
dialog.ShowDialog()
End Using
End Sub
End Class

—

6. チーフアーキテクトが語る:メモリ管理とライフサイクルの真実

このカスタム例外ダイアログを設計・運用するにあたり、シニアエンジニアが絶対に妥協してはならないディテールがいくつか存在します。

1. `Using` ステートメントによる厳格な `Dispose`

例外ダイアログの起動頻度は低いはずですが、だからといってリソース管理を怠ることは許されません。
`CustomExceptionForm` は `Form` クラスを継承しており、システムリソース(ウィンドウハンドル、描画用のGDI+オブジェクト、アイコン等)を保持しています。`ShowDialog()` で呼び出したフォームは、閉じられた後も自動的には `Dispose` されません。呼び出し側が `Using` ブロックで確実にスコープを抜ける際に解放するか、明示的に `Dispose()` を呼ぶことが鉄則です。

2. GDI+ リソースの漏洩(リーク)防止

`SystemIcons.Error.ToBitmap()` で生成したビットマップは、アンマネージドなGDI+リソースです。これをそのまま `PictureBox.Image` に割り当てた場合、フォームが閉じられただけではメモリ上に残り続けます。
コード例に示した通り、`Dispose(ByVal disposing As Boolean)` メソッドをオーバーライドし、フォーム破棄時に `picIcon` の解放処理を明示的に行っているのはこのためです。

3. STAスレッドとCOMの死活問題

クリップボードは内部的にOLE/COM(Component Object Model)の技術に依存しています。
`.NET` のスレッドプール(`ThreadPool`)や `Task` から直接 `Clipboard` クラスを操作しようとすると、スレッドのアパートメント状態が `MTA`(Multi-Threaded Apartment)であるため、高確率で沈黙するかスローします。
本稿で提供した `ClipboardSafeExecutor` は、現在の呼び出し元スレッドの `ApartmentState` を検査し、必要であれば動的に `STAThread` を仕立ててから処理を委譲することで、この根深いCOMの問題を完全に回避しています。

—

まとめ:防御的UIがシステムの寿命を延ばす

システム開発において、「エラーは発生しない」という前提はただの幻想です。

真に優れたアーキテクトは、「エラーは必ず発生する」という前提に立ち、発生した瞬間のダメージコントロールとリカバリーの速度を最大化する仕掛けをあらかじめシステムに組み込みます。

今回紹介したカスタム例外ダイアログは、泥臭いレガシーなWin32の挙動を理解しつつ、それをモダンなMarkdown形式のレポートへ昇華させる、システム管理部門への最大の「敬意」と「配慮」が詰まったパーツです。ぜひ、あなたのWindows Formsプロジェクトに組み込み、開発・運用のサイクルを劇的に改善させてください。

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