エクセルの神髄さんのTwitter企画 「VBA100本ノック」への便乗記事、飛びとびですが、今回は66本目。
お題:VBA100本ノック 66本目:全サブフォルダからファイルを探す
#VBA100本ノック 66本目
— エクセルの神髄 (@yamaoka_ss) 2021年1月13日
ブック自身のあるフォルダ以下の全サブフォルダを検索し、自身と同一名称(拡張子含めて)のファイルを探してください。
同一名称のファイルが見つかったら、シートに出力してください。
・A列:フルパス
・B列:更新日時
・C列:ファイルサイズ
※シートは任意
出題ページ
方針
あちこちにファイルをコピーしてどれがなんだかわからなくこと、よくありますね。 こういう、開いているブックと同名のファイルを検索するマクロを用意しておくと重宝しそうです。
お題への模範的な解答としては FileSystemObject と再帰関数を使ってフォルダ内を探索するものでしょう。
ただ、再帰プログラムを書くのは楽しいのですが、すでに多くの方の回答にあるので今回は見送り、別の方法を考えます。
ところで、この FileSystemObject の再帰的な探索は、フォルダが大きくなるととどうしても重くなるという不満があるようです。
重いといっても実用上はたいして問題ならないと思いますが、 ほんとうに検索速度が重要だとしても VBA でロジックを組む限りどんなに工夫して最適化したところで限界があります。
それならシステムに検索させた方が早いでしょう。 高速化が望めるうえプログラミングも楽になります。
「システム」とは何かと問われれば説明が難しいのですが、具体的には、CMD(コマンドプロンプト)、Shell(エクスプローラ)、PowerShell、WMI、Win32API、.Net Framework 等々、Excel の外、 Windows の OS 周りで提供されている機能を想定しています。
これらはたいていファイル検索機能も持っているので、 VBA からは外部呼び出しとして検索を実行し、結果だけを返してもらうようにします。
プログラミング問題への解答としては、ちょっとズルい感じがしなくもないですが、 Twitterでの回答や 本課題のページの解答例を見ると、 すでに再帰を使わない方法として同様のアイデアがいくつかの紹介されていますので、それもアリということで。
本記事では、ほかの解答例にまだなさそうな、「PowerShell」 と 「WMI」 を使った2つの呼び出しパターンで実装してみます。
おまけで「システムインデックス」も試してみようかと思います。
解答1:PowerShell のスクリプトを使う
Option Explicit ' PowerShell を呼び出してファイル検索する Sub VBA100_066_ex1() Dim folderPath As String Dim findName As String folderPath = ActiveWorkbook.Path findName = ActiveWorkbook.Name If folderPath = "" Then MsgBox "このブックは未保存です" Exit Sub ElseIf ActiveWorkbook.Path Like "https://*" Then MsgBox "OneDriveのファイル検索は未対応です" Exit Sub End If ' ファイル名を検索してファイル情報をタブ区切りのテキストとしてクリップボードにコピーする Const ps = "&{ls $args[0] $args[1] -s -af|%{($_.FullName,$_.LastWriteTime, $_.Length) -join [char]0x9}|scb}" CreateObject("WScript.Shell") _ .Run "powershell -c """ & ps & """ " & q(folderPath) & " " & q(findName), 0, True If Application.ClipboardFormats(1) = -1 Then MsgBox "ファイルは見つかりませんでした" & vbCrLf & folderPath & vbCrLf & findName Exit Sub ' オリジナルファイル自体が検索されるので、最低1個は見つかるはず ' ファイル名に一部特殊文字が含まれているとそれさえ見つからないことがある End If With ThisWorkbook.Worksheets.Add ' 新規シート .Paste .Columns.AutoFit .Activate End With End Sub Private Function q(ByVal str As String) As String q = "'" & Replace(Replace(str, "'", "''"), "’", "’’") & "'" End Function
解答2:WMI のクエリを使う
Option Explicit ' WNI クエリでファイル検索する Sub VBA100_066_ex2() If ActiveWorkbook.Path = "" Then MsgBox "このブックは未保存です" Exit Sub End If If ActiveWorkbook.Path Like "https://*" Then MsgBox "OneDriveのファイル検索は未対応です" Exit Sub End If Dim fso As Object Set fso = CreateObject("Scripting.FileSystemObject") Dim spec As String spec = ActiveWorkbook.FullName ' ファイル検索のクエリ(WQL)を組む Dim query As String query = "select * from CIM_DataFile where" _ & " Drive = " & q(fso.GetDriveName(spec)) _ & " and Path like " & q(Mid(fso.GetParentFolderName(spec), 3) & "%") _ & " and FileName = " & q(fso.GetBaseName(spec)) _ & " and Extension = " & q(fso.GetExtensionName(spec)) ' WMI にファイル検索のクエリを投げる On Error GoTo wmi_error Dim cimFiles As Object Set cimFiles = GetObject("winmgmts:\\" & "." & "\root\cimv2").ExecQuery(query) If cimFiles.Count = 0 Then MsgBox "ファイルは見つかりませんでした" & vbCrLf _ & fso.GetParentFolderName(spec) & vbCrLf & fso.GetFileName(spec) Exit Sub ' オリジナルファイル自体が検索されるので、最低1個は見つかるはず End If Dim sh As Worksheet Set sh = ThisWorkbook.Worksheets.Add ' 新規シート Dim cur As Range Set cur = sh.Range("A1:C1") Dim cimFile As Object For Each cimFile In cimFiles cur = Array( _ cimFile.Name, _ convSwDate(cimFile.LastModified), _ cimFile.FileSize) Set cur = cur.Offset(1) Next sh.Columns.AutoFit sh.Activate Exit Sub wmi_error: Debug.Print query MsgBox Err.Description End Sub ' 日時型の変換 ' https://qiita.com/nukie_53/items/ca89faede67c905912eb Private Function convSwDate(s As String) As Date With CreateObject("WbemScripting.SWbemDateTime") .Value = s convSwDate = .GetVarDate(True) End With End Function Private Function q(str As String) As String q = "'" & Replace(Replace(str, "\", "\\"), "'", "\'") & "'" End Function
考察
技術要素について説明します。
PowerShell
Windows PowerShell とはシステム管理などで使うあの青いコマンド画面のことです。
黒い画面のコマンドプロンプトのほうはもう使わないでほしい、というのがマイクロソフトさんからのお願いです。
上記解答例では、ファイル名を検索する PowerShell スクリプトを用意して、 poweshell コマンドを VBA 側から実行しています。
検索結果の受け渡しは、標準入出力経由でもよかったのですが、面倒なのでここではクリップボードを経由させました。
ちゃんと比較検証はしていませんが、少なくとも FileSystemObject によるよる検索よりはたしかに高速でした。 また CMD のコマンドを使った例よりも速いようです。
さて、powershell コマンドに渡している検索スクリプトは、ワンライナーに収めるためできるだけ短縮した表現になっています。
&{ls $args[0] $args[1] -s -af|%{($_.FullName,$_.LastWriteTime, $_.Length) -join [char]0x9}|scb}
慣れていないと暗号のようで分かりにくいと感じるでしょう。
ちなみに、これを短縮なしで書くと次のような感じになりますが・・・
Invoke-Command -ScriptBlock { Get-ChildItem -Path $args[0] -Filter $args[1] -Recurse -File | ForEach-Object { $a = @($_.FullName, $_.LastWriteTime, $_.Length) $a -join "`t" } | Set-Clipboard } -ArgumentList 'D:\folder\path', 'findName.xlsx'
ひとつも分かり易くなりませんね。 それが PowerShell 。
注意点として、ファイル名によっては検索に失敗する可能性があるようです。 検索の元にしたファイルがあるはずなので、最低1個は引っかかるはずなのですが、それさえも見つからないという状態です。
これはどうも、ファイル名に含まれる特殊な文字が原因となっているようです。
powershell のコマンドラインの制約からファイル名に含まれていると正しく検索できない特殊な文字(記号)、例えばワイルドカードに使われる[
や]
などがあります。
ほかにどんな特殊文字があるのか調べ切れていませんが、 実用のためにはこれらの文字をちゃんとエスケープする処理を追加する必要があるかもしれません。
WMI
WMI (Windows Management Instrumentation) もシステム管理者用の WIndows の機能で、wmic という対話的コマンドも用意されています。
WMI を使うと PC のデバイスや OS の情報、ネットワークの構成、 プロセス や イベント の状態など Windows システム上のあらゆる情報を収集できます。
そこに、ファイルとフォルダも、全てもれなく管理対象として含まれます。
WMI はそれらシステム情報を WMI リポジトリ(WMI Repository)という一種の専用データベースで管理しています。
構造化されたデータベース内の検索は、ファイルシステムのパスを地道に探索する( CMD や PowerShell のような)より遥かに高速になるはずです。
VBA から WMI オブジェクトに渡すのは WQL(Windows Query Language)という、簡易版 SQL のような言語で書かれたクエリ文です。
上記解答プログラムの query
変数を展開すると、筆者の環境(D:\VBA100本ノック\66本目\全サブフォルダからファイルを探す.xlsm)では以下のようなクエリになります。
select * from CIM_DataFile where Drive = 'D:' and Path like '\\VBA100本ノック\\66本目%' and FileName = '全サブフォルダからファイルを探す' and Extension = 'xlsm'
ここで、 ファイルを管理する CIM_DataFile クラスという(テーブルのような)ものからファイルプロパティを条件にデータを検索しています。 CIM_DataFile で参照できるプロパティにどんなものがあるのかは、 ドキュメントを参考にしてください。
パスのセパレータ(\
)は2重(\\
)にする必要があります。
フォルダ配下を検索対象とするため、like
演算子でフォルダパス(Path)の先頭一致をテストしています。
WQL エンジンによるクエリの最適化を期待していいのかわかりませんが、 もしかしたら、判定式の順序にカーディナリティを配慮したり、コストのかかる Like 文の評価を後回しにするなどしたほうが、さらにパフォーマンスの改善が望めるのかもしれません。 これも未検証ですが。
さて、WMI でも注意点があります。
WMI の情報をネット検索していると、リポジトリの修復や再構築方法の話題がけっこう目につきます。 どうも、WMI のレポジトリは壊れることがままあるようなのです。
業務アプリの検索機能に WMI を応用する場合には、要求された信頼性を満たせるのか慎重な判断が必要になるでしょう。
おまけ:システムインデックスのクエリを使う
ついでなので、システムインデックスを使った検索も試しておきます。 検証プログラムは本節の最後に掲載します。
システムインデックス
「システムインデックス」は、 Windows 標準搭載の検索エンジンである Windows Search が使用しているインデックスです。
インデックスとは、ファイル名やプロパティ(属性)、ファイルのデータから抽出された単語などからファイルへの索引として参照される 特別なデータベースです。 これにより Windows Search は検索キーワードから該当ファイルを高速に検索することができます。
Windows Search は、エクスプローラやタスクバーの検索窓からファイルを検索するときに呼ばれていて、私たちは普段から意識せずにこれを使っています。
VBA からシステムインデックスの検索するのにもいろいろ方法があるようですが、ここでは一番お手軽そうな ADO 経由の検索を試してみます。
その場合、WSSQL(Windows Search SQL) というこれまた SQL を拡張した言語によるクエリを Windows Search に投げます。
筆者のドキュメントフォルダ(C:\Users\shego\Documents)配下で EXCEL ファイル(XLSX)を検索するには以下のようなクエリになります。
select System.ItemPathDisplay, System.DateModified, System.Size from SYSTEMINDEX where SCOPE = 'C:\Users\shego\Documents' and System.FileExtension = '.xlsx'
SQL の FROM 句に SYSTEMINDEX を固定で指定します。
WHERE 句の SCOPE で指定したフォルダパスの配下が検索対象となります。 フォルダ直下だけを検索したいときにはこれを DIRECTORY に代えます。
WHERE 句や出力リストに参照できるプロパティにどんなものがあるのかは ドキュメントを確認してみてください。
パフォーマンス的には、特に大量のファイルから検索する場合、WMI のときよりも高速になると期待されます。 ただ、これも実際に測定検証はしていませんのでどの程度とまでは言えませんが。
はい、ここでも注意があります。
確実に対象ファイルが存在するフォルダを検索しても、何も結果が返ってこないことがあるのです。
それにはいくつかの要因があります。
まず、Windows Search は全てのファイルをインデックス化しているわけではありません。
たとえばよくあるCドライブやDドライブの直下に作成した作業データのフォルダ内はデフォルトでは検索されません。 インデックスの作成は PC に負担がかかるためか、デフォルトでは必要最小限のフォルダしか登録されていないからです。
インデックス対象フォルダの追加は「インデックスのオプション」設定で設定します。 デフォルト設定では、「ユーザー」フォルダ(C:\Users)が登録されているので、ドキュメントフォルダなどユーザホーム配下のファイルなら問題なく検索できるはずです。
Windows Search は「インデックスのオプション」設定のほか、「Windowsの検索」設定(クラッシックモードか拡張モードかなど)、 ファイルシステムやエクスプローラの設定、フォルダが OneDrive などクラウドにあるのかどうか等、 様々な環境要因に左右されるので管理が難しいです。
そもそも、ここ最近 Windows Search 周りが刷新されてからは何とも不安定な状態でトラブルや不具合が多発していて、正常に検索できないことが多々あるようです。
今回のお題の解答例とせずに参考的なおまけとしたのも、PC環境によって動作を確認できない可能性があるからです。
とはいえ、安定に稼働さえすれば、システムインデックスを使う利点は大きいです。
- Excel ファイル内の全文検索ができる
- ファイルやフォルダが大量であっても、高速な検索ができる
- ファイルサーバなどリモートマシンを対象に検索できる (PowerShell や WMIでも可能ですが)
以下は検証用のプログラムです。 folderPathにご自身のドキュメントフォルダなどのパスを指定して実行してみてください。
Sub システムインデックスでXLSXファイルを検索する() On Error GoTo error Dim folderPath As String ' ドキュメントフォルダならデフォルトでインデックス化されているはず folderPath = "C:\Users\shego\Documents" 'folderPath = "file:c:/Users/shego/Documents" ' URL形式が正式 'folderPath = "C:\Users\shego\OneDrive\ドキュメント" ' OneDrive Set adoConn = CreateObject("ADODB.Connection") adoConn.Open "Provider=Search.CollatorDSO;Extended Properties='Application=Windows';" Set adoRset = CreateObject("ADODB.Recordset") adoRset.Open _ "select System.ItemPathDisplay, System.DateModified, System.Size from SYSTEMINDEX " & _ "where SCOPE = '" & folderPath & "' and System.FileExtension = '.xlsx'", _ adoConn adoRset.MoveFirst Do Until adoRset.EOF Debug.Print adoRset.Fields(0) ' フルパス名 Debug.Print adoRset.Fields(1) ' 更新日時 Debug.Print adoRset.Fields(2) ' ファイルサイズ adoRset.MoveNext Loop error: Debug.Print Err.Description End Sub
参考資料
- FileInfo クラス (System.IO) | Microsoft Docs
PowerShellで取得できるファイルプロパティの一覧 - WMI and SQL - Win32 apps | Microsoft Docs
WMI Query Language(WQL)の仕様 - CIM_DataFile class - Win32 apps | Microsoft Docs
WMIで取得できるファイルプロパティの一覧 - VBAでUTC時刻・オフセット付き時刻を取得する(WMI使用) - Qiita
- SQL および AQS アプローチを使用したインデックスのクエリ - Win32 apps | Microsoft Docs
- Windows Search SQL 構文を使用したインデックスのクエリ - Win32 apps | Microsoft Docs
Windows Search SQL の仕様 - Core - Win32 apps | Microsoft Docs
システムインデックスで定義されているファイルプロパティの一覧
関連記事
本ブログで再帰関数を使ったVBAマクロの例が以下の記事にあります。