mcpField
D
dotnet-file-io

dotnet-file-io

github.com/wshaddix开发8677 次安装35 周前更新
代码仓库

关于 dotnet-file-io

dotnet-file-io 是介绍 .NET 应用文件 I/O 模式的技能,涵盖异步和按偏移访问、文件监视、内存映射、路径安全及跨平台行为。

主要功能

  1. 01配置异步 FileStream 访问
  2. 02使用 RandomAccess 并发进行按偏移 I/O
  3. 03处理并合并 FileSystemWatcher 事件
  4. 04用 MemoryMappedFile 访问大文件和实现 IPC
  5. 05安全处理路径和临时文件
  6. 06应对平台差异与 I/O 错误

适用场景

  1. 01在 .NET 应用中异步读写文件
  2. 02并发访问文件的不同偏移位置
  3. 03可靠响应密集的文件系统变更
  4. 04处理用户提供的路径时防止路径遍历

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-file-io

安装后在 Claude Code 中输入/dotnet-file-io确认已加载。

详细介绍

dotnet-file-io

适用于 .NET 应用程序的文件 I/O 模式。涵盖带异步标志的 FileStream 构造、用于线程安全偏移量 I/O 的 RandomAccess API、File 便捷方法、FileSystemWatcher 事件处理与防抖、用于大型文件和 IPC 的 MemoryMappedFile、路径处理安全(Combine 与 Join)、安全创建临时文件、跨平台注意事项、IOException 层次结构以及缓冲区大小指导。

不在范围内: PipeReader/PipeWriter 和网络 I/O——参见 [skill:dotnet-io-pipelines]。Async/await 基础知识——参见 [skill:dotnet-csharp-async-patterns]。Span/Memory/ArrayPool 深入模式——参见 [skill:dotnet-performance-patterns]。JSON 和 Protobuf 序列化——参见 [skill:dotnet-serialization]。BackgroundService 生命周期——参见 [skill:dotnet-background-services]。Channel 生产者/消费者——参见 [skill:dotnet-channels]。固定/内存映射后备数组对 GC 的影响——参见 [skill:dotnet-gc-memory]。文件上传验证(IFormFile)——参见 [skill:dotnet-input-validation]。

如需可测试的文件系统访问,可考虑使用 System.IO.Abstractions——有关测试隔离模式,请参见 [skill:dotnet-testing-strategy]。

交叉引用:[skill:dotnet-io-pipelines] 介绍 PipeReader/PipeWriter 网络 I/O,[skill:dotnet-gc-memory] 介绍 POH 和内存映射后备数组对 GC 的影响,[skill:dotnet-performance-patterns] 介绍 Span/Memory 基础知识和 ArrayPool 用法,[skill:dotnet-csharp-async-patterns] 介绍文件流中使用的 async/await 模式。


FileStream

异步标志要求

如果打开流时未设置异步标志,FileStream 异步方法(ReadAsync、WriteAsync)会静默阻塞调用线程。这是 .NET 代码中最常见的文件 I/O 错误。

// CORRECT: async-capable FileStream
await using var fs = new FileStream(
    path,
    FileMode.Open,
    FileAccess.Read,
    FileShare.Read,
    bufferSize: 4096,
    useAsync: true);  // Required for true async I/O

byte[] buffer = new byte[4096];
int bytesRead = await fs.ReadAsync(buffer, cancellationToken);
// ALSO CORRECT: FileOptions overload
await using var fs = new FileStream(
    path,
    FileMode.Create,
    FileAccess.Write,
    FileShare.None,
    bufferSize: 4096,
    FileOptions.Asynchronous | FileOptions.SequentialScan);

如果未设置 useAsync: true 或 FileOptions.Asynchronous,运行时会通过将同步 I/O 分派到线程池来模拟异步操作,从而浪费一个线程并增加开销。

