[C#] 高速でファイルとフォルダを列挙する

2018-11-17 (土)

Win 32 APIのFindFirstFileExを使用して実装します。Directoryクラスと同じような静的メソッドで実装するので、そのまま差し替えられます。

環境

  • Windows 10 Pro 64bit 1803
  • Visual Studio Community 2017 15.8.2
  • .NET Framework 4.6.1
  • C# 7.1

前提条件

  • Windows 7以降のみ対応
  • アクセス権限確認(FileIOPermission)を省略した実装
  • Nativeな例外処理(Marshal.GetLastWin32Error)を省略した実装

十分な動作確認をしてないので、注意してください。

使用方法

Directoryクラスと同じようなメソッドで使用できます。列挙される順番も同じはずです。

// 戻り値は IEnumerable<string> ファイル(フォルダ)のフルパスを返す
DirectoryUtil.EnumerateFiles(path, searchPattern, searchOption);
DirectoryUtil.EnumerateDirectories(path, searchPattern, searchOption);
DirectoryUtil.EnumerateFileSystemEntries(path, searchPattern, searchOption);

// 戻り値は IEnumerable<FileData> ファイル(フォルダ)の詳細を返す
DirectoryUtil.EnumerateFilesData(path, searchPattern, searchOption);
DirectoryUtil.EnumerateDirectoriesData(path, searchPattern, searchOption);
DirectoryUtil.EnumerateFileSystemEntriesData(path, searchPattern, searchOption);

FileDataクラスはFileInfoクラスに似た定義です。

public class FileData
{
    public FileAttributes Attributes { get; }
    public bool IsFile => (Attributes & FileAttributes.Directory) == 0;
    public bool IsDirectory => (Attributes & FileAttributes.Directory) != 0;
    public DateTime CreationTimeUtc { get; }
    public DateTime CreationTime => CreationTimeUtc.ToLocalTime();
    public DateTime LastAccessTimeUtc { get; }
    public DateTime LastAccesTime => LastAccessTimeUtc.ToLocalTime();
    public DateTime LastWriteTimeUtc { get; }
    public DateTime LastWriteTime => LastWriteTimeUtc.ToLocalTime();
    public long Length { get; }
    public string Name { get; }
    public string FullName { get; }

    internal FileData(ref string fullName, ref NativeMethods.WIN32_FIND_DATA findData)
    {
        Attributes = findData.dwFileAttributes;
        CreationTimeUtc = findData.ToCreationTimeUtc;
        LastAccessTimeUtc = findData.ToLastAccessTimeUtc;
        LastWriteTimeUtc = findData.ToLastWriteTimeUtc;
        Length = ((long)findData.nFileSizeHigh << 32) + findData.nFileSizeLow;
        Name = findData.cFileName;
        FullName = fullName;
    }

    public override string ToString() => FullName;
}

実装

DirectoryUtil.cspublic定義を実装します。

public static partial class DirectoryUtil
{
    public static IEnumerable<string> EnumerateFiles(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
    {
        return EnumerateFullName(path, searchPattern, searchOption, true, false);
    }

    public static IEnumerable<string> EnumerateDirectories(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
    {
        return EnumerateFullName(path, searchPattern, searchOption, false, true);
    }

    public static IEnumerable<string> EnumerateFileSystemEntries(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
    {
        return EnumerateFullName(path, searchPattern, searchOption, true, true);
    }

    public static IEnumerable<FileData> EnumerateFilesData(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
    {
        return EnumerateFileData(path, searchPattern, searchOption, true, false);
    }

    public static IEnumerable<FileData> EnumerateDirectoriesData(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
    {
        return EnumerateFileData(path, searchPattern, searchOption, false, true);
    }

    public static IEnumerable<FileData> EnumerateFileSystemEntriesData(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
    {
        return EnumerateFileData(path, searchPattern, searchOption, true, true);
    }
}

public class FileData
{
    public FileAttributes Attributes { get; }
    public bool IsFile => (Attributes & FileAttributes.Directory) == 0;
    public bool IsDirectory => (Attributes & FileAttributes.Directory) != 0;
    public DateTime CreationTimeUtc { get; }
    public DateTime CreationTime => CreationTimeUtc.ToLocalTime();
    public DateTime LastAccessTimeUtc { get; }
    public DateTime LastAccesTime => LastAccessTimeUtc.ToLocalTime();
    public DateTime LastWriteTimeUtc { get; }
    public DateTime LastWriteTime => LastWriteTimeUtc.ToLocalTime();
    public long Length { get; }
    public string Name { get; }
    public string FullName { get; }

    internal FileData(ref string fullName, ref NativeMethods.WIN32_FIND_DATA findData)
    {
        Attributes = findData.dwFileAttributes;
        CreationTimeUtc = findData.ToCreationTimeUtc;
        LastAccessTimeUtc = findData.ToLastAccessTimeUtc;
        LastWriteTimeUtc = findData.ToLastWriteTimeUtc;
        Length = ((long)findData.nFileSizeHigh << 32) + findData.nFileSizeLow;
        Name = findData.cFileName;
        FullName = fullName;
    }

    public override string ToString() => Name;
}

DirectoryUtilEnumerable.csinternal定義を実装します。

ISelector<T>インターフェイスを実装したクラスをEnumerateCore<T>メソッドに渡せば、戻り値の拡張はできます。
(以下はprivate定義にしているので、このままでは外部から拡張できませんが。)

public static partial class DirectoryUtil
{
    internal static class NativeMethods
    {
        [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
        internal static extern SafeFindFileHandle FindFirstFileEx(
            string lpFileName,
            FINDEX_INFO_LEVELS fInfoLevelId,
            out WIN32_FIND_DATA lpFindFileData,
            FINDEX_SEARCH_OPS fSearchOp,
            IntPtr lpSearchFilter,
            FIND_FIRST_EX dwAdditionalFlags);

        [DllImport("kernel32.dll", CharSet = CharSet.Unicode, BestFitMapping = false)]
        internal static extern bool FindNextFile(SafeFindFileHandle hFindFile, out WIN32_FIND_DATA
            lpFindFileData);

        [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
        [DllImport("kernel32.dll")]
        internal static extern bool FindClose(IntPtr handle);

        internal enum FINDEX_INFO_LEVELS
        {
            Standard = 0,
            Basic = 1
        }

        internal enum FINDEX_SEARCH_OPS
        {
            SearchNameMatch = 0,
            SearchLimitToDirectories = 1,
            SearchLimitToDevices = 2
        }

        internal enum FIND_FIRST_EX
        {
            CaseSensitive = 1,
            LargeFetch = 2
        }

        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
        [BestFitMapping(false)]
        internal struct WIN32_FIND_DATA
        {
            public FileAttributes dwFileAttributes;
            public FILE_TIME ftCreationTime;
            public FILE_TIME ftLastAccessTime;
            public FILE_TIME ftLastWriteTime;
            public uint nFileSizeHigh;
            public uint nFileSizeLow;
            public uint dwReserved0;
            public uint dwReserved1;
            [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
            public string cFileName;
            [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)]
            public string cAlternateFileName;

            internal bool IsRelative => cFileName == "." || cFileName == "..";
            internal bool IsFile => (dwFileAttributes & FileAttributes.Directory) == 0;
            internal bool IsDirectory => (dwFileAttributes & FileAttributes.Directory) != 0;

            internal DateTime ToCreationTimeUtc => DateTime.FromFileTimeUtc(ftCreationTime.ToTicks());
            internal DateTime ToLastAccessTimeUtc => DateTime.FromFileTimeUtc(ftLastAccessTime.ToTicks());
            internal DateTime ToLastWriteTimeUtc => DateTime.FromFileTimeUtc(ftLastWriteTime.ToTicks());

            public override string ToString() => cFileName;
        }

        [StructLayout(LayoutKind.Sequential)]
        internal struct FILE_TIME
        {
            public FILE_TIME(long fileTime)
            {
                ftTimeLow = (uint)fileTime;
                ftTimeHigh = (uint)(fileTime >> 32);
            }

            public long ToTicks()
            {
                return ((long)ftTimeHigh << 32) + ftTimeLow;
            }

            internal uint ftTimeLow;
            internal uint ftTimeHigh;
        }

        internal sealed class SafeFindFileHandle : SafeHandleZeroOrMinusOneIsInvalid
        {
            internal SafeFindFileHandle() : base(true)
            {
            }

            protected override bool ReleaseHandle()
            {
                return FindClose(handle);
            }
        }
    }

    private interface ISelector<T>
    {
        T Create(ref string fullName, ref NativeMethods.WIN32_FIND_DATA findData);
    }

    private class FullNameSelector : ISelector<string>
    {
        public string Create(ref string fullName, ref NativeMethods.WIN32_FIND_DATA findData) => fullName;
    }

    private class FileDataSelector : ISelector<FileData>
    {
        public FileData Create(ref string fullName, ref NativeMethods.WIN32_FIND_DATA findData) => new FileData(ref fullName, ref findData);
    }

    private static IEnumerable<string> EnumerateFullName(string path, string searchPattern, SearchOption searchOption, bool includeFiles, bool includeDirs)
    {
        return Enumerate(path, searchPattern, searchOption, includeFiles, includeDirs, new FullNameSelector());
    }

    private static IEnumerable<FileData> EnumerateFileData(string path, string searchPattern, SearchOption searchOption, bool includeFiles, bool includeDirs)
    {
        return Enumerate(path, searchPattern, searchOption, includeFiles, includeDirs, new FileDataSelector());
    }

    private static IEnumerable<T> Enumerate<T>(string path, string searchPattern, SearchOption searchOption, bool includeFiles, bool includeDirs, ISelector<T> selector)
    {
        if (path == null)
            throw new ArgumentNullException(nameof(path));
        if (searchPattern == null)
            throw new ArgumentNullException(nameof(searchPattern));
        if (searchOption != SearchOption.TopDirectoryOnly && searchOption != SearchOption.AllDirectories)
            throw new ArgumentOutOfRangeException(nameof(searchOption));

        return EnumerateCore(Path.GetFullPath(path).TrimEnd('\\'), searchPattern, searchOption, includeFiles, includeDirs, selector);
    }

    private static IEnumerable<T> EnumerateCore<T>(string dir, string searchPattern, SearchOption searchOption, bool includeFiles, bool includeDirs, ISelector<T> selector)
    {
        // extend MAX_PATH
        var search = (dir.StartsWith(@"\\", StringComparison.OrdinalIgnoreCase)
                            ? @"\\?\UNC\" + dir.Substring(2)
                            : @"\\?\" + dir) + @"\" + searchPattern;

        Queue<string> subDirs = null;

        using (var fileHandle = NativeMethods.FindFirstFileEx(search,
                                                              NativeMethods.FINDEX_INFO_LEVELS.Basic,
                                                              out var findData,
                                                              NativeMethods.FINDEX_SEARCH_OPS.SearchNameMatch,
                                                              IntPtr.Zero,
                                                              NativeMethods.FIND_FIRST_EX.LargeFetch))
        {
            if (fileHandle.IsInvalid) yield break;

            do
            {
                if (findData.IsRelative) continue;

                var path = dir + @"\" + findData.cFileName;

                if (findData.IsFile)
                {
                    if (includeFiles)
                        yield return selector.Create(ref path, ref findData);
                }
                else if (findData.IsDirectory)
                {
                    if (includeDirs)
                        yield return selector.Create(ref path, ref findData);

                    if (searchOption == SearchOption.AllDirectories)
                    {
                        subDirs = subDirs ?? new Queue<string>();
                        subDirs.Enqueue(path);
                    }
                }

            } while (NativeMethods.FindNextFile(fileHandle, out findData));
        }

        if (subDirs == null) yield break;

        while (subDirs.Count > 0)
        {
            foreach (var path in EnumerateCore(subDirs.Dequeue(), searchPattern, searchOption, includeFiles, includeDirs, selector))
                yield return path;
        }
    }
}

注意

Windows7より前は未対応

Windows Vista以前の場合は、FindFirstFileExの引数に注意する必要があるようです。

こんな感じで対応が必要ですが、列挙速度は落ちるかも知れません。

FINDEX_INFO_LEVELS findInfoLevel = FINDEX_INFO_LEVELS.FindExInfoStandard;
int additionalFlags = 0;
if (Environment.OSVersion.Version.Major >= 6)
{
    findInfoLevel = FINDEX_INFO_LEVELS.FindExInfoBasic;
    additionalFlags = FIND_FIRST_EX_LARGE_FETCH;
}

WIN32_FIND_DATAの定義の注意点

System.Runtime.InteropServices.ComTypes.FILETIMEは使わない

  • 上記はintで定義されているが、uintを定義した構造体を使用する
  • .NET Frameworkの実装もuintになっている
  • ほとんど問題ないがMacで作成したZIPで問題が発生することがある?

FILETIMEはlongにしない

FILETIME構造体で定義し、longで定義しない方が良いみたいです。
FILETIME structure (Windows)

またuintでHigh, Lowで直接定義せずに、FILETIME構造体を定義した方が良いのかも知れません。

// OK
public FILE_TIME ftCreationTime;
public FILE_TIME ftLastAccessTime;
public FILE_TIME ftLastWriteTime;

// NG
public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime;
public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime;
public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime;

// NG
public long ftCreationTime;
public long ftLastAccessTime;
public long ftLastWriteTime;

// NG
public uint ftCreationTime_dwLowDateTime;
public uint ftCreationTime_dwHighDateTime;
public uint ftLastAccessTime_dwLowDateTime;
public uint ftLastAccessTime_dwHighDateTime;
public uint ftLastWriteTime_dwLowDateTime;
public uint ftLastWriteTime_dwHighDateTime;

[StructLayout(LayoutKind.Sequential)]
internal struct FILE_TIME
{
    public FILE_TIME(long fileTime)
    {
        ftTimeLow = (uint)fileTime;
        ftTimeHigh = (uint)(fileTime >> 32);
    }

    public long ToTicks()
    {
        return ((long)ftTimeHigh << 32) + ftTimeLow;
    }

    internal uint ftTimeLow;
    internal uint ftTimeHigh;
}

感謝

2018-11-17 (土)