Word VBA 堅牢化の極致:FSOとWin32 APIを融合したエンタープライズ級エラーロギングアーキテクチャ
オフィスオートメーションの現場において、Word VBAはExcel VBAの影に隠れがちである。しかし、契約書、公文書、仕様書といった「非定型かつ厳密な文書構造」を自動生成する局面において、Word VBAの右に出るツールは存在しない。
だが、Wordのオブジェクトモデル(`Application`、`Document`、`Range`)は、Excelのそれに比べて極めて気難しい。StoryRangesの概念、BookmarkやFieldの衝突、そして非表示で起動されたWordインスタンスがタスクマネージャーに幽霊のように居座る「ゴーストプロセス問題」など、開発者を悩ませる罠が至る所に潜んでいる。
これらの不確実性が支配する本番環境において、マクロの異常停止は業務の致命的な遅延を意味する。我々シニアエンジニアが構築すべきは、単に「エラーで落ちないマクロ」ではない。「万が一の異常発生時に、発生箇所のコールスタック、プロセス状態、システム環境をミリ秒単位で完全に記録し、一撃で原因を特定できる堅牢なロギング基盤」である。
本稿では、`FileSystemObject` (FSO) と Win32 API を融合させ、COMオブジェクトのライフサイクル管理まで徹底的に考慮した、極限のWord VBAエラーロギングフレームワークの構築手法を解説する。
—
1. Word VBAにおけるエラーハンドリングの特殊性と設計思想
Excel VBAとの決定的な違いは、Wordの文書構造が「1次元の文字ストリームに対するビューの重ね合わせ」である点にある。特に `Range` オブジェクトの操作中にエラーが発生した場合、Wordは内部的なポインタを見失い、ドキュメントをロックしたままクラッシュすることがある。
堅牢なエラーロギング基盤を設計するにあたり、以下の4つの要件を定義する。
1. ミリ秒精度のタイムスタンプと実行コンテキストの捕捉
VBA標準の `Now` 関数(1秒精度)では、高速ループ処理中のエラー順序を保証できない。Win32 APIの `GetLocalTime` を使用し、ミリ秒精度での記録を行う。さらに、マルチユーザー環境やCitrix等の仮想環境を想定し、プロセスID (PID) とWindowsログインユーザー名も同時に記録する。
2. 呼出履歴(コールスタック)の自己追跡
VBAには標準でコールスタックを取得するAPIが存在しない。これをクラスモジュール内のスタック構造で擬似的に表現し、エラー発生時に「どのメソッドを経由してエラーに到達したか」を正確にログ出力する。
3. COMオブジェクトの「確実な解放」を妨げない設計
ロギング処理自体がエラーを起こしては本末転倒である。また、ログ出力中にドキュメントやアプリケーションの参照を掴みっぱなしにしないよう、ファイルI/Oは「Open – Write – Close」を極めて短いライフサイクルで完結させる。
4. レガシーとモダンをつなぐファイル制御
`FileSystemObject` を採用し、共有フォルダー上のログファイルに対する書き込み競合(ロック競合)を適切にハンドリングする。
—
2. システムアーキテクチャ
本システムは、以下の3つのコンポーネントで構成される。
+————————————————————-+
| Client Macro (Standard Module) |
| – Business Logic |
| – Try-Catch-Finally Pattern |
+————————————————————-+
|
| Push/Pop Stack & LogException
v
+————————————————————-+
| Logger Class (ClsLogger) |
| – Singleton-like Access |
| – Call Stack Management |
+————————————————————-+
|
| Direct File I/O (FSO) & Win32 API
v
+————————————————————-+
| OS / File System |
| – Write to UTF-16 Log File |
| – Win32 API (Kernel32.dll) for System Info |
+————————————————————-+
—
3. 極限のロギングクラス:`ClsLogger` の実装
まずは、エラーロギングのコアとなるクラスモジュール `ClsLogger` を作成する。
以下のコードをクラスモジュール(オブジェクト名:`ClsLogger`)に配置してほしい。
Officeの32bit/64bit双方のアーキテクチャに完全対応(VBA7/Win64対応)し、ポインタ型には `LongPtr` を使用している。
Option Explicit
‘ ==============================================================================
‘ クラス名: ClsLogger
‘ 役割: 高精度システム情報の取得、コールスタック管理、および堅牢なファイル書き出し
‘ 依存関係: Microsoft Scripting Runtime (レイトバインディングにより参照設定不要)
‘ ==============================================================================
‘ — Win32 API 宣言 (64bit / 32bit 両対応) —
If VBA7 Then
Private Declare PtrSafe Sub GetLocalTime Lib “kernel32” (ByRef lpSystemTime As SYSTEMTIME)
Private Declare PtrSafe Function GetCurrentProcessId Lib “kernel32” () As Long
Private Declare PtrSafe Function GetUserName Lib “advapi32.dll” Alias “GetUserNameA” (ByVal lpBuffer As String, ByRef nSize As Long) As Long
Else
Private Declare Sub GetLocalTime Lib “kernel32” (ByRef lpSystemTime As SYSTEMTIME)
Private Declare Function GetCurrentProcessId Lib “kernel32” () As Long
Private Declare Function GetUserName Lib “advapi32.dll” Alias “GetUserNameA” (ByVal lpBuffer As String, ByRef nSize As Long) As Long
End If
‘ — Win32 構造体定義 —
Private Type SYSTEMTIME
wYear As Integer
wMonth As Integer
wDayOfWeek As Integer
wDay As Integer
wHour As Integer
wMinute As Integer
wSecond As Integer
wMilliseconds As Integer
End Type
‘ — プライベートメンバ変数 —
Private m_LogFilePath As String
Private m_CallStack As Collection
Private m_FSO As Object
‘ ==============================================================================
‘ 初期化・終了処理
‘ ==============================================================================
Private Sub Class_Initialize()
Set m_CallStack = New Collection
‘ FileSystemObjectをレイトバインディングで生成 (環境依存の排除)
Set m_FSO = CreateObject(“Scripting.FileSystemObject”)
‘ デフォルトのログ出力パスを文書と同一パス、または一時フォルダに設定
On Error Resume Next
If Len(ActiveDocument.Path) > 0 Then
m_LogFilePath = ActiveDocument.Path & “\vba_execution.log”
Else
m_LogFilePath = CreateObject(“WScript.Shell”).SpecialFolders(“Desktop”) & “\vba_execution.log”
End If
On Error GoTo 0
End Sub
Private Sub Class_Terminate()
Set m_CallStack = Nothing
Set m_FSO = Nothing
End Sub
‘ ==============================================================================
‘ プロパティ
‘ ==============================================================================
Public Property Get LogFilePath() As String
LogFilePath = m_LogFilePath
End Property
Public Property Let LogFilePath(ByVal NewPath As String)
m_LogFilePath = NewPath
End Property
‘ ==============================================================================
‘ コールスタック管理メソッド
‘ ==============================================================================
Public Sub PushStack(ByVal ProcedureName As String)
m_CallStack.Add ProcedureName
End Sub
Public Sub PopStack()
If m_CallStack.Count > 0 Then
m_CallStack.Remove m_CallStack.Count
End If
End Sub
Private Function GetCallStackString() As String
Dim i As Long
Dim stackTrace As String
If m_CallStack.Count = 0 Then
GetCallStackString = “Empty Stack”
Exit Function
End If
For i = m_CallStack.Count To 1 Step -1
stackTrace = stackTrace & m_CallStack.Item(i)
If i > 1 Then stackTrace = stackTrace & ” -> ”
Next i
GetCallStackString = stackTrace
End Function
‘ ==============================================================================
‘ システム情報取得ヘルパー
‘ ==============================================================================
Private Function GetFormattedTimestamp() As String
Dim tSystem As SYSTEMTIME
Call GetLocalTime(tSystem)
GetFormattedTimestamp = Format(tSystem.wYear, “0000”) & “/” & _
Format(tSystem.wMonth, “00”) & “/” & _
Format(tSystem.wDay, “00”) & ” ” & _
Format(tSystem.wHour, “00”) & “:” & _
Format(tSystem.wMinute, “00”) & “:” & _
Format(tSystem.wSecond, “00”) & “.” & _
Format(tSystem.wMilliseconds, “000”)
End Function
Private Function GetWindowsUser() As String
Dim buffer As String 255
Dim length As Long
length = 255
If GetUserName(buffer, length) <> 0 Then
GetWindowsUser = Left(buffer, InStr(buffer, vbNullChar) – 1)
Else
GetWindowsUser = “UNKNOWN”
End If
End Function
‘ ==============================================================================
‘ コア・ロギングメソッド
‘ ==============================================================================
Public Sub Log(ByVal LogLevel As String, ByVal Message As String, Optional ByVal ErrNumber As Long = 0, Optional ByVal ErrSource As String = “”)
Dim logLine As String
Dim ts As Object
Dim retryCount As Long
Const ForAppending = 8
Const UnicodeTrue = -1 ‘ UTF-16LEで書き出すことで多言語文字化けを防止
‘ ログフォーマットの構築
‘ [タイムスタンプ] [プロセスID] [ユーザー] [ログレベル] [スタックトレース] メッセージ [エラー番号 / ソース]
logLine = “[” & GetFormattedTimestamp() & “] ” & _
“[PID:” & Format(GetCurrentProcessId(), “000000”) & “] ” & _
“[” & GetWindowsUser() & “] ” & _
“[” & UCase(LogLevel) & “] ” & _
“[” & GetCallStackString() & “] ” & _
Message
If ErrNumber <> 0 Then
logLine = logLine & ” (ErrNo: ” & ErrNumber & ” / Src: ” & ErrSource & “)”
End If
‘ 排他制御のためのリトライループ (ファイルロック対策)
On Error Resume Next
Do
Err.Clear
‘ FSOによるストリーム書き出し。Unicode(UTF-16)を明示して文字化けを完全排除
Set ts = m_FSO.OpenTextFile(m_LogFilePath, ForAppending, True, UnicodeTrue)
If Err.Number = 0 Then
ts.WriteLine logLine
ts.Close
Set ts = Nothing
Exit Do
End If
‘ ロック競合時はミリ秒単位で待機してリトライ (最大5回)
retryCount = retryCount + 1
If retryCount > 5 Then
‘ ログ書き出し自体の失敗は、デバッグウィンドウに出力する最終防衛ライン
Debug.Print “CRITICAL: Logger failed to write. Target: ” & m_LogFilePath & ” Err: ” & Err.Description
Exit Do
End If
DoEvents ‘ OSに制御を戻す
SleepVBA 50 ‘ 簡易ウェイト
Loop
On Error GoTo 0
End Sub
‘ 簡易ウェイト関数
Private Sub SleepVBA(ByVal Milliseconds As Long)
Dim endTime As Double
endTime = Timer + (Milliseconds / 1000)
Do While Timer < endTime
DoEvents
Loop
End Sub
---
4. `Try-Catch-Finally` パターンによる実務マクロへの組み込み
構築した `ClsLogger` を、Word VBAの標準モジュールから呼び出す。
VBAには構造化例外処理がないため、`On Error GoTo` を用いて、モダン言語の `Try-Catch-Finally` 構造を厳格に再現する。
以下のコードは、新規ドキュメントを生成し、段落(Paragraph)にテキストを書き込み、PDFとして保存する一連の処理である。各フェーズで適切にスタックを積み、例外発生時には確実にリソースを解放する。
Option Explicit
‘ グローバルまたはモジュールレベルでのロガーインスタンス保持
Private LogObj As ClsLogger
‘ ==============================================================================
‘ エントリポイント:ドキュメント自動生成バッチ処理
‘ ==============================================================================
Public Sub ExecuteDocumentGenerationPipeline()
‘ ロガーの初期化
Set LogObj = New ClsLogger
LogObj.LogFilePath = ThisDocument.Path & “\system_execution.log”
LogObj.PushStack “ExecuteDocumentGenerationPipeline”
LogObj.Log “INFO”, “=== Wordドキュメント生成パイプライン 開始 ===”
‘ Wordアプリケーションの挙動最適化
On Error GoTo Catch
Application.ScreenUpdating = False
‘ 実際のビジネスロジックの呼び出し
Call CreateReportDocument
LogObj.Log “INFO”, “=== パイプライン 正常終了 ===”
Finally:
‘ Finallyブロック: エラーの有無にかかわらず必ず実行されるクリーンアップ
LogObj.Log “INFO”, “システムリソースの復元処理を実行中…”
Application.ScreenUpdating = True
LogObj.PopStack
‘ ロガーオブジェクトのライフサイクル終了
Set LogObj = Nothing
Exit Sub
Catch:
‘ Catchブロック: エラー情報の捕捉とロギング
LogObj.Log “FATAL”, “パイプライン実行中に致命的なエラーを検知: ” & Err.Description, Err.Number, Err.Source
Resume Finally
End Sub
‘ ==============================================================================
‘ ビジネスロジック:レポート生成処理
‘ ==============================================================================
Private Sub CreateReportDocument()
Dim doc As Word.Document
Dim rng As Word.Range
Dim pdfPath As String
LogObj.PushStack “CreateReportDocument”
LogObj.Log “INFO”, “新規ドキュメントの初期化処理を開始します。”
‘ 1. 新規ドキュメントの作成
On Error GoTo Err_DocCreation
Set doc = Documents.Add
LogObj.Log “DEBUG”, “新規Documentオブジェクトを生成しました。”
On Error GoTo Catch
‘ 2. テキストの書き込みとフォーマッティング
LogObj.Log “INFO”, “ドキュメントへのデータ書き込みを開始します。”
Set rng = doc.Content
rng.Text = “エンタープライズ・システム自動生成レポート” & vbCrLf
rng.Paragraphs(1).Style = doc.Styles(wdStyleTitle)
‘ 故意にエラーを発生させたい場合は以下のコメントアウトを解除
‘ Err.Raise 1001, “CreateReportDocument”, “データベース接続タイムアウトをシミュレート”
‘ 3. PDFエクスポート処理
pdfPath = ThisDocument.Path & “\GeneratedReport.pdf”
LogObj.Log “INFO”, “PDFへのエクスポート処理を実行します。出力先: ” & pdfPath
On Error GoTo Err_Export
doc.ExportAsFixedFormat _
OutputFileName:=pdfPath, _
ExportFormat:=wdExportFormatPDF, _
OpenAfterExport:=False, _
OptimizeFor:=wdExportOptimizeForPrint, _
Range:=wdExportAllDocument
On Error GoTo Catch
LogObj.Log “INFO”, “PDFエクスポートが正常に完了しました。”
Finally:
‘ 参照したCOMオブジェクトの明示的解放(Wordゴーストプロセス化防止の最重要処理)
If Not rng Is Nothing Then Set rng = Nothing
If Not doc Is Nothing Then
LogObj.Log “DEBUG”, “Documentオブジェクトを閉じます。”
doc.Close SaveChanges:=wdDoNotSaveChanges
Set doc = Nothing
End If
LogObj.PopStack
Exit Sub
Catch:
‘ 上位のCatchへ例外をバブリング(再スロー)
Err.Raise Err.Number, Err.Source, Err.Description
‘ — 各フェーズの局所的エラーハンドラ —
Err_DocCreation:
LogObj.Log “ERROR”, “Documentオブジェクトの生成に失敗しました。テンプレートの存在を確認してください。”, Err.Number, Err.Source
Resume Finally
Err_Export:
LogObj.Log “ERROR”, “PDFの書き出しに失敗しました。ファイルが他プロセスにロックされている可能性があります。”, Err.Number, Err.Source
Resume Finally
End Sub
—
5. COMの闇:Wordゴーストプロセスを完全に抹殺する解放の儀式
Word VBAにおける最大のトラップ、それはマクロ終了後もタスクマネージャーに `WINWORD.EXE` が残り続ける「ゴーストプロセス問題」である。これは、VBAがCOMオブジェクト(特に `Document` や `Range`)の参照カウント(Reference Count)を正しくゼロに落とせていないことが原因である。
なぜゴーストプロセスが発生するのか?
VBAのランタイムは、変数への代入が解除されるか、プロシージャを抜ける際に自動的に参照カウントをデクリメントする。しかし、以下の状況下ではデクリメントが正常に行われない。
1. 暗黙の参照(Implicit Reference)の発生
`ActiveDocument.Content.Paragraphs(1)` のような、ドットを重ねた省略表記を行うと、VBAは内部で隠れた一時変数を生成し、その参照を解放し忘れることがある。
2. エラーによるプロシージャの中断
`On Error GoTo` による適切なジャンプを行わず、エラー発生時にそのまま `End` ステートメントが実行された場合、メモリ上のCOMオブジェクトは完全に放置される。
解決策:厳格なクリーンアップ・シーケンス
上記コードに実装されている通り、以下の「黄金律」を守る必要がある。
- ルール 1: すべてのCOMオブジェクト変数(`Document`、`Range`、`Selection`、`Table`等)は、利用が終わる、あるいはエラー処理に入る瞬間に必ず明示的に `Set Variable = Nothing` を実行する。
- ルール 2: 解放の順序は「末端のオブジェクトから親オブジェクトへ」の順で行う。上記例では `rng` (Range) を解放した後に、`doc` (Document) を解放している。
- ルール 3: `End` ステートメントは絶対に使用しない。プログラムを強制終了させる `End` は、COMオブジェクトのデストラクターを呼び出さずにプロセスを破棄するため、リソースリークの最大の温床となる。
—
6. 出力されるログファイルの解析
本アーキテクチャが稼働すると、指定されたパスに以下のような極めて高精度なログが出力される。
[2023/10/25 14:35:10.124] [PID:012452] [shimizu.t] [INFO] [ExecuteDocumentGenerationPipeline] === Wordドキュメント生成パイプライン 開始 ===
[2023/10/25 14:35:10.185] [PID:012452] [shimizu.t] [INFO] [ExecuteDocumentGenerationPipeline -> CreateReportDocument] 新規ドキュメントの初期化処理を開始します。
[2023/10/25 14:35:10.250] [PID:012452] [shimizu.t] [DEBUG] [ExecuteDocumentGenerationPipeline -> CreateReportDocument] 新規Documentオブジェクトを生成しました。
[2023/10/25 14:35:10.255] [PID:012452] [shimizu.t] [INFO] [ExecuteDocumentGenerationPipeline -> CreateReportDocument] ドキュメントへのデータ書き込みを開始します。
[2023/10/25 14:35:11.512] [PID:012452] [shimizu.t] [ERROR] [ExecuteDocumentGenerationPipeline -> CreateReportDocument] PDFの書き出しに失敗しました。ファイルが他プロセスにロックされている可能性があります。 (ErrNo: 4198 / Src: Microsoft Word)
[2023/10/25 14:35:11.530] [PID:012452] [shimizu.t] [DEBUG] [ExecuteDocumentGenerationPipeline -> CreateReportDocument] Documentオブジェクトを閉じます。
[2023/10/25 14:35:11.601] [PID:012452] [shimizu.t] [FATAL] [ExecuteDocumentGenerationPipeline] パイプライン実行中に致命的なエラーを検知: コマンドは失敗しました。 (ErrNo: 4198 / Src: Microsoft Word)
[2023/10/25 14:35:11.605] [PID:012452] [shimizu.t] [INFO] [ExecuteDocumentGenerationPipeline] システムリソースの復元処理を実行中…
このログを見れば、以下の事実が一瞬で判明する。
- いつ: 14時35px分11秒512ミリ秒
- 誰が / どの環境で: プロセスID `012452`、実行ユーザー `shimizu.t`
- どこで: `ExecuteDocumentGenerationPipeline` から呼ばれた `CreateReportDocument` の中
- 何が原因で: Wordエラー `4198` (ExportAsFixedFormatの失敗。書き出し先PDFファイルが他のユーザーやプロセスによってロックされていたため)
—
7. まとめ:職人芸から近代的エンジニアリングへの昇華
Word VBAをレガシーな「マクロ」として放置するか、近代的な「システム」として昇華させるか。その境界線は、「不測の事態に対するトレーサビリティの有無」にある。
今回紹介したロギングアーキテクチャは、FSOの簡便さとWin32 APIの強力なシステム情報取得能力を組み合わせ、WordのCOMライフサイクルに配慮した、極めて実戦的なソリューションである。
このロギング機構を既存の資産に組み込むことで、ユーザーからの「動かなくなった」という曖昧なクレームに対し、エンジニアは「〇〇番のログファイルを確認したところ、他プロセスによるファイルロックが〇時〇分〇秒に発生しています」と、ファクトに基づいた即答が可能になる。
技術の深淵を理解し、泥臭いレガシー環境をも完全にコントロール下に置くこと。それこそが、チーフアーキテクトたる我々の使命である。