FileStreamOptions(.NET 6+)
await using var fs = new FileStream(path, new FileStreamOptions
{
    Mode = FileMode.Open,
    Access = FileAccess.Read,
    Share = FileShare.Read,
    Options = FileOptions.Asynchronous | FileOptions.SequentialScan,
    BufferSize = 4096,
    PreallocationSize = 1_048_576  // Hint for write: reduces fragmentation
});

创建或覆盖文件时,PreallocationSize 会预先预留磁盘空间,从而减少写入时的文件系统碎片。


RandomAccess API(.NET 6+)

RandomAccess 提供静态的、基于偏移量的线程安全文件 I/O。与 FileStream 不同,它没有内部位置状态,因此多个线程可以并发读写不同偏移量,无需同步。

using var handle = File.OpenHandle(
    path,
    FileMode.Open,
    FileAccess.Read,
    FileShare.Read,
    FileOptions.Asynchronous);

// Thread-safe: offset is explicit, no shared position
byte[] buffer = new byte[4096];
int bytesRead = await RandomAccess.ReadAsync(
    handle, buffer, fileOffset: 0, cancellationToken);

// Read from a different offset concurrently -- no locking needed
byte[] buffer2 = new byte[4096];
int bytesRead2 = await RandomAccess.ReadAsync(
    handle, buffer2, fileOffset: 8192, cancellationToken);
Scatter/Gather I/O
// Read into multiple buffers in a single syscall
IReadOnlyList<Memory<byte>> buffers = new[]
{
    new byte[4096].AsMemory(),
    new byte[4096].AsMemory()
};
long totalRead = await RandomAccess.ReadAsync(
    handle, buffers, fileOffset: 0, cancellationToken);
何时使用 RandomAccess,何时使用 FileStream
场景使用
从不同偏移量并发读取RandomAccess
顺序流式读取/写入FileStream
索引文件、数据库页、内存映射的替代方案RandomAccess
与基于 Stream 的 API 集成FileStream

File 便捷方法

对于无需流式处理的小型文件,使用 File 静态方法更简单且正确。

// Read entire file as string (small files only)
string content = await File.ReadAllTextAsync(path, cancellationToken);

// Read all lines
string[] lines = await File.ReadAllLinesAsync(path, cancellationToken);

// Stream lines without loading entire file (.NET 8+)
await foreach (string line in File.ReadLinesAsync(path, cancellationToken))
{
    ProcessLine(line);
}

// Write text atomically (write-then-rename pattern not built-in)
await File.WriteAllTextAsync(path, content, cancellationToken);

// Read all bytes
byte[] data = await File.ReadAllBytesAsync(path, cancellationToken);
适用便捷方法的场景
文件大小方法
< 1 MBFile.ReadAllTextAsync / File.ReadAllBytesAsync
1--100 MBFile.ReadLinesAsync 或使用缓冲读取的 FileStream
> 100 MBFileStream 或使用显式缓冲区管理的 RandomAccess

FileSystemWatcher

基本设置
using var watcher = new FileSystemWatcher(directoryPath)
{
    Filter = "*.json",
    NotifyFilter = NotifyFilters.FileName
                 | NotifyFilters.LastWrite
                 | NotifyFilters.Size,
    IncludeSubdirectories = true,
    EnableRaisingEvents = true
};

watcher.Changed += OnChanged;
watcher.Created += OnCreated;
watcher.Deleted += OnDeleted;
watcher.Renamed += OnRenamed;
watcher.Error += OnError;
对重复事件进行防抖

对于一次逻辑更改,FileSystemWatcher 可能会触发重复事件(编辑器会写入临时文件、重命名文件并删除旧文件)。使用计时器进行防抖。

public sealed class DebouncedFileWatcher : IDisposable
{
    private readonly FileSystemWatcher _watcher;
    private readonly Channel<string> _channel;
    private readonly CancellationTokenSource _cts = new();

