Sparkdo.VirtualFileSystem
3.0.0-preview.1
dotnet add package Sparkdo.VirtualFileSystem --version 3.0.0-preview.1
NuGet\Install-Package Sparkdo.VirtualFileSystem -Version 3.0.0-preview.1
<PackageReference Include="Sparkdo.VirtualFileSystem" Version="3.0.0-preview.1" />
<PackageVersion Include="Sparkdo.VirtualFileSystem" Version="3.0.0-preview.1" />
<PackageReference Include="Sparkdo.VirtualFileSystem" />
paket add Sparkdo.VirtualFileSystem --version 3.0.0-preview.1
#r "nuget: Sparkdo.VirtualFileSystem, 3.0.0-preview.1"
#:package Sparkdo.VirtualFileSystem@3.0.0-preview.1
#addin nuget:?package=Sparkdo.VirtualFileSystem&version=3.0.0-preview.1&prerelease
#tool nuget:?package=Sparkdo.VirtualFileSystem&version=3.0.0-preview.1&prerelease
Sparkdo.VirtualFileSystem
Sparkdo 虚拟文件系统的运行时包:把程序集嵌入资源与物理目录按声明装配成一张经过校验的目录,在首次解析时一次完成校验与物化,再以标准 Microsoft.Extensions.FileProviders.IFileProvider 交给生态(静态文件中间件、配置、模板引擎等),并附一个元数据出口 IVirtualFileCatalog。AddSparkdoVirtualFileSystem() 一行接线。
三条硬承诺(都在只 PackageReference 包体的本地 feed 探针上实测,含端到端 HTTP):
- 接进生态是真的能用——
UseStaticFiles(new StaticFileOptions { FileProvider = …, RequestPath = "/assets" })后/assets/plain.txt200、/assets/no-such.txt404、If-None-Match命中 304;开发期live: true集合改盘后下一次请求就是新内容,快照集合改盘后仍是启动时那一份。 - 命中路径零分配——快照文件命中、纯快照目录列举、
Watch、TryGetDeclaration实测 0 B/次(27–33 ns 量级),且与文件集规模无关(200 文件集与嵌入式集同价);未命中 1 个 24 B 占位对象;快照读流只多一个 64 B 的MemoryStream包装,零缓冲拷贝。 - 失败关闭且一次报全——路径不规范、目录前缀冲突、资源缺失、越界/reparse point/硬链接、预算超限,全部在 Prepare 阶段以
VFS码拒绝;校验层收集全部问题一次性抛出,配置错误一轮修完。
目录
- 快速开始
- 能做什么、不做什么
- 先做三个选型判断
- 安装与宿主侧依赖面
AddSparkdoVirtualFileSystem()注册了什么- Options 逐项语义
- 一次 Prepare 的固定顺序
- 一次查找的固定顺序
- 两套路径规则:声明侧严、查找侧宽
- 嵌入式资源映射规则
- 跨来源语义
IFileProvider适配逐成员- 便利扩展三方法
- 物化预算与句柄级安全边界
- 典型用法配方
- 异常一览
- 错误码一览
- 性能特征(实测)
- 线程安全与生效时机
- 常见坑
- 非目标
- 工程与验证
快速开始
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 它就合法了 |
结论与纪律:
- 只要资源的目录名或基名里含
.,或它是 dotfile,就别依赖自动映射,走AddEmbeddedFile(assembly, resourceName, logicalPath)显式声明。 baseNamespace只是一个普通字符串前缀,不必真是某个类型的命名空间(实测Probe.Explicit这种前缀照样能用);不匹配时得到空集合而不是报错——接线写错最常见表现就是"全都 404"。- 自动枚举里只要有一条资源映射后不规范,整次 Prepare 就失败关闭(
VFS1003,Subject是那串资源名)。用baseNamespace: null(不过滤)时尤其危险:程序集里任何资源名都可能替你触发它。 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(唯一一份内容缓冲) |
五条结论:
- 读路径的分配是设计出来的:命中快照、纯快照目录、
Watch、TryGetDeclaration四路 0 B,由基准列与模块用例(Release 下断言 0 B)双重钉住;未命中只有 1 个 24 B 占位对象,且Name直接引用调用方字符串、不切子串。 - 规模不影响命中价:200 文件物理集的命中/列举与嵌入式小集合同价(25.8/21.2 ns vs 27.8/32.8 ns),因为读路径只查
FrozenDictionary。 - Live 是给开发期的,不是给热路径的:每次打开 0.7 ms 量级、约 2 KB 分配,比快照慢约 4 个数量级。本轮把
GetFinalPathNameByHandle的定长 32K 字符缓冲改成"借小缓冲 + 按需重试",Live 打开分配从 264 KB → 7.3 KB(BND 口径,36 倍);时延基本没动(1.34 → 1.28 ms),因为时间花在句柄探测与杀软实时扫描上,不在缓冲分配上。 - 快照零冗余:4 MB 文件的堆增量实测 4.04 MB——内容只有一份,
IFileProvider流、目录列举与摘要见证共享同一缓冲,读流只多一个 64 B 的MemoryStream壳。 - 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 依赖) |
常见坑
- 忘了前导斜杠不是你的错,但曾是本包的错:中间件传
/x。现在两种形状都命中;自定义IVirtualFileCatalog若要与本包互换,请同样容忍(守则第 12 条)。 - 大小写:本包恒区分大小写;从
PhysicalFileProvider迁来的代码在 Windows 上"本来能命中"的大小写变体会变 404。 baseNamespace写错不报错,只给空目录(表现为全线 404)。用配方 11 自检。- 含点的资源名会被拆成目录(
app.config.json→app/config.json),dotfile 资源会丢前导点(.hidden.txt→hidden.txt);这类文件请显式AddEmbeddedFile。 - 无扩展名的资源被静默跳过(映射规则要求有扩展名)。
baseNamespace: null让全程序集资源参与,其中任意一条映射不规范就整次 Prepare 失败关闭。- 物理集合不能改名:逻辑路径恒等于相对路径,
baseFolder只作用于嵌入集合。 - 物理根必须绝对且存在:相对根 / 不存在根 / 根是文件 / UNC 全是
VFS3004。 - 开发期覆盖顺序写反:
AddEmbedded在前、AddPhysical(live:true)在后 → 覆盖不生效(先到先得)。 - 以为 Live 能感知新增文件:不能。Live 只反映 Prepare 时已声明文件的内容变化。
- 以为 Live 会被摘要保护:Prepare 之后不再校验;声明里的摘要永远是启动时那份。
- 以为 Live 不占预算:单文件 64 MiB 上限照样生效(Prepare 会整读一次算见证);只有"总快照预算"不计 Live。
- 快照改了盘不重启:内容、
Length、摘要都恒为启动时值——这是特性,不是 bug。 - 把 Live 放进生产热路径:每次打开 ~0.7 ms + 2 KB;高 QPS 下这是量级差异。
- 指望重复注册能报错:跨集合同名是静默让位(
VFS1004公共面到不了);而且落选集合的错摘要也不会被发现。 - 忘了目录前缀冲突是跨集合检查:
docs与docs/child.txt只要同时出现在最终目录里就VFS1016,哪怕来自两个不同集合。 GetAllFilesInFolder的顺序当成字典序:它是栈式 DFS,跨子目录顺序不稳定;要稳定请自己排序。- 对未命中对象读
Length或调CreateReadStream:本产品Length=-1不抛(PhysicalFileProvider会抛),但打开流一定抛FileNotFoundException——先判Exists或用GetFileInfoOrNull。 - 指望
Watch触发重载:令牌永不变更,ActiveChangeCallbacks=false;要变化用live: true集合,要热重载目录得自己重建容器。 - dotfile 现在合法,本包也不隐藏它:
AddPhysical一个仓库根目录会把.env、.gitignore都收进目录,实测.hidden.txt经静态文件中间件直接 200 发出去;只有"扩展名不在内容类型表里"的那类(.env)恰好被 404 挡住——那是内容类型规则,不是保护。别把整个仓库根当文件集根,要挡敏感前缀请自己在中间件外面限定请求路径。 - 硬链接会让整个集合失败关闭:Windows 上同 inode 的两个名字都被拒(链接数=2);把发布流水线里的硬链接产物放进物理根会直接起不来。
- junction/符号链接子树被静默跳过:枚举不进去,显式声明则
VFS3006。想让文件进目录就得真放进去。 - 非 Windows / 非 x64-Linux 上物理来源不可用:
VFS3006失败关闭;嵌入与快照内存读不受影响。 - 以为能
new一个提供器:提供器与目录实现都是内部类型,容器是唯一取用路径。 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 | Versions 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. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.8)
- Microsoft.Extensions.FileProviders.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Options (>= 10.0.8)
- Microsoft.Extensions.Primitives (>= 10.0.8)
- Sparkdo.VirtualFileSystem.Abstractions (>= 3.0.0-preview.1)
-
net11.0
- Sparkdo.VirtualFileSystem.Abstractions (>= 3.0.0-preview.1)
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 |