Visioにおける監査ログの極意:ClassモジュールとWindows APIを用いたページ操作の完全追跡
エンタープライズ環境において、Visioは単なる「作図ツール」ではない。それはシステムの構成定義、ネットワークトポロジ、あるいは複雑な業務フローを記述する「生きた設計図」である。
設計図の整合性が失われる瞬間、それはページが削除されたとき、あるいは意図しない変更が加えられたときだ。しかし、多くの開発者は「誰がいつ何をしたか」というトレーサビリティ(追跡可能性)の確保を軽視している。標準的なVBAの機能だけでは、ユーザーの意図的な操作を捕捉し、永続的なログとして残すことは不可能に近い。
本稿では、Classモジュールによるイベントハンドリング、Windows APIを用いた厳格なユーザー識別、そしてメモリ管理を最適化した「監査ログシステム」の構築手法を解説する。これは単なるコードの提示ではない。システムの整合性を守るための、アーキテクチャ設計の記録である。
1. アーキテクチャの設計思想
一般的な「ログ出力」は、特定のプロシージャが実行された際に書き込むものだ。しかし、監査ログの本質は「イベントへの介入」にある。ユーザーがVisioのUI(リボンやメニュー)から直接ページを追加・削除した場合、通常のコードベースのログは機能しない。
我々が実装すべきは、アプリケーションのライフサイクルに深く食い込み、`Document`オブジェクトのイベントを監視する「イベントリスナー・パターン」である。
コンポーネント構成
1. `clsAuditor` (Class Module): `Document`オブジェクトを `WithEvents` で保持し、ページ操作イベントをインターセプトする。
2. `modAPI` (Standard Module): `advapi32.dll` を呼び出し、環境変数に依存しない厳格なユーザー名取得を行う。
3. `ThisDocument`: アプリケーション起動時にリスナーをインスタンス化し、オブジェクトの生存期間を管理する。
2. 実装:厳格なユーザー識別とAPIの呼び出し
`Environ(“Username”)` を使うのは素人の仕事だ。環境変数はユーザーによって容易に偽装できる。真の監査ログには、OSレベルのトークンからユーザー名を特定するWindows APIが必要となる。
' --- modAPI: Windows API定義モジュール ---
Option Explicit
If VBA7 Then
' 64bit環境用宣言
Public Declare PtrSafe Function GetUserNameW Lib "advapi32.dll" (ByVal lpBuffer As LongPtr, ByRef nSize As Long) As Long
Else
' 32bit環境用宣言
Public Declare Function GetUserNameW Lib "advapi32.dll" (ByVal lpBuffer As Long, ByRef nSize As Long) As Long
End If
''' <summary>
''' Windows APIを用いて、セッション中の真正なユーザー名を取得する
''' </summary>
Public Function GetRealUserName() As String
Dim buffer As String * 255
Dim size As Long
size = 256
' API呼び出し
If GetUserNameWStr(buffer, size) <> 0 Then
' Unicode文字列をVBA文字列に変換(Null終端まで)
GetRealUserName = Left$(buffer, InStr(buffer, Chr$(0)) - 1)
Else
GetRealUserName = "Unknown_User"
End If
End Function
' 内部的なUnicode処理用ヘルパー
Private Function GetUserNameWStr(ByVal buffer As String, ByRef size As Long) As Long
' 実装の簡略化のため、ここでは概念を示しているが、
' 本来はStrPtrを用いた高度なメモリ操作が望ましい
GetUserNameW(StrPtr(buffer), size)
GetUserNameWStr = 0 ' Success
End Function
3. 実装:Classモジュールによるイベント・フック
ここが心臓部だ。`Document` オブジェクトの `PageAdded` および `PageDeleted` イベントを捕捉する。
' --- clsAuditor: イベントリスナー・クラスモジュール ---
Option Explicit
' Documentオブジェクトへの参照をイベント付きで保持
Private WithEvents TargetDoc As Visio.Document
Private LogFilePath As String
''' <summary>
''' コンストラクタ:リスナーの初期化
''' </summary>
''' <param name="doc">監視対象のDocumentオブジェクト</param>
''' <param name="logPath">ログの出力先パス</param>
Public Sub Initialize(ByVal doc As Visio.Document, ByVal logPath As String)
Set TargetDoc = doc
LogFilePath = logPath
End Sub
''' <summary>
''' ページが追加された際のイベントハンドラ
''' </summary>
Private Sub TargetDoc_PageAdded(ByVal Page As Visio.Page)
WriteLog "PAGE_ADD", "Page Name: " & Page.Name
End Sub
''' <summary>
''' ページが削除された際のイベントハンドラ
''' </summary>
Private Sub TargetDoc_PageDeleted(ByVal Page As Visio.Page)
' 注意: 削除イベント時、Pageオブジェクトの状態は不安定な場合がある
' 可能な限り、削除される直前の情報を保持する設計が望ましい
WriteLog "PAGE_DELETE", "Page Name: " & Page.Name
End Sub
''' <summary>
''' ログの書き込みプロセス
''' </summary>
Private Sub WriteLog(ByVal action As String, ByVal details As String)
On Error GoTo ErrHandler
Dim fso As Object
Dim ts As Object
Dim userName As String
Dim timestamp As String
' ユーザー情報の取得 (API経由)
userName = GetRealUserName()
timestamp = Format(Now, "yyyy-mm-dd HH:mm:ss")
' FileSystemObjectの利用(パフォーマンスと堅牢性のバランス)
Set fso = CreateObject("Scripting.FileSystemObject")
Set ts = fso.OpenTextFile(LogFilePath, 8, True) ' 8 = ForAppending
' ログフォーマット: [Timestamp] [User] [Action] [Details]
ts.WriteLine StringFormat("[%s] [%s] [%s] %s", timestamp, userName, action, details)
' オブジェクトの明示的な解放(メモリリーク防止)
ts.Close
Set ts = Nothing
Set fso = Nothing
Exit Sub
ErrHandler:
' 監査ログの失敗がメインプロセスを停止させてはならない
' ただし、エラー自体をイベントビューア等に投げるべきである
Debug.Print "CRITICAL ERROR: Audit Log Failed - " & Err.Description
If Not ts Is Nothing Then ts.Close
End Sub
' 文字列フォーマット用ヘルパー(簡易版)
Private Function StringFormat(ByVal fmt As String, ParamArray args() As Variant) As String
Dim i As Long
Dim res As String
res = fmt
For i = LBound(args) To UBound(args)
res = Replace(res, StringFormat("[%s]", i), CStr(args(i))) ' 本来はより厳密な置換が必要
' 簡易実装として、単純な置換ロジックを想定
Next i
' 本来は sprintf のような実装を自作する
StringFormat = res
End Function
*(※注: `StringFormat` は実装の簡略化のため概念を示しています。実運用では `Replace` を用いた再帰的な置換ロジックを実装してください)*
4. インスタンスのライフサイクル管理
Classモジュールは、そのインスタンスが破棄されるとイベントの監視も停止する。`ThisDocument` モジュールにおいて、グローバル変数としてインスタンスを保持し、ドキュメントの生存期間と同期させる必要がある。
' --- ThisDocument: ドキュメント起動時の制御 ---
Option Explicit
' インスタンスをモジュールレベルで保持することで、メモリ上に維持する
Private Auditor As clsAuditor
Private Sub Document_Open()
Dim logPath As String
logPath = ThisDocument.Path & "\audit_log.txt"
' リスナーの初期化
Set Auditor = New clsAuditor
Auditor.Initialize ThisDocument, logPath
Debug.Print "Audit System Initialized. Logging to: " & logPath
End Sub
Private Sub Document_Close()
' 明示的なクリーンアップ
If Not Auditor Is Nothing Then
Set Auditor = Nothing
End If
End Sub
5. 極限の知見:パフォーマンスと堅牢性への考察
シニアエンジニアとして、この実装をさらに高みへ引き上げるための知見を共有しておく。
オブジェクトのライフサイクルとメモリ管理
VBAにおける `Set obj = Nothing` は、単なる作法ではない。特に `WithEvents` を使用する場合、循環参照(Circular Reference)が発生すると、オブジェクトはメモリ上に残り続け、Visioのプロセス自体が終了できなくなる。`Document_Close` で確実に `Nothing` を代入することは、システム全体の安定性に直結する。
I/Oのボトルネック
上記のコードでは `FileSystemObject` を使用している。頻繁なページ操作が行われる環境では、ファイルを開閉するコストが無視できなくなる。超高頻度の操作を想定する場合、ログをメモリ上の `Collection` にバッファリングし、一定時間ごと、あるいは `Document_BeforeClose` 時に一括書き出しを行う「バッチ・ライティング」への転換を検討せよ。
セキュリティの限界
このシステムは「誰が何をしたか」を記録するが、「ログを消去する行為」を止めることはできない。真に堅牢な監査システムを構築するならば、ログの出力先をローカルファイルではなく、書き込み権限を限定したネットワーク上の共有ディレクトリ、あるいは外部のログ管理サーバー(Syslog等)のAPIへ直接POSTする設計にすべきだ。
結論
Visio VBAを用いた監査ログシステムの実装は、単なるプログラミングの範疇を超え、システムの「信頼性」を定義するアーキテクチャ設計そのものである。Classモジュールによるイベントの抽象化、Windows APIによるアイデンティティの確立、そして厳格なメモリ管理。これらを組み合わせることで、初めて「プロフェッショナルな業務ツール」としての地位を確立できるのである。
