【テクニカル・上級編】【上級者向け】COMアドインを利用した「保存時強制クリーンアップ」機能の配布 – Project VBA解析バイブル

スポンサーリンク

【上級者向け】COMアドインを利用した「保存時強制クリーンアップ」機能の配布

Project VBAの現場において、ファイルサイズの肥大化、不要なオブジェクト参照の残存、そしてそれに伴う予期せぬクラッシュは、常にエンジニアを悩ませる悪夢だ。特にマルチユーザー環境において、各々が我が物顔でマクロを組み、ゴミを残したまま上書き保存を繰り返すプロジェクトファイルは、やがて腐敗し、システム全体の信頼性を地底へと引きずり落とす。

標準的な `ThisProject_BeforeSave` イベントにクリーンアップ処理を記述している現場を見かけるが、それは甘い。ユーザーがマクロを無効化して開いた場合や、強制終了からの復旧時、あるいはコードの書き換えによってイベントハンドラがデタッチされた瞬間、その防壁は容易に崩壊する。

真に堅牢なエンタープライズ環境を構築したいのであれば、VBAのレイヤーで戦うことを諦めよ。我々はCOMアドイン(IDTExtensibility2)を用い、Projectのプロセスそのものをフックする。全ユーザーの保存操作を不可避的にインターセプトし、裏側で極限のメモリ最適化とゴミ掃除を強制執行するアーキテクチャの解説だ。

1. なぜVBAではなくCOMアドインなのか?

VBAのイベントドリブンモデルは脆弱だ。`Application.Global` や `ThisProject` のイベントは、実行時エラーやユーザーの操作ミス(「マクロを無効にする」の選択)によって容易にバイパスされる。

一方、COMアドインは、Microsoft Projectの起動時に宿主プロセスへロードされるDLL(またはマニフェストベースの`.NET`アセンブリ)である。Projectのライフサイクル全体を監視し、VBAエンジンが初期化される前、あるいはファイルI/Oの根幹に直接介入することができる。

今回は、開発効率と堅牢性のバランスから、VB.NET(.NET Framework 4.8)を用いたCOMアドインの実装アプローチをとる。これを全クライアントPCにサイレント배포(部署ごとのレジストリ登録)することで、開発者のモラルに依存しない「強制クリーンアップ・パイプライン」が完成する。

2. アーキテクチャの全体像

1. イベントキャプチャ: COMアドイン側で `Microsoft.Office.Interop.MSProject` のアプリケーションイベント(`ProjectBeforeSave`)を捕捉。
2. メモリ・オブジェクト最適化: 保存処理がディスクに書き込まれる直前のミリ秒単位の隙間を突き、Project内部の不要なタスク、リソース、カスタムフィールドのゴミデータをプログラム的にパージ。
3. COMガベージコレクションの強制: .NETからCOMオブジェクトを操作した際に発生するRCW(Runtime Callable Wrapper)の参照カウンタを適正化し、メモリリークを根絶。

3. 実装コード:COMアドイン本体(VB.NET)

以下のコードは、COMアドインの核心部分である。`IDTExtensibility2` インターフェイスを実装し、Projectのアプリケーションインスタンスをフック、保存イベントをインターセプトする。

Imports Extensibility
Imports Microsoft.Office.Core
Imports MSProject = Microsoft.Office.Interop.MSProject
Imports System.Runtime.InteropServices

_
Public Class Connect
Implements IDTExtensibility2

Private WithEvents m_app As MSProject.Application

‘ アドイン読み込み時の初期化
Public Sub OnConnection(application As Object, connectMode As ext_ConnectMode, addInInst As Object, ByRef custom As Array) Implements IDTExtensibility2.OnConnection
Try
m_app = CType(application, MSProject.Application)
Catch ex As Exception
System.Windows.Forms.MessageBox.Show(“COMアドインの初期化に失敗しました: ” & ex.Message)
End Try
End Sub

Public Sub OnDisconnection(disconnectMode As ext_DisconnectMode, ByRef custom As Array) Implements IDTExtensibility2.OnDisconnection
m_app = Nothing
GC.Collect()
GC.WaitForPendingFinalizers()
End Sub

Public Sub OnAddInsUpdate(ByRef custom As Array) Implements IDTExtensibility2.OnAddInsUpdate End Sub
Public Sub OnStartupComplete(ByRef custom As Array) Implements IDTExtensibility2.OnStartupComplete End Sub
Public Sub OnBeginShutdown(ByRef custom As Array) Implements IDTExtensibility2.OnBeginShutdown End Sub

