Sparkdo.VirtualFileSystem 3.0.0-preview.1

This is a prerelease version of Sparkdo.VirtualFileSystem.
dotnet add package Sparkdo.VirtualFileSystem --version 3.0.0-preview.1
                    
NuGet\Install-Package Sparkdo.VirtualFileSystem -Version 3.0.0-preview.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Sparkdo.VirtualFileSystem" Version="3.0.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Sparkdo.VirtualFileSystem" Version="3.0.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="Sparkdo.VirtualFileSystem" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Sparkdo.VirtualFileSystem --version 3.0.0-preview.1
                    
#r "nuget: Sparkdo.VirtualFileSystem, 3.0.0-preview.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Sparkdo.VirtualFileSystem@3.0.0-preview.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Sparkdo.VirtualFileSystem&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Sparkdo.VirtualFileSystem&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Tool

Sparkdo.VirtualFileSystem

Sparkdo 虚拟文件系统的运行时包:把程序集嵌入资源与物理目录按声明装配成一张经过校验的目录,在首次解析时一次完成校验与物化,再以标准 Microsoft.Extensions.FileProviders.IFileProvider 交给生态(静态文件中间件、配置、模板引擎等),并附一个元数据出口 IVirtualFileCatalog。AddSparkdoVirtualFileSystem() 一行接线。

三条硬承诺(都在只 PackageReference 包体的本地 feed 探针上实测,含端到端 HTTP):

  • 接进生态是真的能用——UseStaticFiles(new StaticFileOptions { FileProvider = …, RequestPath = "/assets" }) 后 /assets/plain.txt 200、/assets/no-such.txt 404、If-None-Match 命中 304;开发期 live: true 集合改盘后下一次请求就是新内容,快照集合改盘后仍是启动时那一份。
  • 命中路径零分配——快照文件命中、纯快照目录列举、Watch、TryGetDeclaration 实测 0 B/次(27–33 ns 量级),且与文件集规模无关(200 文件集与嵌入式集同价);未命中 1 个 24 B 占位对象;快照读流只多一个 64 B 的 MemoryStream 包装,零缓冲拷贝。
  • 失败关闭且一次报全——路径不规范、目录前缀冲突、资源缺失、越界/reparse point/硬链接、预算超限,全部在 Prepare 阶段以 VFS 码拒绝;校验层收集全部问题一次性抛出,配置错误一轮修完。

目录

快速开始

A. 最小可用:把一批嵌入模板读出来

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Sparkdo.VirtualFileSystem;

var services = new ServiceCollection();
services.AddSparkdoVirtualFileSystem(options => options.AddEmbedded<MyModuleMarker>());
using var container = services.BuildServiceProvider();

var provider = container.GetRequiredService<IFileProvider>();
var file = provider.GetFileInfo("Templates/email/welcome.html");
if (!file.Exists)
{
    throw new InvalidOperationException("模板缺失。");
}

using var reader = new StreamReader(file.CreateReadStream());
Console.WriteLine(await reader.ReadToEndAsync());

标记类型只用来定位程序集:AddEmbedded<TMarker>() 缺省以 typeof(TMarker).Namespace 作为资源名前缀。上面那条形如 MyModule.Templates.email.welcome.html 的清单资源名会映射成逻辑路径 Templates/email/welcome.html。

B. 接静态文件中间件(最常见的生产用法)

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Sparkdo.VirtualFileSystem;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSparkdoVirtualFileSystem(options =>
{
    options.AddEmbedded<MyModuleMarker>();                       // 随程序发布的资源
    options.AddPhysical(Path.Combine(builder.Environment.ContentRootPath, "files"));  // 部署后可替换的目录
});

var app = builder.Build();

// 直接把虚拟文件挂到一个前缀下;FileProvider 由本包注册。
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = app.Services.GetRequiredService<IFileProvider>(),
    RequestPath = "/assets",
});

实测(同一份内容分别经 StaticFileMiddleware 与 PhysicalFileProvider 对照):

请求 本产品 备注
GET /assets/plain.txt 200,Content-Length: 19,ETag: "19db1ded53e8013" 嵌入式快照
GET /assets/Templates/email/welcome.html 200,type=text/html 内容类型由扩展名推断,正常生效
GET /assets/docs/note.txt 200(改盘后仍返回启动时的 SNAPSHOT NOTE) 快照不可变
GET /assets/hot.txt(live: true 集合) 200 v1 → 改盘后同一容器同一中间件立即 200 v2 after disk write,ETag 随之改变 Live 实时读盘
GET /assets/no-such.txt 404
GET /assets/PLAIN.TXT 404 序数比较、区分大小写(PhysicalFileProvider 在 Windows 上是 200)
GET /assets/../etc/passwd、/assets/%2e%2e/plain.txt 404
HEAD /assets/plain.txt 200,长度正确
GET 带 Range: bytes=0-4 206,body=hello,Accept-Ranges: bytes 快照流是 MemoryStream,中间件的 Range 处理照常生效
GET 带 If-None-Match 304 ETag 生成,条件请求可用
GET /assets/.hidden.txt(dotfile,.txt 类型可识别) 200,type=text/plain 本域不隐藏 dotfile——它真的被发出去了
GET /assets/.env(dotfile,扩展名不在内容类型表里) 404 拦下来的是"未知扩展名"(ServeUnknownFileTypes=false),不是 dotfile 规则;同名文件换个可识别扩展名就是 200
响应头 Last-Modified 不输出 快照时间戳恒为 1970-01-01,被中间件当作"未知"过滤
GET /assets/docs/(目录) 404 本包不提供目录默认文档

关键点:中间件传给 IFileProvider 的 subpath 恒带前导 /(/plain.txt)。本包的查找面已按生态惯例容忍恰好一个前导斜杠;这一点是本轮实测发现的缺陷并修好的——不要假设"宿主自己会去掉斜杠"。

C. 开发期热覆盖:先物理、后嵌入

builder.Services.AddSparkdoVirtualFileSystem(options =>
{
    // 注册顺序就是优先级:先注册的赢。
    options.AddPhysical(Path.Combine(builder.Environment.ContentRootPath, "AppFiles"), live: true);
    options.AddEmbedded<MyModuleMarker>();
});

AppFiles/Templates/email/welcome.html 存在时覆盖同名嵌入资源,且改盘即生效;文件不存在时嵌入资源照常命中。生产环境把这一行去掉即可回到"只有嵌入资源"。

D. 显式声明 + 摘要见证(发布物完整性)

using System.Security.Cryptography;
using Sparkdo.VirtualFileSystem;

options.AddEmbeddedFile(
    typeof(MyModuleMarker).Assembly,
    resourceName: "MyModule.Data.2026.report.xlsx",           // 清单资源原名
    logicalPath: "reports/2026.Q3.final.xlsx",                // 想叫什么就叫什么
    contentDigest: new ContentDigest(
        ContentDigestAlgorithm.Sha256,
        Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes("build/final.xlsx"))).ToLowerInvariant()));

给了见证就必须一致,Prepare 实测不符时抛:虚拟文件系统准备失败:VFS2002 文件内容与声明的 SHA-256 摘要不一致。

E. 只要元数据:查这条文件从哪来

using Sparkdo.VirtualFileSystem;

var catalog = container.GetRequiredService<IVirtualFileCatalog>();

if (catalog.TryGetDeclaration("Templates/email/welcome.html", out var declaration))
{
    Console.WriteLine(declaration.Source);           // EmbeddedFileSource { AssemblyIdentity = …, ResourceName = … }
    Console.WriteLine(declaration.Exposure);         // Snapshot
    Console.WriteLine(declaration.ContentDigest);    // ContentDigest { Algorithm = Sha256, Value = ed396b77… }
}

