Sparkdo.VirtualFileSystem.Abstractions
3.0.0-preview.1
dotnet add package Sparkdo.VirtualFileSystem.Abstractions --version 3.0.0-preview.1
NuGet\Install-Package Sparkdo.VirtualFileSystem.Abstractions -Version 3.0.0-preview.1
<PackageReference Include="Sparkdo.VirtualFileSystem.Abstractions" Version="3.0.0-preview.1" />
<PackageVersion Include="Sparkdo.VirtualFileSystem.Abstractions" Version="3.0.0-preview.1" />
<PackageReference Include="Sparkdo.VirtualFileSystem.Abstractions" />
paket add Sparkdo.VirtualFileSystem.Abstractions --version 3.0.0-preview.1
#r "nuget: Sparkdo.VirtualFileSystem.Abstractions, 3.0.0-preview.1"
#:package Sparkdo.VirtualFileSystem.Abstractions@3.0.0-preview.1
#addin nuget:?package=Sparkdo.VirtualFileSystem.Abstractions&version=3.0.0-preview.1&prerelease
#tool nuget:?package=Sparkdo.VirtualFileSystem.Abstractions&version=3.0.0-preview.1&prerelease
Sparkdo.VirtualFileSystem.Abstractions
虚拟文件系统产品域的稳定公共契约:逻辑路径、文件来源联合、内容摘要见证、暴露模式、文件声明、校验问题,以及两个出口接口(目录查询 IVirtualFileCatalog 与提供器标记 IVirtualFileProvider)。
本包不读磁盘、不读程序集资源、不校验、不物化、不抛任何异常。它只回答两件事:一条文件声明长什么样,以及怎么问出一张已准备好的目录。
三条硬承诺(都在本地 feed 探针上实测过):
- 零外部 NuGet 依赖:
nuspec的net11.0依赖组是空的。编译与运行只需要 net11.0 共享框架(Microsoft.Extensions.FileProviders.Abstractions与Microsoft.Extensions.Primitives已在其中)。实测本包程序集的引用集合就是System.Runtime, Microsoft.Extensions.FileProviders.Abstractions, System.Collections。 - 类型不校验,但语义有边界:
new LogicalPath("../bad//.\\x")照样构造成功。规范校验、路径裁决与物化全在核心包Sparkdo.VirtualFileSystem的 Prepare 阶段执行并失败关闭。把契约类型当"数据形状",不要当"验证器"。 - 声明是值语义:七个类型里六个是
record(LogicalPath是readonly record struct),可比较、可with、可安全跨线程传递;Declarations里的元素与TryGetDeclaration返回的是同一个对象实例,不存在两份事实。
目录
- 什么时候只引这个包
- 安装与宿主侧依赖面
- 七个类型与它们的角色
- 逐成员语义
- 值语义与默认值实测
- 逻辑路径的字符规则(谁来判、判多重)
- 摘要见证的分级
- 来源联合与扩展缝
- 两个出口接口:谁实现、谁消费
- 自己实现一个只读目录
- 实现者守则
- 异常一览
- 非目标
什么时候只引这个包
| 你的位置 | 该引什么 | 为什么 |
|---|---|---|
| 领域库/共享库只想声明"我有哪些文件、逻辑路径是什么、期望摘要是什么",由宿主去装配 | 本包 | 不引入编排、不引入 DI 语义,net11.0 组保持空依赖(net10.0 组只声明平台内置的 FileProviders.Abstractions 与 Primitives) |
要为虚拟文件系统写另一种目录来源(自定义 IVirtualFileCatalog,例如从数据库或远程清单装载声明) |
本包(+ 核心包做物化) | 契约层就是给这种实现留的 |
只想把 IFileProvider 交出去、或从 IVirtualFileProvider 解析 |
本包 | IVirtualFileProvider : IFileProvider 在这里 |
| 要真的跑起来:注册文件集、校验、物化、读内容 | Sparkdo.VirtualFileSystem |
本包只有形状,没有行为 |
| 只要标准生态接口,不想引任何 Sparkdo 包 | Microsoft.Extensions.FileProviders.Abstractions |
本包的存在理由就是给 IFileProvider 加一层"可声明、可见证"的元数据 |
安装与宿主侧依赖面
dotnet add package Sparkdo.VirtualFileSystem.Abstractions
包自身依赖面(实测 nuspec):<dependencies> 只有一个 targetFramework="net11.0" 的空组。宿主侧另需:
| 你想做的事 | 额外要引的包 | 依据(实测) |
|---|---|---|
构造声明、实现 IVirtualFileCatalog、传 IVirtualFileProvider |
无 | IFileProvider 与 IChangeToken 由 net11.0 共享框架提供 |
自己 new ServiceCollection() 并 BuildServiceProvider() |
Microsoft.Extensions.DependencyInjection |
容器实现不在共享框架内(Abstractions 在) |
用 PhysicalFileProvider/CompositeFileProvider 组合 |
Microsoft.Extensions.FileProviders.Physical / .Composite |
这些实现也不在共享框架内 |
只引本包、不引任何
Microsoft.Extensions.*包,是实测过的编译配置——一个net11.0控制台工程只PackageReference本包即可编译并运行本包的全部类型。
七个类型与它们的角色
| 类型 | 形状 | 谁生产 | 谁消费 | 一句话 |
|---|---|---|---|---|
LogicalPath |
readonly record struct |
核心包(由注册面或枚举派生) | 目录查询的主键 | NFC、/ 分隔、无根、无空段与 ./.. 段的相对路径 |
VirtualFileSource |
abstract record |
核心包 | 物化分发 | 来源基类;本版本有嵌入式与物理两个封闭子型 |
EmbeddedFileSource |
sealed record |
核心包 | 读资源 | (AssemblyIdentity, ResourceName) |
PhysicalFileSource |
sealed record |
核心包 | 读盘 | (RootPath, RelativePath) |
ContentDigest / ContentDigestAlgorithm |
sealed record / enum |
调用方(显式见证)或核心包(现算) | 完整性判定 | 算法 + 64 位小写十六进制 SHA-256 |
VirtualFileExposure |
enum |
调用方选择、核心包校验 | 读路径策略 | Snapshot(固定内容视图)与 Live(实时读取) |
VirtualFileDeclaration |
sealed record |
核心包 | IVirtualFileCatalog |
一条声明 = 路径 + 来源 + 可选见证 + 暴露 |
VirtualFileSystemIssue |
sealed record |
核心包 | 诊断与日志 | Code + 中文 Message + 可空 Subject |
IVirtualFileCatalog |
interface |
核心包实现(DefaultVirtualFileCatalog) |
需要元数据的调用方 | 按路径查声明、枚举全量声明 |
IVirtualFileProvider |
interface : IFileProvider |
核心包实现 | 宿主组合 | 零成员的解析标记 |
逐成员语义
LogicalPath
public readonly record struct LogicalPath(string Value);
Value是原样字符串:构造不校验、不重写、不 trim。default(LogicalPath).Value是null(结构体默认值不跑构造函数),所以别把default当"空路径"用。- 相等按
Value序数比较(record 语义),GetHashCode一致,可直接进HashSet<LogicalPath>。 ToString()形态:LogicalPath { Value = Templates/welcome.html }。
VirtualFileSource 与两个子型
public abstract record VirtualFileSource;
public sealed record EmbeddedFileSource(string AssemblyIdentity, string ResourceName) : VirtualFileSource;
public sealed record PhysicalFileSource(string RootPath, string RelativePath) : VirtualFileSource;
EmbeddedFileSource.AssemblyIdentity是程序集的Assembly.FullName(核心包用它把声明绑回已注册的Assembly实例);ResourceName是清单资源名(GetManifestResourceNames()返回的那种点分名字),不是逻辑路径。PhysicalFileSource.RelativePath用/分隔(核心包在枚举时已把\换成/);RootPath是登记的绝对根路径。- 三个类型都是
record,值相等,可with。模式匹配是核心包分发物化的方式,也是你消费来源信息的唯一姿势:
var kind = declaration.Source switch
{
EmbeddedFileSource embedded => $"资源 {embedded.ResourceName} 来自 {embedded.AssemblyIdentity}",
PhysicalFileSource physical => $"磁盘文件 {physical.RootPath}\{physical.RelativePath}",
_ => "未知来源",
};
ContentDigest 与 ContentDigestAlgorithm
public sealed record ContentDigest(ContentDigestAlgorithm Algorithm, string Value);
public enum ContentDigestAlgorithm { Sha256 }
Value必须是小写 64 位十六进制;格式与算法合法性由核心包在校验阶段判(不合规是VFS1005),本包不检查——new ContentDigest(ContentDigestAlgorithm.Sha256, "ABC")能构造出来。- 只有
Sha256一个成员。加新算法会是新增枚举成员(非破坏性),但核心包会拒绝它不认识的值(VFS1005)。
VirtualFileExposure
public enum VirtualFileExposure { Snapshot, Live } // Snapshot = 0,Live = 1
default是Snapshot,所以new VirtualFileDeclaration(path, source, digest, default)就是快照。Snapshot:Prepare 阶段把内容读进不可变缓冲,之后与磁盘无关。Live:Prepare 只做一次探测与见证记录,每次打开实时读盘。嵌入式来源只能Snapshot(核心包会拒绝其它组合,VFS1010)。
VirtualFileDeclaration
public sealed record VirtualFileDeclaration(
LogicalPath LogicalPath,
VirtualFileSource Source,
ContentDigest? ContentDigest,
VirtualFileExposure Exposure);
- 逻辑路径是 Prepared 目录的主键;
Source决定去哪里取内容;ContentDigest可空(空=便捷注册,由 Prepare 现算补齐;非空=显式见证,必须与实际内容一致,否则VFS2002)。 - 这是"一条声明"的唯一表示:核心包的两个查询出口(
TryGetDeclaration与Declarations)返回同一个实例,摘要要么两边都有、要么都没有,不会分叉。
VirtualFileSystemIssue
public sealed record VirtualFileSystemIssue(string Code, string Message, string? Subject);
Code是VFS前缀的稳定错误码;Message是中文说明;Subject通常是出问题的逻辑路径或来源标识,可为null(如集合级超限)。- 本包不抛异常,也不判定问题;它只是核心包把失败原因交出来的载体(消费入口是核心包的
VirtualFileSystemPreparationException.Issues)。
IVirtualFileCatalog
public interface IVirtualFileCatalog
{
bool TryGetDeclaration(string logicalPath, out VirtualFileDeclaration declaration);
IReadOnlyList<VirtualFileDeclaration> Declarations { get; }
}
TryGetDeclaration:命中返回true并给出声明;未命中时declaration被赋null(签名不可空,未命中请立刻丢弃它)。入参非法(null、空白、//、反斜杠、盘符、./..段)按"未命中"处理还是抛异常,取决于实现:核心包的实现选择"未命中",只对null/空白抛ArgumentNullException/ArgumentException(参数名logicalPath)。- 入参路径允许带恰好一个前导
/(生态调用方的常见形状),返回的声明仍携带规范化后的逻辑路径。 Declarations:全量声明,按逻辑路径序数排序;是快照式的只读集合,可以安全枚举(本版本实现返回ImmutableArray,枚举本身会走IReadOnlyList接口,因此每次foreach约 56 B——只在需要遍历/序列化时用它,单次查找请走TryGetDeclaration)。
IVirtualFileProvider
public interface IVirtualFileProvider : IFileProvider;
- 零自有成员(实测
GetMembers().Length == 0),只作为"这是本产品提供的IFileProvider"的解析标记存在。 - 为什么需要它:核心包对标准
IFileProvider用TryAdd注册,宿主若已自行注册(例如 wwwroot 的PhysicalFileProvider),标准接口就让位;此时可以从IVirtualFileProvider拿到本产品的实现,再自己组合。
值语义与默认值实测
var p1 = new LogicalPath("Templates/welcome.html");
var p2 = new LogicalPath("Templates/welcome.html");
p1 == p2; // True
p1.GetHashCode() == p2.GetHashCode(); // True
default(LogicalPath).Value is null; // True
var source = new EmbeddedFileSource("MyAssembly, Version=1.0.0.0", "MyAssembly.Data.welcome.html");
source == new EmbeddedFileSource("MyAssembly, Version=1.0.0.0", "MyAssembly.Data.welcome.html"); // True
var declaration = new VirtualFileDeclaration(p1, source, null, VirtualFileExposure.Snapshot);
declaration.ContentDigest is null; // True(便捷注册形状)
declaration with { Exposure = VirtualFileExposure.Live }; // 只改暴露,其余保留
record 的 ToString() 会打印全部成员,适合直接进日志:
EmbeddedFileSource { AssemblyIdentity = MyAssembly, Version=1.0.0.0, ResourceName = MyAssembly.Data.welcome.html }
ContentDigest { Algorithm = Sha256, Value = aaaa…(64 个 a) }
VirtualFileSystemIssue { Code = VFS1003, Message = 路径不规范。, Subject = a/../b }
逻辑路径的字符规则(谁来判、判多重)
声明侧与查找侧是两套不同严格度的规则,本包只定义形状,规则在核心包:
| 面向 | 规则 | 违反后果 |
|---|---|---|
| 声明侧(进目录的路径) | NFC、/ 分隔、无根、无盘符、无 \、无空段、无 ./..、段内无控制字符、段不是 Windows 保留名(CON/COM1/CLOCK$ 等)、段不以点或空格结尾、不含 < > : " | ? * |
Prepare 失败关闭 VFS1003(物理相对路径同理,VFS1008/VFS3005 为防御分支) |
| 查找侧(调用方传进来的 subpath) | 只额外容忍恰好一个前导 /(目录形式可再带一个尾随 /);其余非法形状一律按"不存在"处理 |
不抛异常,返回 Exists=false 的占位对象 / Exists=false 的列举 |
以点开头的名字(.gitignore、.env、.editorconfig)是合法逻辑路径——Windows 与 POSIX 都允许这类名字;核心包早期版本把"点之前的基名为空"误判成保留名,会让一个 dotfile 干掉整次 Prepare,现已修正并有用例钉住。同时"整段都是点"(.、..、...)仍被拒绝,因为 Windows 会把它折叠成当前/上级目录语义。
摘要见证的分级
| 注册方式 | 见证从哪来 | 不符时 |
|---|---|---|
便捷注册(AddEmbedded<TMarker>()、AddPhysical(root) 枚举) |
Prepare 现算 SHA-256 并写进声明,Declarations 与 TryGetDeclaration 都能读到 |
不存在"不符",因为值就是当场算的 |
显式声明并给出 ContentDigest |
调用方提供 | Prepare 失败关闭 VFS2002(逐字消息:文件内容与声明的 SHA-256 摘要不一致。) |
| 显式声明但不给见证 | Prepare 现算补齐 | 同上,不报错 |
Live 暴露的文件也遵循这套:Prepare 读一次算见证,之后的读不再校验——磁盘内容变了,声明里的摘要仍是 Prepare 时那一份。
来源联合与扩展缝
VirtualFileSource 是 abstract record,本版本只有两个封闭子型。新增来源(例如未来的生成文件、内存来源)走"加子型 + 核心包加物化分支",公共接缝不变。
需要知道的边界:
- 核心包对不认识的来源子型直接失败关闭(
VFS1013),不会静默跳过。 EmbeddedFileSource的 identity 必须与已注册程序集一致;同一 identity 绑到不同Assembly实例会被拒(VFS4005),程序集没有FullName(匿名/dynamic)也无法登记(VFS4002)。- 只有核心包能真正"打开"来源——本包的来源记录是纯数据,不含句柄、不含流。
两个出口接口:谁实现、谁消费
你的应用
├─ 读内容:Microsoft.Extensions.FileProviders.IFileProvider ← 主读口(由核心包注册)
├─ 读元数据:Sparkdo.VirtualFileSystem.IVirtualFileCatalog ← 声明/摘要/暴露查询
└─ 解析本产品实现:IVirtualFileProvider(标准接口被宿主占用时的回退口)
核心包 Sparkdo.VirtualFileSystem
├─ 实现 IVirtualFileProvider(内部类型 VirtualFileProvider)
└─ 实现 IVirtualFileCatalog(内部类型 DefaultVirtualFileCatalog)
主读口是 IFileProvider:GetFileInfo / GetDirectoryContents / Watch 三个成员足以读完内容和列举。IVirtualFileCatalog 只在你要问"这条路径是从哪来的、内容摘要是什么、是快照还是实时"时才用。
自己实现一个只读目录
契约层的全部消费面都不需要 DI。下面是一个可直接编译的自定义目录(实测形状):
using Sparkdo.VirtualFileSystem;
public sealed class MemoryVirtualFileCatalog(
IReadOnlyList<VirtualFileDeclaration> declarations) : IVirtualFileCatalog
{
public bool TryGetDeclaration(string logicalPath, out VirtualFileDeclaration declaration)
{
// 签名声明不可空,未命中必须显式赋 null!。
declaration = declarations.FirstOrDefault(d =>
string.Equals(d.LogicalPath.Value, logicalPath, StringComparison.Ordinal))!;
return declaration is not null;
}
public IReadOnlyList<VirtualFileDeclaration> Declarations { get; } = declarations;
}
var catalog = new MemoryVirtualFileCatalog(
[
new VirtualFileDeclaration(
new LogicalPath("Templates/email/welcome.html"),
new EmbeddedFileSource("MyApp, Version=1.0.0.0", "MyApp.Templates.email.welcome.html"),
ContentDigest: null,
VirtualFileExposure.Snapshot),
new VirtualFileDeclaration(
new LogicalPath("data/region.json"),
new PhysicalFileSource(@"C:\app\files", "data/region.json"),
new ContentDigest(ContentDigestAlgorithm.Sha256, "0".PadRight(64, '0')),
VirtualFileExposure.Live),
]);
catalog.Declarations.Count; // 2
catalog.TryGetDeclaration("data/region.json", out var hit); // True
catalog.TryGetDeclaration("nope", out var miss); // False,miss 为 null
要点:主键比较请用 StringComparison.Ordinal 并与本域其余实现保持一致(区分大小写);Declarations 建议按路径序数排序,方便 diff 与日志对齐。
实现者守则
- 不要在契约类型里塞校验:形状稳定是本包的全部职责,判定属于核心包。
TryGetDeclaration未命中时只赋null,不要抛;不要返回"空的声明对象"当未命中。Declarations必须是只读快照语义:不要在枚举中途改内部列表让调用方看到撕裂结果。Declarations的元素与TryGetDeclaration的返回值应是同一实例(或值相等的同一份事实)——不要让两个出口给出不同的摘要。Declarations排序请固定(推荐逻辑路径序数升序),否则日志与快照测试会漂。- 不要新增
VirtualFileSource的子型却指望核心包认识它——核心包对未知来源失败关闭(VFS1013)。 - 不要把
EmbeddedFileSource.ResourceName写成逻辑路径:它是点分的清单资源名。 PhysicalFileSource.RelativePath用/,不要留\(核心包会按非规范路径拒绝)。ContentDigest.Value给小写 64 位十六进制;大写或短串会被VFS1005拒绝。- 别给嵌入式来源设
Live:核心包会VFS1010失败关闭。 - 别把
default(LogicalPath)当空路径传下去:它的Value是null。 - 自定义目录若要与核心包互换使用,请同样容忍"恰好一个前导
/"的入参形状,否则静态文件中间件那类调用方会全量未命中。
异常一览
本包不主动抛出任何异常:七个类型都是数据形状,两个接口没有默认实现。而且契约类型对 null 一律不设防(实测):
new LogicalPath(null!); // 不抛;ToString() 打印 LogicalPath { Value = }
default(LogicalPath).Value is null; // True
default(LogicalPath).GetHashCode(); // 0(不抛)
default(LogicalPath) == default(LogicalPath); // True
new EmbeddedFileSource(null!, null!); // 不抛
new VirtualFileDeclaration(default, null!, null, default); // 不抛,Source 为空、Exposure 为 Snapshot
new VirtualFileSystemIssue(null!, null!, null!); // 不抛
| 场景 | 结果(实测) | |
|---|---|---|
用 null/空串构造上述任何记录 |
构造成功、可打印;不抛异常。防线在核心包:路径非法 → VFS1003,来源缺字段 → VFS1009/VFS1013,摘要格式非法 → VFS1005 |
|
把 default(LogicalPath) 当"空路径"传下去 |
能传,但它的 Value 是 null;核心包按非规范路径处理(VFS1003),别指望它等于"根" |
|
自定义 IVirtualFileCatalog 对 null 入参 |
由你自己决定。核心包实现抛 ArgumentNullException(logicalPath)(传 null)与 ArgumentException(传空白串) |
|
| 核心包 Prepare 失败 | Sparkdo.VirtualFileSystem.VirtualFileSystemPreparationException(类型在核心包) |
携带 IReadOnlyList<VirtualFileSystemIssue>,逐条 Code/Message/Subject |
非目标
- 不做路径规范化、不做 NFC 重写、不做大小写折叠——本包不解释字符串含义。
- 不做摘要计算与比对(
SHA256只在核心包的物化里算)。 - 不做文件读取、不定义读取管道、不暴露任何
Stream。 - 不做 DI 扩展、不做配置对象、不做 Options 模式;接线全在核心包。
- 不做文件版本、时间戳、权限、扩展属性等元数据——声明模型刻意只保留"路径 + 来源 + 见证 + 暴露"。
- 不定义自己的门面接口;
IFileProvider就是读口。 - 不提供
GeneratedFileSource等未来来源(抽象基类只是留缝)。 - 不做变更通知(
Watch属于IFileProvider,其令牌语义由核心包给出)。
| 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.FileProviders.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Primitives (>= 10.0.8)
-
net11.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Sparkdo.VirtualFileSystem.Abstractions:
| Package | Downloads |
|---|---|
|
Sparkdo.VirtualFileSystem
Sparkdo 虚拟文件系统:声明式文件集校验与物化、失败关闭的物理句柄级验证、不可变快照与标准 IFileProvider 适配,AddSparkdoVirtualFileSystem() 一行接线。 |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.0-preview.1 | 62 | 10/30/2026 |
| 2.0.0-preview.2 | 102 | 7/20/2026 |
| 2.0.0-preview.1 | 92 | 7/18/2026 |
| 1.0.4-preview.3 | 502 | 6/8/2026 |
| 1.0.4-preview.2 | 487 | 6/7/2026 |
| 1.0.4-preview.1 | 460 | 6/7/2026 |
| 1.0.3 | 1,144 | 6/6/2026 |
| 1.0.3-preview.1 | 479 | 5/17/2026 |
| 1.0.2 | 857 | 4/27/2026 |
| 1.0.2-preview.5 | 192 | 4/15/2026 |
| 1.0.2-preview.4 | 200 | 2/8/2026 |
| 1.0.2-preview.3 | 201 | 2/1/2026 |
| 1.0.2-preview.2 | 229 | 1/31/2026 |
| 1.0.2-preview.1 | 240 | 12/4/2025 |
| 1.0.1 | 2,620 | 11/27/2025 |
| 1.0.0 | 2,504 | 11/25/2025 |
| 1.0.0-preview.5 | 184 | 10/24/2025 |