‘ =========================================================================
‘ 保存直前イベントのインターセプト
‘ =========================================================================
Private Sub m_app_ProjectBeforeSave(ByVal pj As MSProject.Project, ByRef Cancel As Boolean) Handles m_app.ProjectBeforeSave
If pj Is Nothing Then Exit Sub

Try
‘ 1. 未使用のローカルリソースおよび割り当てのパージ
ExecuteDeepCleanup(pj)

‘ 2. プロジェクトデータベースの断片化を抑制するための最適化フラグ操作
‘ (※必要に応じて内部プロパティを調整)

Catch ex As Exception
‘ ログ出力基盤へフックすることを推奨。ここでは簡易的に中断を回避
System.Diagnostics.Debug.WriteLine(“Cleanup Error: ” & ex.Message)
Finally
‘ RCWの即時解放(メモリ最適化の極意)
Marshal.ReleaseComObject(pj)
End Try
End Sub

‘ =========================================================================
‘ 極限のクリーンアップ・ロジック
‘ =========================================================================
Private Sub ExecuteDeepCleanup(ByVal pj As MSProject.Project)
‘ 例:孤立したタスク(名前が空、かつマイルストーンでもないゴミデータ)の物理削除
Dim t As MSProject.Task
For i As Integer = pj.Tasks.Count To 1 Step -1
t = pj.Tasks(i)
If t IsNot Nothing Then
If String.IsNullOrEmpty(t.Name) AndAlso t.Summary = False Then
t.Delete()
End If
Marshal.ReleaseComObject(t)
End If
Next

‘ 不要なUndoスタックのクリア(ファイルサイズ爆発の主原因)
‘ ※Projectオブジェクトモデルの制限を考慮し、GCと組み合わせて強制解放
GC.Collect()
GC.WaitForPendingFinalizers()
End Sub

End Class

4. シニアエンジニアが知るべき「メモリ最適化」の罠

上記のコードで特筆すべきは、`Marshal.ReleaseComObject(pj)` およびループ内での COM オブジェクトの明示的な解放だ。

.NETからCOM(Microsoft Project)を操作する場合、裏側では RCW(Runtime Callable Wrapper) が生成される。VB.NET側でいくら `GCHandle` を操作しようとも、COM側の参照カウントがゼロにならなければ、プロセスはメモリ上に残り続け、やがて `OutOfMemoryException` またはProject自体のサイレントクラッシュを引き起こす。

特に `ProjectBeforeSave` のような高頻度かつクリティカルなイベント内では、以下の鉄則を厳守せよ。

  • ループ変数の即時解放: コレクション(`pj.Tasks`など)を逆順で走査し、取得した個別オブジェクト(`Task`など)はループのスコープごとに必ず `Marshal.ReleaseComObject()` を叩くこと。
  • ガベージコレクションの強制同期: COMオブジェクトの解放後、即座に `GC.Collect()` を呼ぶだけでは不十分だ。マネージヒープとアンマネージヒープの同期を取るために、必ず `GC.WaitForPendingFinalizers()` をセットで記述しろ。

5. 配布と展開(Deployment)の要件

このCOMアドインを全社展開するためには、単なるファイルのコピーでは不十分である。レジストリハイブ(`HKEY_CURRENT_USER\Software\Microsoft\Office\Project\Addins\`)への登録が必要となる。

エンタープライズ環境においては、以下の手順でグループポリシー(GPO)またはSCCM / Intuneを活用したサイレントインストールを構築する。

1. 強固な署名 (Strong Name): アセンブリには必ず厳密名キー(`.snk`)を付与し、GAC(グローバルアセンブリキャッシュ)または信頼されたディレクトリに配置する。
2. レジストリ登録スクリプト (.reg / PowerShell):

$path = “HKCU:\Software\Microsoft\Office\Project\Addins\ProjectCleanAddIn.Connect”
If (!(Test-Path $path)) { New-Item -Path $path -Force }
Set-ItemProperty -Path $path -Name “LoadBehavior” -Value 3 -Type DWord
Set-ItemProperty -Path $path -Name “FriendlyName” -Value “Enterprise Project Clean-Up Add-In”
Set-ItemProperty -Path $path -Name “Description” -Value “強制保存時クリーンアップ・COMアドイン”

(※ `LoadBehavior` の `3` は「起動時に読み込む」を意味する)

総括

VBAのコード内に閉じこもっているうちは、真のシステム安定化は訪れない。ユーザーの「保存」という不可逆なアクションに対し、COMアドインという黒衣を纏わせることで初めて、システムは揺動しない強靭さを手に入れる。

レガシーなProject運用に絶望しているアーキテクトよ。今こそVBAの限界を超越し、プロセスを支配せよ。

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