catalog.Declarations.Count;                          // 全量声明,按逻辑路径序数排序

能做什么、不做什么

做 不做
把嵌入资源 + 物理目录统一成一张按逻辑路径寻址的目录 写文件、创建目录、删除、上传——只读,没有任何写入 API
Prepare 阶段一次校验(路径、冲突、来源、见证)并失败关闭 运行期热重载目录;文件集在首次解析时冻结
不可变快照(内容驻留内存、零冗余、多读共享同一缓冲) 内容缓存/TTL/失效策略(快照本身就是缓存,改盘不回头)
live: true 物理集合的实时读盘(开发期覆盖) 变更通知/文件监视(Watch 令牌永不变更,Live 靠每次打开反映磁盘)
SHA-256 摘要见证:显式声明强校验,便捷注册现算并记入目录 签名、验签、加密、防篡改证明——摘要只是内容等同性见证
物理来源的句柄级验证(越界、reparse point、硬链接、跨设备) 权限模型/ACL;不是"安全沙箱",只是"打开的就是解析出来的那个文件"
标准 IFileProvider 出口 + IVirtualFileCatalog 元数据出口 自己的门面 API;不提供 ReadAllText 之类的业务便捷层(只有三个存在性/列举扩展)
单文件 64 MiB / 总快照 256 MiB / 4096 条的物化预算硬边界 预算可调——上限属实现安全边界,不由配置覆盖
序数、区分大小写的跨平台一致路径语义 大小写折叠、区域设置敏感比较、\ 作为分隔符
AOT/裁剪友好(无反射激活、FrozenDictionary、[LibraryImport] 源生成 P/Invoke) 非 Windows/非 x64-Linux 平台上的物理来源(那些平台上失败关闭)

先做三个选型判断

① 内容该来自嵌入还是物理?

你的处境 选 写法
文件随程序集发布、部署后不会变(模板、默认配置、静态片段) 嵌入式 AddEmbedded<TMarker>() 或 AddEmbedded(assembly, baseNamespace, baseFolder)
部署后可运维替换的文件、体积大、或数量在启动前不确定 物理快照 AddPhysical(root)
开发期要改了就生效 物理 Live AddPhysical(root, live: true),并排在嵌入之前
单条文件要改名/要见证 显式声明 AddEmbeddedFile(...) / AddPhysicalFile(...)

② Snapshot 还是 Live? Snapshot 在 Prepare 一次性读入内存,此后与磁盘无关:读得极快(0 分配)、内容不可能被盘上变化影响、但改了盘必须重启进程。Live 每次打开都走句柄级验证读盘:反映当前磁盘(实测改盘后下一次 HTTP 请求即新内容),代价是每次打开 ~0.7 ms 量级 + 2 KB 分配,不要放进生产热路径。嵌入式来源只能 Snapshot。

③ 什么时候不该用本域? 要写入/上传→ 用 blob-storing;要按 key 缓存任意字节并带 TTL → 用 caching;要读的是配置项而不是文件 → 用宿主配置系统;要的是"文件随时间戳变化"的缓存语义 → 本包的快照 LastModified 恒为 1970,帮不了你;要监视目录变化并回调 → 本包不提供(Watch 永不变更)。

安装与宿主侧依赖面

dotnet add package Sparkdo.VirtualFileSystem

包依赖面(实测 nuspec):Sparkdo.VirtualFileSystem 的 net11.0 依赖组只有一项——Sparkdo.VirtualFileSystem.Abstractions(同版本)。宿主侧另需:

你想做的事 额外要引的包 依据(实测)
注册文件集、解析 IFileProvider/IVirtualFileCatalog、读内容 无 IFileProvider、IChangeToken、Microsoft.Extensions.Options、DependencyInjection.Abstractions 均由 net11.0 共享框架提供
new ServiceCollection() + BuildServiceProvider()(控制台/测试) Microsoft.Extensions.DependencyInjection 容器实现不在共享框架内
ASP.NET Core 站点 FrameworkReference Microsoft.AspNetCore.App(Web SDK 已带) 中间件在 ASP.NET 共享框架
与物理/组合提供器对接 Microsoft.Extensions.FileProviders.Physical / .Composite 这些实现类不在共享框架内

上面第一行是实测出来的:一个只 PackageReference Sparkdo.VirtualFileSystem 的 net11.0 工程,不加任何 Microsoft.Extensions.* 包,即可编译并通过 GetRequiredService<IFileProvider>() 读到文件(容器实现除外,它需要第二行的包)。

AddSparkdoVirtualFileSystem() 注册了什么

一次调用做 5 件事,全部是 TryAdd 语义(实测逐条):

注册 生命周期 实现 让位语义
AddOptions<SparkdoVirtualFileSystemOptions>() + configure — — 每次调用都追加自己的 configure,因此多次调用会累积文件集
PreparedVirtualFiles Singleton 内部类型 TryAdd;重复调用描述符数恒为 1
IVirtualFileProvider Singleton 内部 VirtualFileProvider TryAdd;标记接口总是能解析到本产品实现
IFileProvider(工厂 → 同一个 IVirtualFileProvider 实例) Singleton 工厂 TryAdd;宿主先注册的标准接口不被覆盖
IVirtualFileCatalog Singleton 内部 DefaultVirtualFileCatalog TryAdd

实测矩阵:

场景 结果
只调 AddSparkdoVirtualFileSystem() IFileProvider 与 IVirtualFileProvider 是同一实例;空配置也 Prepare 成功(空目录)
先 AddSingleton<IFileProvider>(mine) 再 Add 标准接口解析到 mine;本产品从 IVirtualFileProvider 取,仍完整可用
先 Add 再 AddSingleton<IFileProvider>(mine) 标准接口解析到 mine(后注册者胜),IVirtualFileProvider 仍是本产品实现
连调两次 Add(各带自己的 configure) 两批文件集都生效;四个服务描述符各 1 条,不翻倍
首次解析抛 VirtualFileSystemPreparationException 后再解析 每次解析都抛同一类型,异常不被 DI 包装(InnerException 为空)

Options 逐项语义

SparkdoVirtualFileSystemOptions 是公共类型,可以 new,但除了容器路径没有别的消费者(提供器与目录实现都是内部类型)——手工 new 只能用来攒配置,然后交给 AddSparkdoVirtualFileSystem(configure)。

方法 参数 语义与实测细节
AddEmbedded<TMarker>(baseNamespace?, baseFolder?) baseNamespace 缺省取 typeof(TMarker).Namespace;null 或空串=不过滤(全程序集清单资源参与) 枚举 → 按约定映射 → 收集候选;映射后仍不规范的资源让整次 Prepare 失败关闭(VFS1003),不是跳过该资源
AddEmbedded(assembly, baseNamespace?, baseFolder?) 同上,程序集显式给 assembly 为 null → ArgumentNullException(assembly)(入口即抛,不进 Prepare)
AddEmbeddedFile(assembly, resourceName, logicalPath, contentDigest?) 单条显式声明,不做枚举 resourceName/logicalPath 空白 → ArgumentException(参数名同名);给了 contentDigest 就强校验(VFS2002);logicalPath 合法性在 Prepare 判
AddPhysical(rootPath, live = false) rootPath 必须是绝对路径;live 决定该集合的暴露模式 递归枚举普通文件(跳过 ReparsePoint,以及仅 Windows 上的 Hidden/System——Unix 把点开头文件名映射成 Hidden,按属性跳过会静默漏掉 .env 这类文件;不递归进 reparse point 目录);逻辑路径=根内相对路径(\→/)
AddPhysicalFile(rootPath, relativePath, contentDigest?, live = false) 单条显式声明,不枚举 逻辑路径恒等于 relativePath——物理集合没有改名能力(baseFolder 也只对嵌入集合有效)