    public DebouncedFileWatcher(string path, string filter)
    {
        _channel = Channel.CreateBounded<string>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.DropOldest
            });

        _watcher = new FileSystemWatcher(path, filter)
        {
            EnableRaisingEvents = true
        };
        _watcher.Changed += (_, e) =>
            _channel.Writer.TryWrite(e.FullPath);
    }

    // requires: using System.Runtime.CompilerServices;
    public async IAsyncEnumerable<string> WatchAsync(
        TimeSpan debounce,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        using var linked = CancellationTokenSource
            .CreateLinkedTokenSource(ct, _cts.Token);
        var seen = new Dictionary<string, DateTime>();

        await foreach (var path in
            _channel.Reader.ReadAllAsync(linked.Token))
        {
            var now = DateTime.UtcNow;
            if (seen.TryGetValue(path, out var last)
                && now - last < debounce)
                continue;

            seen[path] = now;
            yield return path;
        }
    }

    public void Dispose()
    {
        _cts.Cancel();
        _watcher.Dispose();
        _cts.Dispose();
    }
}
缓冲区溢出

内部缓冲区默认为 8 KB。发生大量快速更改时,缓冲区会溢出,导致事件丢失。通过 InternalBufferSize 增大缓冲区(Windows 上最大为 64 KB),并处理 Error 事件。

watcher.InternalBufferSize = 65_536;  // 64 KB
watcher.Error += (_, e) =>
{
    if (e.GetException() is InternalBufferOverflowException)
    {
        logger.LogWarning("FileSystemWatcher buffer overflow -- events lost");
        // Trigger full directory rescan
    }
};
平台差异
平台后端显著行为
WindowsReadDirectoryChangesW最可靠;支持最大 64 KB 的 InternalBufferSize
Linuxinotify每个用户的监视数量有限制(/proc/sys/fs/inotify/max_user_watches);递归监视会为每个子目录添加一个 inotify 监视
macOSFSEvents (kqueue fallback)事件粒度较粗;可能会稍有延迟地批量处理事件

MemoryMappedFile

持久化(大型文件访问)

将文件映射到虚拟内存,以便随机访问,而无需显式调用读写操作。对于无法完全装入内存的大型文件,此方式效率很高——操作系统会按需将数据分页调入和调出。

using var mmf = MemoryMappedFile.CreateFromFile(
    path,
    FileMode.Open,
    mapName: null,
    capacity: 0,  // Use file's actual size
    MemoryMappedFileAccess.Read);

using var accessor = mmf.CreateViewAccessor(
    offset: 0,
    size: 0,  // Map entire file
    MemoryMappedFileAccess.Read);

// Read a struct at a specific offset
accessor.Read<MyHeader>(position: 0, out var header);

// Or use a view stream for sequential access
using var stream = mmf.CreateViewStream(
    offset: 0,
    size: 4096,
    MemoryMappedFileAccess.Read);
非持久化(IPC 共享内存)
// Process A: create shared memory region
using var mmf = MemoryMappedFile.CreateNew(
    "SharedRegion",
    capacity: 1_048_576,  // 1 MB
    MemoryMappedFileAccess.ReadWrite);

using var accessor = mmf.CreateViewAccessor();
accessor.Write(0, 42);

// Process B: open existing shared memory region
using var mmf2 = MemoryMappedFile.OpenExisting(
    "SharedRegion",
    MemoryMappedFileRights.Read);

using var accessor2 = mmf2.CreateViewAccessor(
    0, 0, MemoryMappedFileAccess.Read);
int value = accessor2.ReadInt32(0);  // 42

有关内存映射后备数组和 POH 使用对 GC 的影响,请参阅 [skill:dotnet-gc-memory]。


路径处理

Path.Combine 与 Path.Join 的安全性

当第二个参数是根路径时,Path.Combine 会静默丢弃第一个参数。如果将用户输入作为第二个参数传入,就可能引发路径遍历攻击。

// DANGEROUS: Path.Combine drops basePath when userInput is rooted
string basePath = "/app/uploads";
string userInput = "/etc/passwd";
string result = Path.Combine(basePath, userInput);
// result = "/etc/passwd"  -- basePath is silently ignored

