VBA

Daclare文のDLL検索フォルダを指定する

DLLの配布フォルダとパスの問題

VBAで外部のAPI関数を呼び出すとき、Declare文 をモジュールシートの先頭に

Private Declare PtrSafe Function foldl1 Lib "mapM.dll" (ByRef ...

のように書いて関数とDLLを指定します。上記ではDLL名を"mapM.dll"としか書いていませんが、デフォルトでパスが通っているWindows APIと違って、 VBAHaskell などの自作プログラムの場合はDLLの置き場所とパス指定をどう書けばいいかが悩みのタネでした:frowning2:

考慮している前提条件はこんな感じです。

  • VBAモジュールはいろいろな環境に配布してライブラリとして使ってもらう
  • 配布するモジュールのDeclare文に特定のパスをハードコーディングしたくない(DLL名だけにしたい)
  • 複数人でネットワークフォルダを共有できる環境では、DLLは個々のPCに配布せず共有フォルダに置きたい
  • 開発・デバッグ時はリリースDLLとデバッグDLLを使い分けたい
  • DLLを再ビルドしたとき、VBA側のプロセスを落とさずにDLLファイルを入れ替えたい
  • 必要に応じてフォルダを動的に指定したい

とりあえず考えられる選択肢は以下のようなものだと思います。

1. デフォルトでパスが通っている場所に置く

C:\WINDOWS、C:\WINDOWS\system32、C:\Program Files\Microsoft Office\Office14\ などです。

  • 長所
    • Declere文にパスをハードコーディングする必要がない
  • 短所
    • 個々のPCにDLLを配布する必要がある
    • 配置するフォルダが限定されるうえ、ユーザーはそこにファイルを置く権限を持っていないかもしれない。

2. 適当な共有フォルダに置いて、そこにパスを通す

  • 長所
    • Declere文にパスをハードコーディングする必要がない
    • 個々のPCにDLLを配布する必要がない
  • 短所
    • 個々のPCにパス設定が必要なうえ、ユーザーはその権限を持っていないかもしれない

3.Declare文にパスを追記する

Declare文を Lib "\\NetworkDirve\NetworkDirectory\mapFdll" などと環境ごとに書き換えます。

  • 長所
    • 個々のPCにパス設定をしなくていい
  • 短所
    • Declere文がたくさんあると面倒
    • DLLを置くフォルダを変更するときはパスの書き換えが(多数)発生
    • VBAプログラムを別の環境に持って行ったときは、パスの書き換えが(多数)発生

4.LoadLibraryする

Excelだったら ThisWorkbook モジュールに以下のマクロを記述します。

Private Declare PtrSafe Function LoadLibrary Lib "kernel32.dll" _
            Alias "LoadLibraryA" (ByVal fileName As String) As Long

Private Sub Workbook_Open()
    Call LoadLibrary("\\NetworkDirve\NetworkDirectory1\mapM.dll")
    Call LoadLibrary("\\NetworkDirve\NetworkDirectory2\***")
End Sub
  • 長所
    • Declere文にパスをハードコーディングする必要がない
    • 個々のPCにDLLを配布する必要がない
    • パスは環境に合わせて動的に設定可能
  • 短所
    • DLLファイル単位でLoadLibraryを呼ぶ必要がある。
    • DLLをリビルドしたあと入れ替えたくてもVBA側で End コマンドを打つだけではできず、FreeLibrary API を呼んで掴んだDLLを解放しないとダメ。FreeLibraryの引数にはDLLモジュールのハンドルが要求されるので、その値をどこかに保持しておかなければならないし、LoadLibraryしたのと同じ回数呼ぶ必要がある。もしくはExcelやWordなどのプロセスを落とす必要がある

また、同じDLLを使うブックそれぞれがLoadLibraryをしていたら、それらを複数立ち上げたとき2度目以降のLoadLibraryは無駄な気がします。

5.AddDllDirectorySetDefaultDllDirectories を使う

結論から書くと、Window APIの AddDllDirectorySetDefaultDllDirectories を使う方法が自分の目的に合うことがわかりました。「このフォルダもDLL検索パスに追加してよ」という機能です。似たものとしてSetDllDirectoryというAPIもあり、Windowsデベロッパーセンターで見るとこの3つはこういう関係のようです。

API 機能 備考
AddDllDirectory プロセスごとのDLLの
検索パスを追加する
呼ぶたびにフォルダの指定を追加できる
SetDllDirectory プロセスごとのDLLの
検索パスを設定する
呼ぶたびに前回の指定はリセットされる
SetDefaultDllDirectories デフォルトのDLL検索
パスの種類を設定する
AddDllDirectoryの効果を反映させる引数を与える

SetDefaultDllDirectories は「ユーザーが指定したフォルダを考慮する/しない」のスイッチ機能のようです。

AddDllDirectory function
 DLL_DIRECTORY_COOKIE  WINAPI AddDllDirectory(
  _In_ PCWSTR NewDirectory               // ← フォルダの指定
);

SetDefaultDllDirectories function
 BOOL  WINAPI SetDefaultDllDirectories(
  _In_ DWORD DirectoryFlags               // ← LOAD_LIBRARY_SEARCH_DEFAULT_DIRS
);

複数のフォルダを検索パスに指定することがありえるので、SetDllDirectoryではなくAddDllDirectoryを使います。
また、SetDefaultDllDirectories の引数には、デフォルトのパスとユーザー指定のパスが両方考慮される LOAD_LIBRARY_SEARCH_DEFAULT_DIRS(0x00001000) を選びます。(WindowsデベロッパーセンターのSetDefaultDllDirectories function 説明 より)

  • 長所
    • Declere文にパスをハードコーディングする必要がない
    • 個々のPCにDLLを配布する必要がない
    • 個々のPCにパス設定をする必要がない
    • パスは環境に合わせて動的に設定可能
    • DLLファイル単位ではなくフォルダ単位なので、ひとつのフォルダに多数のDLLがある場合でも1回の呼び出しで済む
    • Endコマンドを打つとDLLを解放してくれるが、パス設定は残っていて、何もしなくてもDLLをそのまま使える ←地味に重要
  • 短所
    • OSとしてWindows 8またはWindows Server 2012以上を必要とする

6.結論

VBA側のモジュールはこうしました。

Private Declare PtrSafe Function SetDefaultDllDirectories Lib "kernel32.dll" _
            (ByVal DirectoryFlags As Long) As Long

Private Declare PtrSafe Function AddDllDirectory Lib "kernel32.dll" _
            (ByVal fileName As String) As LongPtr

' DLL検索パスを設定    ExcelならWorkbook_Openでやってもいい
Sub set_dll_folder()
    Dim b As Byte, p As LongPtr
    b = SetDefaultDllDirectories(&H1000)    'LOAD_LIBRARY_SEARCH_DEFAULT_DIRS
    p = AddDllDirectory(StrConv("C:\***\***\***", vbUnicode))
    'p = AddDllDirectory(必要に応じて追加
End Sub

これでDelcare文にはDLL名だけ記述すればよく、パスを書く必要はなくなります。

AddDllDirectoryの引数はPCWSTRなので、フォルダ名はStrConv( , vbUnicode)で変換します。
ハードコーディングではなく環境にあわせて動的にフォルダ名を生成するようなこともできるはずです。

追記

ひとつのVBAモジュールで、リリース版のDLLとデバッグ版のDLLを切り替えたくなる場合があります。この場合はRemoveDllDirectoryという別のAPIを使って以下のようにできます。

 BOOL  WINAPI RemoveDllDirectory(
  _In_ DLL_DIRECTORY_COOKIE Cookie
);
Option Explicit

Private Declare PtrSafe Function SetDefaultDllDirectories Lib "kernel32.dll" _
            (ByVal DirectoryFlags As Long) As Long

Private Declare PtrSafe Function AddDllDirectory Lib "kernel32.dll" _
            (ByVal fileName As String) As LongPtr

Private Declare PtrSafe Function RemoveDllDirectory Lib "kernel32.dll" _
            (ByVal cookie As LongPtr) As Long

Private NDEBUG As Boolean
Private cookie_ As LongPtr

' 最初はリリース版のDLLを組み込む
Private Sub Workbook_Open()
    If cookie_ <> 0 Then Exit Sub
    Dim b As Byte
    b = SetDefaultDllDirectories(&H1000)    'LOAD_LIBRARY_SEARCH_DEFAULT_DIRS
    cookie_ = AddDllDirectory(StrConv("***\Release", vbUnicode))
    NDEBUG = True
End Sub

' デバッグ版のDLLに切り替える
Private Sub changeToDebug()
    If cookie_ = 0 Then Call Workbook_Open
    If NDEBUG Then
        If RemoveDllDirectory(cookie_) Then
            cookie_ = AddDllDirectory(StrConv("***\Debug", vbUnicode))
            NDEBUG = False
        Else
            Stop
        End If
    End If
End Sub

' リリース版のDLLに切り替える
Private Sub changeToRelease()
    If cookie_ = 0 Then Call Workbook_Open
    If Not NDEBUG Then
        If RemoveDllDirectory(cookie_) Then
            cookie_ = AddDllDirectory(StrConv("***\release", vbUnicode))
            NDEBUG = True
        Else
            Stop
        End If
    End If
End Sub

これにわざわざUIを作る必要はないと思うので、changeToDebugchangeToReleaseの呼び出しは「VBE上でF5を押下する」でいいでしょう。