五个方法都返回 this,可链式调用。

入口参数校验(同步抛,不进 Prepare)与 Prepare 校验(VFS 码)是两层,实测对照:

options.AddPhysical("   ");                       // ArgumentException(rootPath):The value cannot be an empty string…
options.AddPhysicalFile(root, "");                // ArgumentException(relativePath)
options.AddEmbeddedFile(assembly, " ", "a.txt");  // ArgumentException(resourceName)
options.AddEmbedded(null!);                        // ArgumentNullException(assembly)

options.AddPhysical("relative-root");              // Prepare → VFS3004 物理文件根必须是已声明的绝对路径。
options.AddPhysical(rootWithSpacesAround);         // Prepare → VFS3004 物理文件根目录不存在或无法枚举。(枚举先失败,轮不到 VFS1007)
options.AddPhysicalFile(rootWithSpacesAround, "a.txt");   // Prepare → VFS1007 物理文件来源必须指定稳定的根路径。

一次 Prepare 的固定顺序

Prepare 在首次解析 IVirtualFileProvider / IVirtualFileCatalog / IFileProvider 时执行一次(实测:BuildServiceProvider() 不触发;首次解析之后新增到磁盘的文件不再可见)。

首次解析
  ├─ 读 Options 快照(按注册顺序遍历文件集)
  ├─ 逐集合收集候选声明
  │    ├─ 嵌入式:枚举清单资源并按约定映射;映射后非规范 → VFS1003 立即失败关闭
  │    │         (程序集无 FullName → VFS4002;同 identity 绑不同程序集 → VFS4005)
  │    └─ 物理:递归枚举(根不存在/不可枚举 → VFS3004);相对路径非规范 → VFS1003
  ├─ 集合内去重(VFS1004,防御性)+ 跨集合 first-wins 裁决(后来者静默让位)
  ├─ CatalogValidator:一次收集全部问题(条目超限 VFS3013 直接短路返回)
  │    ├─ 路径规范 VFS1003 / 重复 VFS1004 / 摘要格式 VFS1005 / 来源 VFS1007~1013 / 暴露枚举 VFS1015
  │    └─ 目录前缀冲突 VFS1016(跨集合也要查,所以「让位后再校验」)
  ├─ 逐条物化(**按胜出顺序**,任一条失败只报那一条)
  │    ├─ 嵌入式:打开资源(缺失 VFS4003,读失败 VFS4004)→ 预算内读取(VFS3011)→ 见证比对(VFS2002)
  │    ├─ 物理快照:路径解析 + 句柄级验证(VFS3004/3006/3007)→ 读取 → 见证比对 → 驻留字节数组
  │    └─ 物理 Live:同上但读完即关句柄,只留见证与绝对路径
  ├─ 每条累计快照字节并核对总预算(VFS3012)
  └─ 构建 FrozenDictionary 文件表 + ImmutableArray 声明表;提供器构造期再建
       快照文件信息缓存与目录树索引(此后读路径只查表)

一次查找的固定顺序