// SAFER: Path.Join does not discard on rooted paths (.NET Core 2.1+)
string result2 = Path.Join(basePath, userInput);
// result2 = "/app/uploads//etc/passwd"  -- preserves basePath
防止路径遍历
public static string SafeResolvePath(string basePath, string userPath)
{
    // Resolve to absolute, resolving ../ and symlinks
    string fullBase = Path.GetFullPath(basePath);
    string fullPath = Path.GetFullPath(
        Path.Join(fullBase, userPath));

    // OrdinalIgnoreCase: safe cross-platform default.
    // On Linux-only deployments, Ordinal is more precise.
    if (!fullPath.StartsWith(fullBase + Path.DirectorySeparatorChar,
            StringComparison.OrdinalIgnoreCase)
        && !fullPath.Equals(fullBase, StringComparison.OrdinalIgnoreCase))
    {
        throw new UnauthorizedAccessException(
            "Path traversal detected");
    }

    return fullPath;
}
跨平台路径分隔符

请使用 Path.DirectorySeparatorChar(平台特定)和 Path.AltDirectorySeparatorChar,而不要硬编码 / 或 \\。Path.Join 和 Path.Combine 会自动处理分隔符规范化。


安全临时文件

Path.GetTempFileName() 会创建一个名称模式可预测的零字节文件;当临时目录中包含 65,535 个 .tmp 文件时会抛出异常。请改用 Path.GetRandomFileName()。

// INSECURE: predictable name, may throw IOException
// string tempFile = Path.GetTempFileName();

// SECURE: cryptographically random name, explicit creation
string tempPath = Path.Join(
    Path.GetTempPath(),
    Path.GetRandomFileName());

// CreateNew ensures atomic creation -- fails if file exists
await using var fs = new FileStream(
    tempPath,
    FileMode.CreateNew,
    FileAccess.Write,
    FileShare.None,
    bufferSize: 4096,
    FileOptions.Asynchronous | FileOptions.DeleteOnClose);

await fs.WriteAsync(data, cancellationToken);

FileOptions.DeleteOnClose 可确保流关闭时删除临时文件。在 Windows 上,操作系统会保证最后一个句柄关闭时删除文件。在 Linux/macOS 上,文件会在 Dispose 期间删除;如果进程被突然终止(SIGKILL),则可能不会删除。


跨平台注意事项

区分大小写
平台默认文件系统大小写行为
WindowsNTFS保留大小写,不区分大小写
macOSAPFS保留大小写,不区分大小写(默认)
Linuxext4/xfs区分大小写

对于必须跨平台运行的路径比较,请使用 StringComparison.OrdinalIgnoreCase。不要假设大小写行为——如有需要,请在运行时检查。

UnixFileMode(.NET 7+)
// Set POSIX permissions on file creation (Linux/macOS only)
await using var fs = new FileStream(path, new FileStreamOptions
{
    Mode = FileMode.Create,
    Access = FileAccess.Write,
    UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite
    // 0600 -- owner read/write only
});

在 Windows 上,UnixCreateMode 会被静默忽略。不要在 Windows 上将其用作安全控制——请改用 ACL 或 Windows 安全 API。

文件锁定
平台锁类型行为
Windows强制锁其他进程无法读写被锁定的区域
Linux建议锁(flock/fcntl)锁定是协作式的——进程可以忽略锁
macOS建议锁(flock)与 Linux 相同——协作式

FileShare 标志用于控制 Windows 上的共享行为。在 Linux/macOS 上,可使用 FileStream.Lock 进行建议锁定,但请注意,不遵循协作约定的进程可以绕过锁定。


错误处理

IOException 层次结构
IOException
  +-- FileNotFoundException
  +-- DirectoryNotFoundException
  +-- PathTooLongException
  +-- DriveNotFoundException
  +-- EndOfStreamException
  +-- FileLoadException
