5.2 设计详解
5.1 节通过几个简单的实例演示从编程的角度对文件系统做了初步介绍,下面从设计的角度进一步阐述文件系统。这个抽象的文件系统以目录的形式来组织文件,我们可以利用它读取某个文件的内容,也可以对目录或者文件实施监控并及时得到变化的通知。由于 IFileProvider 对象提供了针对文件系统变换的监控功能,在.NET Core应用中类似的功能大都利用一个IChangeToken对象来实现,所以在对IFileProvider进行深入介绍之前应该先了解IChangeToken。
5.2.1 IChangeToken
从字面上理解,IChangeToken对象就是一个与某组监控数据相关联的“令牌”(Token),它能够在检测到数据改变时及时对外发出一个通知。如果 IChangeToken 对象关联的数据发生改变,那么它的 HasChanged属性将变成 True。我们可以调用其 RegisterChangeCallback方法注册一个在数据发生改变时可以自动执行的回调,该方法会返回一个 IDisposable 对象,可以用其Dispose方法解除注册的回调。IChangeToken接口的另一个属性ActiveChangeCallbacks表示当数据发生变化时是否需要主动执行注册的回调操作。
.NET Core 提供了若干原生的 IChangeToken 接口实现类型,我们最常使用的是一个名为CancellationChangeToken的实现。CancellationChangeToken的实现原理很简单,就是按照如下形式借助CancellationToken对象来发送通知的。
除了 CancellationChangeToken,有时也会使用一个名为 CompositeChangeToken的实现类型。顾名思义,CompositeChangeToken 代表由多个 IChangeToken 组合而成的复合型 IChange Token 对象。如下面的代码片段所示,在调用构造函数创建一个 CompositeChangeToken 对象时,需要提供这些IChangeToken对象。对于一个CompositeChangeToken对象来说,只要组成它的任何一个 IChangeToken发生改变,其 HasChanged属性就变成 True,而注册的回调自然会被执行。只要任何一个IChangeToken的同名属性返回True,ActiveChangeCallbacks属性就会返回True。
我们可以直接调用 IChangeToken 提供的 RegisterChangeCallback 方法注册在接收到数据变化通知后的回调操作,但是更常用的方式则是直接调用静态类型 ChangeToken 提供的如下两个OnChange 方法重载进行回调注册,这两个方法的第一个参数需要被指定为一个用来提供IChangeToken对象的Func<IChangeToken>委托。
5.2.2 IFileProvider
了解 IChangeToken对象之后,我们将关注点转移到文件系统的核心接口 IFileProvider上,该接口定义在NuGet包“Microsoft.Extensions.FileProviders.Abstractions”中。前面做了几个简单的实例演示,体现了文件系统承载的 3 个基本功能,而这 3 个基本功能分别体现在IFileProvider接口如下所示的3个方法中。
虽然文件系统采用目录组织文件,但不论是目录还是文件都通过一个 IFileInfo 对象来表示,至于具体是目录还是文件则通过 IFileInfo的 IsDirectory属性来确定。对于一个 IFileInfo对象,我们可以通过只读属性 Exists 判断指定的目录或者文件是否真实存在。而 Name 属性和PhysicalPath 属性分别表示文件或者目录的名称与物理路径。LastModified 属性返回一个时间戳,表示目录或者文件最后一次被修改的时间。对于一个表示具体文件的 IFileInfo 对象来说,我们可以利用 Length属性得到文件内容的字节长度。我们还可以借助 CreateReadStream方法返回的Stream对象读取文件的内容。
IFileProvider接口的GetFileInfo方法会根据指定的路径得到表示所在文件的IFileInfo对象。换句话说,虽然一个 IFileInfo对象可以用于描述目录和文件,但是 GetFileInfo方法的目的在于得到指定路径返回的文件而不是目录(笔者不太认同这种容易产生歧义的API设计)。一般来说,不论指定的文件是否存在,GetFileInfo 方法总会返回一个具体的 IFileInfo 对象,因为目标文件的存在与否是由该对象的Exists属性确定的。
如果希望得到某个目录的内容,如需要查看多少文件或者子目录包含在这个目录下,可以调用 IFileProvider对象的 GetDirectoryContents方法,并将所在目录的路径作为参数。目录内容通过该方法返回的 IDirectoryContents 对象来表示。如下面的代码片段所示,一个IDirectoryContents对象实际上是一组 IFileInfo对象的集合,组成这个集合的所有 IFileInfo就是对包含在这个目录下的所有文件和子目录的描述。和 GetFileInfo 方法一样,不论指定的目录是否存在,GetDirectoryContents方法总是返回一个具体的 IDirectoryContents对象,它的 Exists属性可以确定指定目录是否存在。
如果要监控 IFileProvider 所在目录或者文件的变化,我们可以调用它的 Watch 方法,但前提是对应的 IFileProvider 对象提供了这样的监控功能。这个方法接受一个字符串类型的参数filter,我们可以利用这个参数指定一个针对“文件匹配模式”(File Globbing Pattern)表达式(以下简称Globbing Pattern表达式)来筛选需要监控的目标目录或者文件。
Globbing Pattern表达式比正则表达式简单,它只包含“*”一种通配符,如果认为它包含两种通配符,那么另一个通配符是“**”。Globbing Pattern表达式体现为一个文件路径,其中,“*”代表所有不包括路径分隔符(“/”或者“\”)的所有字符,“**”代表包含路径分隔符在内的所有字符。表5-1列举了常见的几种Globbing Pattern表达式。
表5-1 常见的几种Globbing Pattern表达式
一般来说,不论是调用 IFileProvider对象的 GetFileInfo方法或者 GetDirectoryContents方法所指定的目标文件或者目录的路径,还是调用 Watch 方法指定的筛选表达式,都是一个针对当前 IFileProvider 对象映射根目录的相对路径。指定的这个路径可以采用“/”字符作为前缀,但是这个前缀不是必要的。换句话说,下面两组程序是完全等效的。
总的来说,以 IFileProvider 对象为核心的文件系统从设计上来看是非常简单的。除了 IFile Provider接口,文件系统还涉及其他一些对象,如IDirectoryContents、IFileInfo和IChangeToken等。文件系统涉及的接口及其相互之间的关系如图5-4所示。
图5-4 文件系统涉及的接口及其相互之间的关系
5.2.3 PhysicalFileProvider
ASP.NET Core应用中使用得最多的还是具体的物理文件,如配置文件、View文件以及作为Web 资源的静态文件。物理文件系统由定义在 NuGet 包“Microsoft.Extensions.FileProviders.Physical”中的 PhysicalFileProvider 来构建。System.IO 命名空间下定义了一整套针对操作物理目录和文件的API,但PhysicalFileProvider最终也是通过调用这些API来完成相关的IO操作的。
PhysicalFileInfo
一个 PhysicalFileProvider 对象总是映射到某个具体的物理目录上,被映射的目录所在的路径通过构造函数的参数 root 来提供,该目录将作为 PhysicalFileProvider 的根目录。GetFileInfo方法返回的 IFileInfo对象代表指定路径对应的文件,这是一个类型为 PhysicalFileInfo的对象。一个物理文件可以通过一个 System.IO.FileInfo对象来表示,一个 PhysicalFileInfo对象实际上就是对该对象的封装,定义在 PhysicalFileInfo 的所有属性都来源于 FileInfo 对象。对于创建读取文件输出流的 CreateReadStream 方法来说,它返回的是一个根据物理文件绝对路径创建的FileStream对象。
对于 PhysicalFileProvider 的 GetFileInfo 方法来说,即使指定的路径指向一个具体的物理文件,也不是总返回一个PhysicalFileInfo对象。PhysicalFileProvider会将一些场景视为“目标文件不存在”,并让 GetFileInfo 方法返回一个 NotFoundFileInfo 对象。具体来说,PhysicalFile Provider的GetFileInfo方法在如下场景中会返回一个NotFoundFileInfo对象。
● 确实没有一个物理文件与指定的路径相匹配。
● 如果指定的是一个绝对路径(如“c:\foobar”),即Path.IsPathRooted方法返回True。
● 如果指定的路径指向一个隐藏文件。
顾名思义,具有如下定义的 NotFoundFileInfo 类型表示一个“不存在”的文件。NotFound FileInfo 对象的 Exists 属性总是返回 False,而其他属性则变得没有任何意义。当调用其CreateReadStream 试图读取一个根本不存在的文件的内容时,会抛出一个 FileNotFound Exception类型的异常。
PhysicalDirectoryInfo
PhysicalFileProvider利用一个PhysicalFileInfo对象来描述某个具体的物理文件,而一个物理目录则通过一个 PhysicalDirectoryInfo对象来描述。既然 PhysicalFileInfo是对一个 FileInfo对象的封装,那么 PhysicalDirectoryInfo对象封装的就是表示目录的 DirectoryInfo对象。如下面的代码片段所示,我们需要在创建一个 PhysicalDirectoryInfo 对象时提供 DirectoryInfo 对象,PhysicalDirectoryInfo 实现的所有属性的返回值都来源于 DirectoryInfo 对象。由于CreateReadStream 方法的目的总是读取文件的内容,所以 PhysicalDirectoryInfo 类型的这个方法会抛出一个InvalidOperationException类型的异常。
PhysicalDirectoryContents
调用 PhysicalFileProvider的 GetDirectoryContents方法时,如果指定的路径指向一个具体的目录,那么该方法会返回一个类型为 PhysicalDirectoryContents 的对象。PhysicalDirectory Contents是一个 IFileInfo对象的集合,该集合中包括所有描述子目录的 PhysicalDirectoryInfo对象和描述文件的 PhysicalFileInfo对象。PhysicalDirectoryContents的 Exists属性取决于指定的目录是否存在。
NotFoundDirectoryContents
如果指定的路径并不指向一个存在的目录,或者指定的是一个绝对路径,GetDirectory Contents方法都会返回一个 Exists属性为 False的 NotFoundDirectoryContents对象。如下所示的代码片段展示了 NotFoundDirectoryContents 类型的定义,如果需要使用这样一个类型,就可以直接利用静态属性Singleton得到对应的单例对象。
PhysicalFilesWatcher
下面介绍PhysicalFileProvider的Watch方法。调用该方法时,PhysicalFileProvider会通过解析Globbing Pattern表达式来确定我们期望监控的文件或者目录,最终利用FileSystemWatcher对象对这些文件实施监控。这些文件或者目录的变化(创建、修改、重命名和删除等)都会实时地反映到Watch方法返回的IChangeToken上。
PhysicalFileProvider的 Watch方法中指定的 Globbing Pattern表达式必须是针对当前根目录的相对路径,可以使用“/”或者“./”前缀,也可以不采用任何前缀。一旦使用了绝对路径(如“c:\test\*.txt”)或者“../”前缀(如“../test/*.txt”),不论解析出的文件是否存在于PhysicalFileProvider 的根目录下,这些文件都不会被监控。除此之外,如果没有指定 Globbing Pattern表达式,PhysicalFileProvider也不会有任何文件会被监控。
PhysicalFileProvider 针对物理文件系统变化的监控是通过下面的 PhysicalFilesWatcher 对象实现的,其 Watch方法内部会直接调用 PhysicalFileProvider的 CreateFileChangeToken方法,并返回得到的 IChangeToken 对象。这是一个公共类型,如果有监控物理文件系统变化的需要,可以直接使用这个类型。
从 PhysicalFilesWatcher构造函数的定义可以看出,它最终利用一个 FileSystemWatcher对象(对应参数 fileSystemWatcher)来完成针对指定根目录下(对应参数 root)所有子目录和文件的监控。FileSystemWatcher的CreateFileChangeToken方法返回的IChangeToken对象会帮助我们感知到子目录或者文件的添加、删除、修改和重命名,但是它会忽略隐藏的目录和文件。需要注意的是,如果不再需要对指定目录实施监控,应调用PhysicalFileProvider的Dispose方法,该方法可以将FileSystemWatcher对象关闭。
小结
可以借助图5-5所示的UML对由PhysicalFileProvider构建物理文件系统的整体设计进行总结。首先,该文件系统使用PhysicalDirectoryInfo对象和PhysicalFileInfo对象来描述目录与文件,它们分别是对DirectoryInfo对象和FileInfo(System.IO.FileInfo)对象的封装。
PhysicalFileProvider 的 GetDirectoryContents 方法返回一个 EnumerableDirectoryContents 对象(如果指定的目录存在),组成该对象的分别是根据其所有子目录和文件创建的PhysicalDirectoryInfo对象和PhysicalFileInfo对象。当调用PhysicalFileProvider的GetFileInfo方法时,如果指定的文件存在,返回的是描述该文件的 PhysicalFileInfo 对象。而 PhysicalFileProvider 的Watch方法则利用FileSystemWatcher来监控指定文件或者目录的变化。
图5-5 PhysicalFileProvider涉及的主要类型及其相互之间的关系
5.2.4 EmbeddedFileProvider
一个物理文件可以直接作为资源内嵌到编译生成的程序集中。借助 EmbeddedFileProvider,我们可以采用统一的编程方式来读取内嵌的资源文件,该类型定义在 NuGet 包“Microsoft.Extensions.FileProviders.Embedded”中。在正式介绍 EmbeddedFileProvider 之前,我们必须知道如何将一个项目文件作为资源内嵌到编译生成的程序集中。
将项目文件变成内嵌资源
在默认情况下,添加到.NET Core 项目中的静态文件并不会成为目标程序集的内嵌资源文件。如果需要将静态文件作为目标程序集的内嵌文件,就需要修改当前项目对应的.csproj 文件。具体来说,我们需要按照前面实例演示的方式在.csproj文件中添加<ItemGroup>/<EmbeddedResource>元素,并利用Include属性显式地将对应的资源文件包含进来。
<EmbeddedResource>的 Include 属性可以设置多个路径,路径之间采用分号(;)作为分隔符。以图 5-6所示的目录结构为例,如果需要将 root目录下的 4个文件作为程序集的内嵌文件,我们可以修改.csproj文件,并按照如下形式将4个文件的路径包含进来。
图5-6 包含资源文件的.NET Core项目
除了指定每个需要内嵌的资源文件的路径,我们还可以采用基于通配符“*”和“**”的Globbing Pattern表达式将一组匹配的文件批量包含进来。同样是将 root目录下的所有文件作为程序集的内嵌文件,但下面的定义方式更加简洁。
<EmbeddedResource>具有两个属性:Include 属性用来添加内嵌资源文件,Exclude 属性负责排除不符合要求的文件。还是以前面的项目为例,对于 root目录下的 4个文件,如果不希望baz.txt文件作为内嵌资源文件,也可以按照如下方式将其排除。
读取资源文件
每个程序集都有一个清单文件(Manifest),其作用是记录组成程序集的所有文件成员。总的来说,一个程序集主要由两种类型的文件构成,即承载 IL代码的托管模块文件和编译时内嵌的资源文件。针对图 5-6 所示的项目结构,如果将 4 个文本文件以资源文件的形式内嵌到生成的程序集(App.dll)中,程序集的清单文件将采用如下形式来记录它们。
虽然文件在原始项目中具有层次化的目录结构,但是当它们成功转移到编译生成的程序集中之后,目录结构将不复存在,所有的内嵌文件将统一存放在同一个容器中。如果通过Reflector 打开程序集,资源文件的扁平化存储将会一目了然(见图 5-7)。为了避免命名冲突,编译器会根据原始文件所在的路径对资源文件重新命名,具体的规则 是“{BaseNamespace}.{Path}”,目录分隔符将统一转换成“.”。值得强调的是,资源文件名称的前缀不是程序集的名称,而是为项目设置的基础命名空间的名称。
图5-7 内嵌资源文件的扁平化存储
表示程序集的 Assembly对象定义的如下几个方法用来提取内嵌资源文件的相关信息和读取指定资源文件的内容。GetManifestResourceNames 方法用于获取记录在程序集清单文件中的资源文件名,GetManifestResourceInfo方法则用于获取指定资源文件的描述信息。如果需要读取某个资源文件的内容,可以将资源文件名称作为参数调用 GetManifestResourceStream 方法,该方法会返回一个读取文件内容的Stream对象。
同样是针对前面这个演示项目对应的目录结构,当 4 个文件作为内嵌文件被成功转移到编译生成的程序集中后,我们可以调用程序集对象的 GetManifestResourceNames 方法获取这 4 个内嵌文件的资源名称。如果以资源名称(App.root.dir1.foobar.foo.txt)作为参数调用 GetManifest ResourceStream方法,可以读取资源文件的内容,具体的演示如下所示。
EmbeddedFileProvider
对内嵌于程序集的资源文件有了大致的了解之后,针对 EmbeddedFileProvider 的实现原理就比较容易理解。由于内嵌于程序集的资源文件采用扁平化存储形式,所以通过EmbeddedFileProvider 构建的文件系统中并没有目录层级的概念。我们可以认为所有的资源文件都保存在程序集的根目录下。对于 EmbeddedFileProvider 构建的文件系统来说,它提供的IFileInfo 对象总是对一个具体的资源文件进行描述,这是一个具有如下定义的 Embedded ResourceFileInfo对象。
如上面的代码片段所示,在创建一个EmbeddedResourceFileInfo对象时需要指定内嵌资源文件在清单文件中的路径(resourcePath)、所在的程序集、资源文件的名称(name),以及作为文件最后修改时间的DateTimeOffset对象。由于一个EmbeddedResourceFileInfo对象总是对应一个具体的内嵌资源文件,所以它的 Exists属性总是返回 True,IsDirectory属性则返回 False。由于资源文件系统并不具有层次化的目录结构,它所谓的物理路径毫无意义,所以PhysicalPath属性直接返回 Null。CreateReadStream 方法返回的是调用程序集的 GetManifestResourceStream 方法返回的输出流,而表示文件长度的Length返回的是这个Stream对象的长度。
下面的代码片段是 EmbeddedFileProvider的定义。创建一个EmbeddedFileProvider对象时,除了指定资源文件所在的程序集,还可以指定一个基础命名空间。如果该命名空间没有显式设置,在默认情况下会将程序集的名称作为命名空间,也就是说,如果为项目指定了一个不同于程序集名称的基础命名空间,那么创建EmbeddedFileProvider对象时必须指定这个命名空间。
调用 EmbeddedFileProvider 的 GetFileInfo 方法并指定资源文件的逻辑名称时,该方法会将它与命名空间一起组成资源文件在程序集清单的名称(路径分隔符会被替换成“.”)。如果对应的资源文件存在,那么一个 EmbeddedResourceFileInfo 会被创建并返回,否则返回的将是一个NotFoundFileInfo 对象。对于内嵌资源文件系统来说,根本就不存在所谓的文件更新问题,所以它的Watch方法会返回一个HasChanged属性总是False的IChangeToken对象。
由于内嵌于程序集的资源文件总是只读的,它所谓的最后修改时间实际上是程序集的生成日期,所以EmbeddedFileProvider在提供EmbeddedResourceFileInfo对象时会将程序集文件的最后更新时间作为资源文件的最后更新时间。如果不能正确解析这个时间,那么EmbeddedResourceFileInfo的LastModified属性将被设置为当前UTC时间。
由于 EmbeddedFileProvider 构建的内嵌资源文件系统不存在层次化的目录结构,所有的资源文件可以视为全部存储在程序集的根目录下,所以它的 GetDirectoryContents 方法只有在指定一个空字符串或者“/”(空字符串和“/”都表示根目录)时才会返回一个描述这个根目录的DirectoryContents对象,该对象实际上是一组 EmbeddedResourceFileInfo对象的集合。在其他情况 下,EmbeddedFileProvider 的 GetDirectoryContents 方法总是返回一个NotFound DirectoryContents对象。
5.2.5 两个特殊的IFileProvider实现
PhysicalFileProvider 和 EmbeddedFileProvider 分别构建了针对物理文件与程序集内嵌文件的文件系统,除此之外,还有两个特殊的 IFileProvider 实现类型可供选择,它们分别是NullFileProvider和 CompositeFileProvider。NullFileProvider代表一个不包含任何内容的空文件系统,CompositeFileProvider 则是通过多个 IFileProvider 共同构建的组合式的文件系统。这两个特殊的 FileProvider 类型都定义在“Microsoft.Extensions.FileProviders.Abstractions”这个NuGet包中。
NullFileProvider
顾名思义,一个 NullFileProvider 对象代表一个不包含任何子目录和文件的空文件系统,所以它的 GetDirectoryContents方法和 GetFileInfo方法分别返回一个 NotFoundDirectoryContents对象和NotFoundFileInfo 对象。对于一个空的文件系统来说,并不存在所谓的目录和文件变化,所以其Watch方法返回一个NullChangeToken对象。相关的类型定义在如下所示的代码片段中。
CompositeFileProvider
NullFileProvider代表一个空的文件系统;CompositeFileProvider则正好相反,代表一个由多个 IFileProvider 构建的复合型文件系统。如下面的代码片段所示,当调用构造函数创建一个CompositeFileProvider对象时,需要提供一组构建这个复合型文件系统的IFileProvider对象。
由于 CompositeFileProvider由多个 IFileProvider对象构成,所以当调用其 GetFileInfo方法根据指定的路径获取对应文件时,它会遍历这些 IFileProvider 对象,直到找到一个存在的(对应 IFileInfo的 Exists属性返回 True)文件。如果所有的 IFileProvider都不能提供这个文件,它会返回一个NotFoundFileInfo对象。由于遍历的顺序取决于构建CompositeFileProvider时提供的IFileProvider 的顺序,所以如果对这些 IFileProvider 具有优先级的要求,应该将高优先级的IFileProvider对象放在前面。
对于表示复合文件系统的 CompositeFileProvider 来说,某个目录的内容是由所有这些内部IFileProvider 共同提供的,所以 GetDirectoryContents 方法返回的也是一个复合型的DirectoryContents,具体的类型为具有如下定义的CompositeDirectoryContents。与GetFileInfo方法一样,如果多个IFileProvider存在一个具有相同路径的文件,那么CompositeFileProvider总是从优先提供的IFileProvider中提取。
CompositeFileProvider的 Watch 方法返回的也是一个复合型的 IChangeToken对象,其类型就是前面介绍的 CompositeChangeToken。这个 CompositeChangeToken 对象由组成 Composite FileProvider 的所有 IFileProvider 来提供(通过调用 Watch 方法),所以它能监控任何一个IFileProvider对象对应的文件系统的变化,如下所示的代码片段体现了Watch方法的实现逻辑。