GetFileInfo(subpath)
  ├─ 宽松规范化:容忍恰好一个前导 /;拒绝空串、`\`、盘符、空段、. 与 .. 段、控制字符、非 NFC
  ├─ 命中快照信息缓存(1 次查表)    → 返回预构建的 VirtualFileInfo(0 分配)
  ├─ 命中文件表但它是 Live           → 新建 VirtualLiveFileInfo:做一次句柄级验证探测(存在性 + 长度)
  └─ 都不命中                        → new VirtualNotFoundFileInfo(subpath)(1 个 24 B 占位,Name 直接引用入参串)

GetDirectoryContents(subpath)
  ├─ 宽松规范化(目录形式额外容忍一个尾随 /)
  ├─ 目录索引查不到 / 路径非法        → 共享的 NotFound 单例(Exists=false,可安全枚举,0 分配)
  ├─ 纯快照目录                      → 预构建的不可变列举(同一实例,0 分配)
  └─ 含 Live 文件的目录              → 每次重建:静态子项 + 每个 Live 文件逐个句柄探测 → 按 Name 序数排序

Watch(filter) 忽略入参(null 也接受),恒返回同一个不可变令牌。

两套路径规则:声明侧严、查找侧宽

同一字符串在两个面上被不同标准对待。这不是不一致,是刻意的:声明侧要保证目录本身可复现(跨平台、跨宿主),查找侧要顺从生态调用方的形状。

形状 声明侧(进目录) 查找侧(GetFileInfo/GetDirectoryContents/TryGetDeclaration)
Templates/a.txt 合法 命中
/Templates/a.txt VFS1003 命中(容忍恰好一个前导 /)
Templates/(目录形式) VFS1003 GetDirectoryContents 命中;GetFileInfo 未命中
//Templates/a.txt、///x VFS1003 未命中(不抛)
a/../b.txt、./a、a//b、a/ VFS1003 未命中
back\slash.txt VFS1003 未命中(\ 永不是分隔符)
C:/x、c:\x VFS1003 未命中
CON、con.txt、COM1、LPT1、CLOCK$、CONIN$ VFS1003(Windows 保留名) 未命中(目录里不可能有)
a<b、a>b、a:b、a"b、a|b、a?b、a*b VFS1003 未命中
.gitignore、config/.env 合法(dotfile 与 dotfile 目录都允许) 命中
.、..、...、a.、a␣(尾点/尾空格) VFS1003 未命中
控制字符(\t、\n、\u0001) VFS1003 未命中
非 NFC(e + U+0301 组合) VFS1003;NFC 形式 é.txt 合法 非 NFC 未命中
落单代理对 VFS1003 —
中文、空格内嵌、#、% 合法 命中

所有比较都是 StringComparison.Ordinal、区分大小写:实测 plain.txt 命中而 PLAIN.TXT 未命中;Templates 命中而 templates 未命中。这与 PhysicalFileProvider 在 Windows 上的大小写不敏感行为不同,迁移时注意。

嵌入式资源映射规则

剥离 baseNamespace 前缀后:最后一个 . 之后是扩展名,倒数第二个 . 之后是文件基名,其余 . 全部转 /。

实测映射表(同一程序集,baseNamespace = 标记类型命名空间):

清单资源名(去前缀后) 逻辑路径 说明
Templates.email.welcome.html Templates/email/welcome.html 标准形状
plain.txt plain.txt 无目录
deep.a.b.c.md deep/a/b/c.md 任意深度
app.config.json app/config.json 名字里带点会被拆成目录——你想要的是同级的 app.config.json,得到的是嵌套的 config.json
.hidden.txt hidden.txt 前导点被吃掉:dotfile 资源在自动映射下会丢名字
trail..txt trail/.txt 连续点产出一个以点开头的名字(形状已经变了)
noext (静默跳过) 无扩展名不参与映射
dot. dot. → 非法 尾部点使路径不规范 → VFS1003 失败关闭
Probe.Embedded.Happy..hidden.txt(baseNamespace=null 时) Probe/Embedded/Happy/.hidden.txt 同一个资源在前缀不同时命运不同:换个 baseNamespace 它就合法了

结论与纪律:

  1. 只要资源的目录名或基名里含 .,或它是 dotfile,就别依赖自动映射,走 AddEmbeddedFile(assembly, resourceName, logicalPath) 显式声明。
  2. baseNamespace 只是一个普通字符串前缀,不必真是某个类型的命名空间(实测 Probe.Explicit 这种前缀照样能用);不匹配时得到空集合而不是报错——接线写错最常见表现就是"全都 404"。
  3. 自动枚举里只要有一条资源映射后不规范,整次 Prepare 就失败关闭(VFS1003,Subject 是那串资源名)。用 baseNamespace: null(不过滤)时尤其危险:程序集里任何资源名都可能替你触发它。
  4. AddPhysical 没有这套规则:磁盘相对路径原样成为逻辑路径,archive.tar.gz 就是 archive.tar.gz,v1.2/read.me 就是 v1.2/read.me。这也是"含点文件名"更省心的来源。

跨来源语义

first-wins(先到先得):文件集按注册顺序裁决逻辑路径归属。实测两个方向都成立:物理先注册→物理内容胜出;嵌入先注册→嵌入内容胜出。后注册的同名条目静默让位,不报错、不警告。因此:

  • 每个 Add* 调用就是一个集合,"同一路径注册两次"的语义是让位(VFS1004 只是防御性守卫,公共注册面走不到)。
  • 让位发生在校验之前:所以「前缀冲突」(VFS1016)是跨集合也要查的——集合 A 有文件 docs、集合 B 有文件 docs/child.txt,即使二者都不重复也会失败关闭。实测跨集合场景确实报 VFS1016,Subject=docs。
  • 落选的集合不做物化、也不校验见证:实测给落选条目挂一个错摘要不会失败。别指望"错摘要能帮你发现重复注册"。

Snapshot 与 Live:默认 Snapshot。实测快照在 Prepare 后:改盘内容不变、Length 与摘要恒为启动时值、Prepare 之后新增的文件不出现、删除的文件仍在。Live 集合反过来:内容/长度实时、被删即 Exists=false(打开抛 FileNotFoundException:文件不存在:x.txt。)、被移走再放回立刻可读,但Prepare 之后新增的文件同样不出现——声明集在 Prepare 冻结。

摘要见证分级:见前述。补充两点实测细节:Live 文件在 Prepare 时会被完整读一次以算见证(所以它也受单文件 64 MiB 上限约束,超限 VFS3011),但之后不再计入快照内存;Declarations 与 TryGetDeclaration 给的是同一个声明对象,摘要两边一致(这是修过的缺陷:曾经 Declarations 返回物化前的声明,便捷注册的摘要在列表里是 null)。

IFileProvider 适配逐成员

本包给生态四种 IFileInfo。成员语义逐字实测:

成员 快照文件 Live 文件(存在) Live 文件(已被删) 未命中占位 目录项
Exists true true false false true
Length 快照字节数 当前磁盘长度 -1 -1 -1
PhysicalPath null(不暴露) 绝对路径(暴露) null null null
Name 逻辑路径最后一段 同左 同左 入参原串 目录段名
IsDirectory false false false false true
LastModified 1970-01-01T00:00:00+00:00 同左 1970-01-01T00:00:00+00:00 DateTimeOffset.MinValue 1970-01-01…
CreateReadStream() MemoryStream(只读、publiclyVisible:false,包共享缓冲,零拷贝) 真 FileStream(每次重新句柄验证) 抛 FileNotFoundException:文件不存在:{Name}。 抛 FileNotFoundException:文件不存在:{Name}。 抛 InvalidOperationException:目录不能创建读取流。

IDirectoryContents:命中给只读列举;未命中给共享单例(Exists=false,枚举为空但不抛)。空目录不会存在(目录由文件路径隐式派生),所以"存在的空目录"不会出现。

IChangeToken:单一实例,HasChanged 恒 false、ActiveChangeCallbacks 恒 false、RegisterChangeCallback 不执行回调并返回一个 no-op IDisposable(callback 传 null 抛 ArgumentNullException(callback))。

与 PhysicalFileProvider 的行为差异(实测,迁移时最容易踩):

场景 本产品 PhysicalFileProvider(Windows)
GetFileInfo("PLAIN.TXT") 未命中 命中
GetFileInfo("no-such.txt").Length -1(不碰磁盘) 抛 FileNotFoundException
GetFileInfo("docs")(目录) 未命中,Exists=false Exists=false 但 Length 抛、CreateReadStream 抛 UnauthorizedAccessException
GetFileInfo("a\\b.txt") 未命中 命中
GetFileInfo("a//b.txt") 未命中 命中(Path.Combine 吞掉多余斜杠)
GetFileInfo("exists.txt/") 未命中 Exists=true 且 Name="",打开流抛 IOException:目录名称无效
LastModified 恒定 真实磁盘时间戳
PhysicalPath 快照隐藏、Live 暴露 总是暴露绝对路径
未命中读 .Name 入参原串(与 BCL NotFoundFileInfo 同形:null 入参给 null Name,实测一致) 同左

便利扩展三方法

挂在标准 IFileProvider 上(VirtualFileProviderExtensions),对 CompositeFileProvider 等任何实现都可用——实测穿透组合提供器工作正常。

方法 命中 未命中 入参校验(实测)
GetFileInfoOrNull(subpath) IFileInfo null null → ArgumentNullException(subpath);空串/全空白 → ArgumentException(subpath)
GetDirectoryContentsOrNull(subpath) IDirectoryContents null null → ArgumentNullException(subpath);空白串不拦(返回 null)——与上一个方法的严格度不同,别指望它替你校验
GetAllFilesInFolder(folder) 递归文件列表(不含目录项) 目录不存在→空列表 null → ArgumentNullException(folder);空串=根;传文件路径→空列表

GetAllFilesInFolder 的顺序不是全局字典序:它是栈式深度优先,同一目录内按 Name 序数,跨子目录表现为"后进入的子目录先出来"。实测一个混合树给出 b.txt, c.txt, y.txt, z.txt(而根目录列举是 a, b.txt, c.txt)。要稳定顺序请自己 OrderBy(..., StringComparer.Ordinal)。Live 文件以列举时刻的探测结果为准,所以这个调用在含 Live 的目录上每次都真读盘。

物化预算与句柄级安全边界

预算(硬编码,不可调)

上限 值 实测边界
单文件 64 MiB(64 * 1024 * 1024) 恰好 64 MiB 通过;64 MiB + 1 字节 → VFS3011,Subject 是那条逻辑路径
快照总字节 256 MiB 4 × 60 MiB 通过;再加 20 MiB → VFS3012,Subject 是触发超限的那条
集合条目数 4096 恰好 4096 通过;4097 → VFS3013(Subject=null),且只报这一条、不再报其它问题

Live 文件不驻留内存,因此不计入总预算(实测同样 260 MiB 的 Live 集合通过),但每个 Live 文件在 Prepare 时仍被完整读一次算见证,所以单文件上限照样生效。

句柄级验证(物理来源)

打开的物理文件必须"就是解析出来的那个文件"。实测在 Windows 上:

场景 结果(逐字)
硬链接(同一 inode 两个名字) VFS3006 物理文件不能是硬链接。 — 两个名字都被拒(链接数都是 2)
路径中含 junction(目录 reparse point) VFS3006 物理文件路径不能包含 reparse point。
根目录本身就是 junction VFS3006 物理文件根不能是 reparse point。
符号链接文件被显式声明 VFS3006 物理文件路径不能包含 reparse point。
自动枚举遇到 junction 目录 静默跳过整个子树(AttributesToSkip 含 ReparsePoint),不报错、也不可见
相对路径逃逸(../x) VFS1003(声明校验先拦);VFS3005 是物化层的防御分支
UNC / 设备命名空间根(\\server\share、\\.\C:\x) VFS3004 物理文件根不能使用 UNC 或设备命名空间。(枚举形态先报 物理文件根目录不存在或无法枚举。)
根声明大小写与磁盘不一致 通过(Windows 上最终路径按不区分大小写比对)
根带尾分隔符 通过

平台面:Windows 走 CreateFileW + GetFileInformationByHandleEx + GetFinalPathNameByHandleW + 逐段 reparse 检查(不校验进程架构);Linux 走 openat2(RESOLVE_NO_SYMLINKS|BENEATH|NO_XDEV)+ fstat,仅 x64;其余平台(含 macOS/BSD、非 x64 Linux)物理来源失败关闭 VFS3006 当前平台无法从已验证的文件句柄解析物理文件目标。(这两条是代码路径结论,本轮只在 Windows 实测。)

每次打开的真实成本:根句柄 + 每个中间段一次元数据句柄 + 目标句柄 + 最终路径解析。这就是 Live 路径 ~0.7 ms/次 的来源(含杀软实时扫描干扰),也是 2000 文件快照集合 Prepare 约 1.3 s 的来源——不是反射、不是分配。

AOT 与裁剪

IsAotCompatible + IsTrimmable + Trim/SingleFile/AOT 分析器常开,dotnet build/dotnet pack 零诊断;清单资源读取、FrozenDictionary、[LibraryImport] 源生成 P/Invoke 都不需要运行时反射。注意:本轮未做真实 PublishAot 链接验证(本机缺 clang/lld-link),该结论来自分析器静态面。

典型用法配方

以下配方全部在本地 feed 探针(只 PackageReference 包体)里逐段编译运行过;含 Microsoft.AspNetCore.* 的配方另有一个 Web SDK 探针工程真起真请求。

1. 一个模块的默认模板 + 部署后可替换

builder.Services.AddSparkdoVirtualFileSystem(options =>
{
    options.AddPhysical(Path.Combine(builder.Environment.ContentRootPath, "files"), live: false);
    options.AddEmbedded<MyModuleMarker>();
});

files/ 下的文件覆盖同名嵌入资源;目录里没有的走嵌入。files/ 必须存在(实测不存在 → VFS3004),部署流程要保证建好空目录。

2. 开发期热覆盖,生产期纯快照

if (builder.Environment.IsDevelopment())
{
    builder.Services.AddSparkdoVirtualFileSystem(options =>
        options.AddPhysical(Path.Combine(builder.Environment.ContentRootPath, "AppFiles"), live: true));
}

builder.Services.AddSparkdoVirtualFileSystem(options => options.AddEmbedded<MyModuleMarker>());

Live 集合只在开发环境注册,且先于嵌入集合(先到先得)。

3. 多个模块互不干扰地共享一张目录

builder.Services.AddSparkdoVirtualFileSystem(options =>
{
    options.AddEmbedded<ModuleAFiles.Marker>(baseFolder: "a");
    options.AddEmbedded<ModuleBFiles.Marker>(baseFolder: "b");
    options.AddPhysical(sharedRoot);
});

baseFolder 给整个嵌入集合加逻辑路径前缀(实测 assets/Templates/email/welcome.html 这种形状)。用前缀隔开就不用担心跨模块撞名。

4. 宿主已有自己的 IFileProvider,手动组合

using Microsoft.Extensions.FileProviders;
using Sparkdo.VirtualFileSystem;

services.AddSingleton<IFileProvider>(wwwRootProvider);        // 我们的:会被让位
services.AddSparkdoVirtualFileSystem(options => options.AddEmbedded<MyModuleMarker>());

// 之后自行组合,顺序即优先级:
var composite = new CompositeFileProvider(
    provider.GetRequiredService<IVirtualFileProvider>(),       // 从标记接口取本产品
    wwwRootProvider);
app.UseStaticFiles(new StaticFileOptions { FileProvider = composite });

实测:IFileProvider 解析到的仍是宿主的 wwwRootProvider,IVirtualFileProvider 解析到本产品实现,两者可自由组合。

5. 递归拿到某个前缀下的全部文件(例如批量注册表单模板)

using Sparkdo.VirtualFileSystem;

var provider = scope.ServiceProvider.GetRequiredService<IFileProvider>();

foreach (var file in provider.GetAllFilesInFolder("Templates").OrderBy(static f => f.Name, StringComparer.Ordinal))
{
    using var stream = file.CreateReadStream();
    // ……
}

顺序不保证(见便利扩展一节),要稳定就自己排序。

6. 启动期自检:把配置错误从第一个请求搬到启动期

using Microsoft.Extensions.FileProviders;
using Sparkdo.VirtualFileSystem;

try
{
    _ = provider.GetRequiredService<IFileProvider>();      // 主动触发 Prepare
}
catch (VirtualFileSystemPreparationException exception)
{
    foreach (var issue in exception.Issues)
    {
        logger.LogError("虚拟文件系统 {Code}:{Message}({Subject})",
            issue.Code, issue.Message, issue.Subject ?? "-");
    }

    throw;
}

AddSparkdoVirtualFileSystem() 本身什么都不校验(它只是攒注册),错误一律在首次解析时集中爆出来。宿主若用 IHostedService/IStartupFilter 之外的懒解析,请把这一步显式加在启动流程里。

7. 完整性把关:给发布物挂摘要见证

using System.Security.Cryptography;
using Sparkdo.VirtualFileSystem;

// 构建期算好并写进清单/常量
var digest = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(distFile))).ToLowerInvariant();

options.AddPhysicalFile(distRoot, "data/region.json", new ContentDigest(ContentDigestAlgorithm.Sha256, digest));

实测不符即失败关闭;大小写写错、长度不足 64、含非十六进制字符 → VFS1005(格式问题先于内容比对被拒)。

8. 测试里不起宿主

var services = new ServiceCollection();
services.AddSparkdoVirtualFileSystem(options => options.AddPhysical(tempRoot));
using var container = services.BuildServiceProvider();
var provider = container.GetRequiredService<Microsoft.Extensions.FileProviders.IFileProvider>();

容器是唯一的取用路径(提供器实现是内部类型,new 不出来),所以测试里也走这三行;AddPhysical 的根必须真实存在。

9. 读文本/字节的正确姿势(避免二次拷贝)

var file = provider.GetFileInfoOrNull("data/region.json");
if (file is null)
{
    return;
}

// 快照:MemoryStream 直接包共享缓冲,读它不额外拷贝内容
using var stream = file.CreateReadStream();
using var reader = new StreamReader(stream);
var text = reader.ReadToEnd();

先判 Exists(或用 GetFileInfoOrNull)再打开;未命中直接打开会抛 FileNotFoundException。

10. 问出"这个路径到底来自哪个程序集/哪个目录"

if (catalog.TryGetDeclaration("data/region.json", out var declaration))
{
    var origin = declaration.Source switch
    {
        EmbeddedFileSource embedded => $"嵌入资源 {embedded.ResourceName}({embedded.AssemblyIdentity})",
        PhysicalFileSource physical => $"磁盘 {Path.Combine(physical.RootPath, physical.RelativePath.Replace('/', Path.DirectorySeparatorChar))}",
        _ => "未知来源",
    };
    Console.WriteLine($"{origin},暴露={declaration.Exposure},摘要={declaration.ContentDigest?.Value}");
}

TryGetDeclaration 也接受带前导 / 的入参(实测命中,且返回的声明里 LogicalPath 是规范化后的形状)。

11. 诊断"全都是 404":先确认命名空间前缀对不对

var count = catalog.Declarations.Count;
if (count == 0)
{
    // baseNamespace 不匹配时得到空集合而不是异常。打印真实资源名对齐前缀:
    foreach (var name in typeof(MyModuleMarker).Assembly.GetManifestResourceNames())
    {
        Console.WriteLine(name);
    }
}

实测:前缀不匹配 → 空目录、Declarations.Count == 0、所有读取 Exists=false。

12. 别把预算当配置项

// 超限是失败关闭,不是截断,也不是降级:
// 单文件 64 MiB → VFS3011;快照总量 256 MiB → VFS3012;条目 4096 → VFS3013
var huge = Path.Combine(root, "video.mp4");           // 80 MiB
options.AddPhysicalFile(root, "video.mp4");           // Prepare → VFS3011 文件内容超过单文件物化上限。

需要托管大文件请用 blob-storing;需要"偶尔读一次的大文件"就让它留在物理目录之外,由宿主自己的 IO 处理。

异常一览

抛出点 类型(参数名) 逐字消息/说明
AddEmbedded(null) / AddEmbeddedFile(null, …) ArgumentNullException (assembly) Value cannot be null. (Parameter 'assembly')
AddEmbeddedFile(…, resourceName: " ", …) ArgumentException (resourceName) The value cannot be an empty string or composed entirely of whitespace. (Parameter 'resourceName')
AddEmbeddedFile(…, logicalPath: " ", …) ArgumentException (logicalPath) 同上形状
AddPhysical(" ") / AddPhysicalFile(" ", …) ArgumentException (rootPath) 同上形状
AddPhysicalFile(…, relativePath: "") ArgumentException (relativePath) 同上形状
AddSparkdoVirtualFileSystem(null) ArgumentNullException (services) Value cannot be null.
Prepare 失败(首次及之后每次解析) VirtualFileSystemPreparationException(派生自 InvalidOperationException) 单条:虚拟文件系统准备失败:{Code} {Message};多条:虚拟文件系统准备失败(共 N 个问题): + 每行 - {Code} {Message}({Subject})
手工构造异常时传 null 列表 ArgumentNullException (issues) 本轮修过:以前是 NullReferenceException
手工构造异常时传空列表 不抛 消息 虚拟文件系统准备失败:未知错误。
GetFileInfo/GetDirectoryContents 传 null 或非规范形状 不抛 返回未命中占位(与 BCL NotFoundFileInfo 同形)
catalog.TryGetDeclaration(null) ArgumentNullException (logicalPath) 传空串是 ArgumentException
NotFound/被删 Live 文件 CreateReadStream() FileNotFoundException 文件不存在:{Name}。
目录项 CreateReadStream() InvalidOperationException 目录不能创建读取流。
GetFileInfoOrNull(null/空白) ArgumentNullException/ArgumentException (subpath) 见便利扩展表
GetAllFilesInFolder(null) ArgumentNullException (folder)
Watch(null) 不抛 返回同一令牌
RegisterChangeCallback(null, …) ArgumentNullException (callback)
快照文件超预算/句柄验证失败/资源缺失 全部收敛成 VFS 码,由 VirtualFileSystemPreparationException 携带 不逃逸裸异常

错误码一览

逐字消息 + 触发点 + 公共注册面可达性("可达"= 本轮探针或仓库用例实测触发过;"防御"= 代码路径存在但公共面构造不出)。

码 逐字消息 触发点 可达性
VFS1000 文件声明不能为空。 声明列表含 null 防御
VFS1003 逻辑路径必须是 NFC、使用 / 且不含根、空段、. 或 .. 段的相对路径。 显式声明的 logicalPath 不规范 可达(Subject=该路径)
VFS1003 嵌入式资源无法映射为规范逻辑路径。 自动枚举的资源映射后不规范 可达(Subject=资源名)
VFS1003 物理文件无法映射为规范逻辑路径。 自动枚举的文件相对路径不规范 可达(Subject=相对路径)
VFS1004 同一集合中的逻辑路径不能重复。 同一文件集内重复路径 防御(公共面构造不出)
VFS1005 内容摘要必须是 64 位小写 SHA-256 十六进制值。 显式见证格式不合(大写/长度≠64/非 hex) 可达
VFS1007 物理文件来源必须指定稳定的根路径。 RootPath 带首尾空格(显式声明路径) 可达
VFS1008 物理文件相对路径必须满足逻辑路径规范。 RelativePath 非规范 防御(VFS1003 先拦)
VFS1009 嵌入式文件来源必须指定程序集 identity 和资源名称。 来源字段空白 防御(入口已校验)
VFS1010 嵌入式文件只能使用 Snapshot 暴露。 嵌入式来源配 Live 防御(公共面恒 Snapshot)
VFS1013 文件声明必须指定受支持的文件来源。 / 文件声明包含未知的文件来源。 Source 为 null 或未知子型 防御
VFS1015 文件暴露模式必须是已支持的枚举值。 Exposure 不是 Snapshot/Live 防御
VFS1016 同一集合中的逻辑路径不能既是文件又充当其他路径的目录前缀。 docs 与 docs/child.txt 并存(同/跨集合) 可达(Subject=冲突祖先)
VFS2002 文件内容与声明的 SHA-256 摘要不一致。 显式见证与内容不符(嵌入/物理快照/物理 Live 都实测) 可达
VFS3004 物理文件根目录不存在或无法枚举。 AddPhysical 枚举失败(不存在/是文件/UNC/空格) 可达
VFS3004 物理文件根必须是已声明的绝对路径。 相对根(且目录存在) 可达
VFS3004 物理文件根不能使用 UNC 或设备命名空间。 \\server\share、\\.\C:\x 可达
VFS3004 物理文件根目录不存在或无法打开。 显式声明时根句柄开失败 可达
VFS3004 物理文件根必须是可验证的目录。 / 物理文件根必须是可验证且不包含符号链接的目录。 根不是目录(Linux 形态) 防御/未在本机实测
VFS3005 物理文件相对路径无法安全解析。 / 物理文件相对路径不能逃离已声明的根目录。 物化层路径解析 防御
VFS3006 物理文件不能是硬链接。 Windows 句柄链接数≠1 可达
VFS3006 物理文件路径不能包含 reparse point。 路径段是 junction/符号链接 可达
VFS3006 物理文件根不能是 reparse point。 根本身是 junction 可达
VFS3006 物理文件句柄的最终目标越过了已声明根目录或包含 reparse point。 最终路径校验失败 防御(越界已被上游拦住)
VFS3006 物理文件不能是硬链接或跨越已声明根目录所在的文件系统。 Linux fstat 校验 代码路径(本轮未测 Linux)
VFS3006 当前平台无法从已验证的文件句柄解析物理文件目标。 非 Windows 且非 Linux 代码路径
VFS3006 无法验证物理文件路径段的 reparse point 状态。 / 无法从已验证的根目录句柄解析物理文件路径。 段元数据/最终路径查询失败 防御
VFS3007 物理文件不存在。 显式声明的目标不存在,或指向一个目录(Windows 上目录的元数据句柄开不出来,两种都归这条;实测 AddPhysicalFile(root, "sub") 且 sub 是目录 → 就是这条) 可达
VFS3007 物理文件不存在或不是可读取的普通文件。 句柄属性判定分支(Windows 上通常被上一条抢先命中;Linux 路径给出这条) 代码路径(本轮未单独复现)
VFS3007 无法打开已验证的物理文件句柄。 / 无法从已验证的物理文件句柄读取内容。 打开或读取异常 防御
VFS3007 物理文件路径段必须是目录。 中间段是文件 防御
VFS3011 文件内容超过单文件物化上限。 单文件 > 64 MiB(快照与 Live 都算) 可达
VFS3012 文件集合超过总物化预算。 快照累计 > 256 MiB 可达
VFS3013 文件集合条目数量超过安全上限。 条目 > 4096(短路,仅此一条) 可达
VFS4002 嵌入式文件集所属程序集缺少 FullName,无法按 identity 绑定。 匿名/动态程序集 防御
VFS4002 嵌入式文件来源的程序集 identity 与已注册程序集不一致。 identity 漂移 防御
VFS4003 已声明的嵌入式资源不存在。 显式声明的资源名拼错 可达
VFS4003 无法打开已声明的嵌入式资源。 GetManifestResourceStream 抛异常 防御
VFS4004 无法读取已声明的嵌入式资源。 资源流 IO 异常 防御
VFS4005 同一程序集 identity 绑定了不同程序集。 同 FullName 的不同 Assembly 实例 防御

性能特征(实测)

环境:Windows 11 (10.0.28120.3151) / .NET 11.0.0-rc.1.26425.128 / Intel Core i9-10900K(20 逻辑、10 物理核)/ X64 RyuJIT x86-64-v3。 BND 列来自基准工程(Job.ShortRun + MemoryDiagnoser + InProcessEmit 工具链,Allocated 的 – 就是 0 B);探针列是本地 feed 探针直测(GC.GetAllocatedBytesForCurrentThread;时延含委托与循环开销,系统性偏高,但覆盖了基准工程没有的形状)。

场景 BND 时延 BND 分配 探针时延 探针分配
GetFileInfo 快照命中(嵌入) 27.81 ns – 147 ns 0 B
GetFileInfo 快照命中(物理 200 文件集) 25.80 ns – 189 ns 0 B
GetFileInfo 未命中 32.77 ns 24 B 171 ns 24 B
GetFileInfo 非规范路径 — — 91 ns 24 B
GetFileInfo 目录路径 — — 156 ns 24 B
GetFileInfo 带前导 / 的命中 — — — 约 40 B/次(去斜杠的一次小字符串拷贝)
GetDirectoryContents 纯快照目录 32.78 ns – 147 ns 0 B
GetDirectoryContents 根(走预构建缓存) 21.17 ns(物理 200 集) – 34 ns 0 B
GetDirectoryContents 未命中 — — 128 ns 0 B(共享单例)
Watch 0.79 ns – 6 ns 0 B
TryGetDeclaration 命中 / 未命中 33.22 ns – 46 / 33 ns 0 B
Declarations 枚举一次(IReadOnlyList 装箱) — — 257 ns 56 B
GetFileInfoOrNull 命中 — — 37 ns 0 B
快照 CreateReadStream + 读完 56.16 ns 64 B 120 ns 64 B
GetAllFilesInFolder(20 文件快照目录) — — 1269 ns 728 B
含 Live 的目录列举(每次重建 + 逐个探测) — — 727 µs 2120 B
Live GetFileInfo(含一次句柄探测) — — 726 µs 2024 B
Live CreateReadStream + 读完(= BND 的 Live_OpenAndRead,两次句柄验证都在内) 1,277.65 µs 7327 B 1452 µs 4064 B
Prepare:2000 个 1 KB 文件(一次性) — — — 1335 ms
Prepare:1 个 8 MB 文件(一次性) — — — 26.3 ms
4 MB 快照的堆增量 — — — 4.04 MB(唯一一份内容缓冲)

五条结论:

  1. 读路径的分配是设计出来的:命中快照、纯快照目录、Watch、TryGetDeclaration 四路 0 B,由基准列与模块用例(Release 下断言 0 B)双重钉住;未命中只有 1 个 24 B 占位对象,且 Name 直接引用调用方字符串、不切子串。
  2. 规模不影响命中价:200 文件物理集的命中/列举与嵌入式小集合同价(25.8/21.2 ns vs 27.8/32.8 ns),因为读路径只查 FrozenDictionary。
  3. Live 是给开发期的,不是给热路径的:每次打开 0.7 ms 量级、约 2 KB 分配,比快照慢约 4 个数量级。本轮把 GetFinalPathNameByHandle 的定长 32K 字符缓冲改成"借小缓冲 + 按需重试",Live 打开分配从 264 KB → 7.3 KB(BND 口径,36 倍);时延基本没动(1.34 → 1.28 ms),因为时间花在句柄探测与杀软实时扫描上,不在缓冲分配上。
  4. 快照零冗余:4 MB 文件的堆增量实测 4.04 MB——内容只有一份,IFileProvider 流、目录列举与摘要见证共享同一缓冲,读流只多一个 64 B 的 MemoryStream 壳。
  5. Prepare 是真成本:每个物理文件都要走一遍句柄级验证(根 + 每段 + 目标)+ 读 + SHA-256,2000 文件约 1.3 s;把它放在启动期是一次性的,但别把上千文件的大目录塞进快照集合还指望冷启动很快。

线程安全与生效时机

对象 结论(实测)
IFileProvider / IVirtualFileCatalog 实例 单例、无状态;读路径只查不可变索引。实测 8 线程并发解析得到同一实例,并发各读 2 万次无失败
首次解析的并发 由 DI 保证只 Prepare 一次;Prepare 抛出的异常在后续每次解析重复抛出(不会"半成功")
快照 IFileInfo 可并发使用;CreateReadStream() 每次返回新的 MemoryStream(共享只读缓冲),互不干扰
Live IFileInfo 每次构造/打开各自开句柄,不共享状态;4 线程 × 200 次并发打开同一 Live 文件无失败、内容一致
Watch 令牌 全局唯一实例,永不触发;注册回调返回 no-op disposable,不产生回调泄漏
SparkdoVirtualFileSystemOptions 只在容器构建前的配置阶段写;FileSets 是 internal 列表,Prepare 读尽后与实例无关
生效时机 文件集、暴露模式、摘要都在 Prepare 冻结。运行期没有热更新:改磁盘内容只对 Live 集合生效,改"有哪些文件"必须重建容器(重启进程)
跨 scope 解析出的提供器与容器根绑定;ValidateScopes 下从根解析单例是正常的(本包注册的都是单例,没有 scoped 依赖)

常见坑

  1. 忘了前导斜杠不是你的错,但曾是本包的错:中间件传 /x。现在两种形状都命中;自定义 IVirtualFileCatalog 若要与本包互换,请同样容忍(守则第 12 条)。
  2. 大小写:本包恒区分大小写;从 PhysicalFileProvider 迁来的代码在 Windows 上"本来能命中"的大小写变体会变 404。
  3. baseNamespace 写错不报错,只给空目录(表现为全线 404)。用配方 11 自检。
  4. 含点的资源名会被拆成目录(app.config.json → app/config.json),dotfile 资源会丢前导点(.hidden.txt → hidden.txt);这类文件请显式 AddEmbeddedFile。
  5. 无扩展名的资源被静默跳过(映射规则要求有扩展名)。
  6. baseNamespace: null 让全程序集资源参与,其中任意一条映射不规范就整次 Prepare 失败关闭。
  7. 物理集合不能改名:逻辑路径恒等于相对路径,baseFolder 只作用于嵌入集合。
  8. 物理根必须绝对且存在:相对根 / 不存在根 / 根是文件 / UNC 全是 VFS3004。
  9. 开发期覆盖顺序写反:AddEmbedded 在前、AddPhysical(live:true) 在后 → 覆盖不生效(先到先得)。
  10. 以为 Live 能感知新增文件:不能。Live 只反映 Prepare 时已声明文件的内容变化。
  11. 以为 Live 会被摘要保护:Prepare 之后不再校验;声明里的摘要永远是启动时那份。
  12. 以为 Live 不占预算:单文件 64 MiB 上限照样生效(Prepare 会整读一次算见证);只有"总快照预算"不计 Live。
  13. 快照改了盘不重启:内容、Length、摘要都恒为启动时值——这是特性,不是 bug。
  14. 把 Live 放进生产热路径:每次打开 ~0.7 ms + 2 KB;高 QPS 下这是量级差异。
  15. 指望重复注册能报错:跨集合同名是静默让位(VFS1004 公共面到不了);而且落选集合的错摘要也不会被发现。
  16. 忘了目录前缀冲突是跨集合检查:docs 与 docs/child.txt 只要同时出现在最终目录里就 VFS1016,哪怕来自两个不同集合。
  17. GetAllFilesInFolder 的顺序当成字典序:它是栈式 DFS,跨子目录顺序不稳定;要稳定请自己排序。
  18. 对未命中对象读 Length 或调 CreateReadStream:本产品 Length=-1 不抛(PhysicalFileProvider 会抛),但打开流一定抛 FileNotFoundException——先判 Exists 或用 GetFileInfoOrNull。
  19. 指望 Watch 触发重载:令牌永不变更,ActiveChangeCallbacks=false;要变化用 live: true 集合,要热重载目录得自己重建容器。
  20. dotfile 现在合法,本包也不隐藏它:AddPhysical 一个仓库根目录会把 .env、.gitignore 都收进目录,实测 .hidden.txt 经静态文件中间件直接 200 发出去;只有"扩展名不在内容类型表里"的那类(.env)恰好被 404 挡住——那是内容类型规则,不是保护。别把整个仓库根当文件集根,要挡敏感前缀请自己在中间件外面限定请求路径。
  21. 硬链接会让整个集合失败关闭:Windows 上同 inode 的两个名字都被拒(链接数=2);把发布流水线里的硬链接产物放进物理根会直接起不来。
  22. junction/符号链接子树被静默跳过:枚举不进去,显式声明则 VFS3006。想让文件进目录就得真放进去。
  23. 非 Windows / 非 x64-Linux 上物理来源不可用:VFS3006 失败关闭;嵌入与快照内存读不受影响。
  24. 以为能 new 一个提供器:提供器与目录实现都是内部类型,容器是唯一取用路径。
  25. Declarations 每次 foreach 装箱 56 B:单次查询走 TryGetDeclaration;只在遍历/序列化时碰列表。

非目标

  • 不做写入、创建、删除、上传——只读目录。
  • 不做运行期热重载、文件监视、变更通知;Watch 令牌恒不变。
  • 不做内容缓存策略(TTL/失效/预热):快照本身就是不可变缓存,Live 就是直读磁盘,没有中间层。
  • 不做签名/加密/防篡改;摘要只是内容等同性见证。
  • 不做权限模型、ACL、沙箱;句柄级验证只保证"打开的就是解析出来的那个文件"。
  • 不做预算可调;64 MiB / 256 MiB / 4096 是实现安全边界。
  • 不做 GeneratedFileSource(契约基类只是留缝);不实现任何生成器。
  • 不做具体宿主集成包(ASP.NET Core、Razor、配置源、静态资源打包都只用标准 IFileProvider 接缝)。
  • 不做大小写折叠、区域设置比较、\ 分隔符、// 折叠等"宽容"路径语义(除刻意容忍的那一个前导/尾随斜杠)。
  • 不提供自有门面 API(没有 ReadAllText/CopyTo 之类的业务便捷层)。
  • 不代注册宿主的 IFileProvider,不做 CompositeFileProvider 自动装配。

工程与验证

工程 职责 打包
src/Sparkdo.VirtualFileSystem.Abstractions(同版本发布) 契约层:路径/来源/摘要/声明/问题/两个出口接口,零 NuGet 依赖 Sparkdo.VirtualFileSystem.Abstractions
src/Sparkdo.VirtualFileSystem 校验、物化、句柄级验证、快照与目录索引、IFileProvider 适配、便利扩展、DI 接线 Sparkdo.VirtualFileSystem(只依赖契约包)
benchmarks/Sparkdo.VirtualFileSystem.Benchmarks BenchmarkDotNet 九场景观测 不打包
test/Sparkdo.VirtualFileSystem.Tests 87 项用例(Windows 实测 81 通过 + 6 项平台/环境门控跳过):路径规范与 dotfile、嵌入映射与见证、物理枚举与 Live、first-wins、前缀冲突、IFileProvider 契约、DI 让位、失败关闭、零分配与 Live 分配门禁、生态形状守卫 不打包
# 模块全量测试(Windows 实测 87 项:81 通过 + 6 项平台/环境门控跳过;Release 构建 0 警告)
dotnet test src/virtual-file-system/Sparkdo.VirtualFileSystem.slnx

# 基准(本地观测,约 2 分钟)
dotnet run -c Release --project src/virtual-file-system/benchmarks/Sparkdo.VirtualFileSystem.Benchmarks

# 打包并核对包内自述与 XML 文档随包
dotnet pack src/virtual-file-system/src/Sparkdo.VirtualFileSystem -c Release -o artifacts/feed
unzip -l artifacts/feed/Sparkdo.VirtualFileSystem.1.0.0.nupkg | grep -E "README|\.xml"

本文所有读数与逐字消息来自:本地 feed 探针(只 PackageReference 包体的 net11.0 控制台工程,覆盖 9 组场景 / 71 项断言)、Web SDK 探针(真起 Kestrel 打真实 HTTP 请求,含 ETag/304/HEAD/Ranges/越界/大小写)、以及仓库用例。本轮另修了五处公共面缺陷(生态前导斜杠、目录两视图不同源、GetFinalPathNameByHandle 定长 32K 缓冲、VirtualFileSystemPreparationException(null) 抛 NullReferenceException、dotfile 被误判为保留名),每处都有守卫用例,并用变异注入证明"回退到修复前这些用例会变红"(其中 Live 分配门禁在回退时精确读到 132,714 B/次)。

本地 feed 探针的一条纪律:探针必须用不重复的版本号。同一版本号会命中全局 NuGet 缓存里的旧包,实测踩到过缓存中 lib/net10.0 的旧 API 顶掉新包体、导致探针编译失败的坑。

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed.  net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on Sparkdo.VirtualFileSystem:

Package Downloads
Sparkdo.Emailing

Sparkdo 邮件发送库,提供基于 SMTP 的邮件发送功能,支持同步/异步发送、后台队列发送和模板邮件

Sparkdo.AspNetCore

Sparkdo ASP.NET Core 集成库,提供与 ASP.NET Core 框架深度集成的功能实现

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.0.0-preview.1 53 10/30/2026
2.0.0-preview.2 105 7/20/2026
2.0.0-preview.1 90 7/18/2026
1.0.4-preview.3 478 6/8/2026
1.0.4-preview.2 471 6/7/2026
1.0.4-preview.1 438 6/7/2026
1.0.3 1,082 6/6/2026
1.0.3-preview.1 462 5/17/2026
1.0.2 814 4/27/2026
1.0.2-preview.5 190 4/15/2026
1.0.2-preview.4 198 2/8/2026
1.0.2-preview.3 205 2/1/2026
1.0.2-preview.2 224 1/31/2026
1.0.2-preview.1 260 12/4/2025
1.0.1 2,740 11/27/2025
1.0.0 2,718 11/25/2025
1.0.0-preview.5 186 10/24/2025