特定情况的 HResult 代码
try
{
    await using var fs = new FileStream(path,
        FileMode.Open, FileAccess.Read);
    // ...
}
catch (IOException ex) when (ex.HResult == unchecked((int)0x80070070))
{
    // ERROR_DISK_FULL (Windows) -- disk full
    logger.LogError("Disk full: {Path}", path);
}
catch (IOException ex) when (ex.HResult == unchecked((int)0x80070020))
{
    // ERROR_SHARING_VIOLATION -- file locked by another process
    logger.LogWarning("File locked: {Path}", path);
}
catch (UnauthorizedAccessException ex)
{
    // Permission denied (not an IOException subclass)
    logger.LogError("Access denied: {Path}", path);
}
磁盘已满时的刷新行为

写入操作可能会成功,但数据仍缓存在内存中。磁盘已满的情况可能会在调用 Flush 或 Dispose 时才显现,而不是在调用 Write 时显现。刷新时务必检查是否抛出异常。

await using var fs = new FileStream(path,
    FileMode.Create, FileAccess.Write,
    FileShare.None, 4096, FileOptions.Asynchronous);

await fs.WriteAsync(data, cancellationToken);

// IOException (disk full) may throw here, not at WriteAsync
await fs.FlushAsync(cancellationToken);

缓冲区大小

基于 dotnet/runtime 基准测试和内部 FileStream 实现的建议:

文件操作建议的缓冲区原因
小文件顺序读取(< 1 MB)4 KB(默认值)与 OS 页面大小一致;FileStream 默认值
大文件顺序读取(> 1 MB)64--128 KB摊销系统调用开销;超过 128 KB 后收益递减
网络附加存储(NFS/SMB)64 KB较大的缓冲区可摊销网络往返开销
SSD 随机访问4 KB与 SSD 页面大小一致;较大的缓冲区会浪费预读资源
FileStream 顺序扫描4 KB + FileOptions.SequentialScanOS 预读可处理数据预取

FileOptions.SequentialScan 会提示 OS 预取读取位置之后的数据。它有利于顺序读取,但可能降低随机访问模式下的性能。


Agent 注意事项

  1. 不要在未设置 useAsync: true 的情况下使用 FileStream 异步方法 —— 未启用异步标志时,ReadAsync/WriteAsync 会将同步 I/O 分派到线程池,导致线程阻塞并增加开销。始终传入 useAsync: true 或 FileOptions.Asynchronous。
  2. 不要将 Path.Combine 与不可信输入一起使用 —— 如果第二个参数是根路径,Path.Combine 会静默丢弃基础路径,从而导致路径遍历。请使用 Path.Join(.NET Core 2.1+),并验证解析后的路径位于预期的基础目录下。
  3. 不要使用 Path.GetTempFileName() —— 它会创建可预测的文件名,并在文件数达到 65,535 时抛出异常。请使用 Path.GetRandomFileName() 和 FileMode.CreateNew,以安全、原子地创建临时文件。
  4. 不要忽略 FileSystemWatcher 的重复事件 —— 编辑器和工具可能会因一次逻辑更改触发多个事件。请使用计时器或 Channel 节流来实现防抖。
  5. 不要仅依赖 FileSystemWatcher 来可靠地检测更改 —— 缓冲区溢出会导致事件静默丢失。请处理 Error 事件,并将定期重新扫描作为后备方案。
  6. 不要假设文件锁定在 Linux/macOS 上是强制性的 —— 在 Unix 上,FileStream.Lock 和 FileShare 标志使用建议锁定,不遵守协议的进程可以绕过它。请据此设计协议。
  7. 不要只捕获 IOException 而忽略 UnauthorizedAccessException —— 权限错误会抛出 UnauthorizedAccessException,它不继承自 IOException。请在文件访问错误处理中同时处理这两种异常。
  8. 不要假设 WriteAsync 会立即报告磁盘已满错误 —— 数据可能已缓冲。磁盘已满导致的 IOException 可能会在 FlushAsync 或 Dispose 时出现。请始终处理刷新时的异常。

参考资料

评论

支持 Markdown