.NET Core的文件系统[4]:由EmbeddedFileProvider构建的内嵌(资源)文件系统
一个物理文件可以直接作为资源内嵌到编译生成的程序集中。借助于EmbeddedFileProvider,我们可以统一的编程方式来读取内嵌于某个程序集中的资源文件,不过在这之前我们必须知道如何将一个项目文件作为资源并嵌入到生成的程序集中。 [ 本文已经同步到《ASP.NET Core框架揭秘》之中]
目录
一、将项目文件变成内嵌资源
二、读取资源文件
三、EmbededFileProvider
一、将项目文件变成内嵌资源
在默认情况下,我们添加到一个.NET项目中的静态文件并不会成为项目编译生成的程序集的内嵌资源文件。如果需要,我们需要通过修改project.json文件中与编译相关的设置显式地将某个项目文件添加到内嵌资源文件列表中,这个与内嵌资源相关的配置选项就是“buildOptions/embed”。“buildOptions/embed”的配置结构比较典型,project.json文件中涉及到文件选择策略的绝大部分配置选项几乎都采用了这样的结构。除了用于选在内嵌资源文件的配置选项“buildOptions/embed”,其他与文件选择相关的配置选项还如下这些:
- buildOptions/compile:从当前项目中选择参与编译的源文件。
- buildOptions/copyToOutput:从当前项目中选择在编译时自动拷贝到输出目录(默认为bin目录)的文件。
- packOptions/files:从当前项目中选择在打包的时候添加到生车的NuGet包的文件。
- publishOptions:从当前项目中选择需要发布的文件。
对于包括“buildOptions/embed”在内的上述这五种配置选项,我们可以指定一个对象作为它的值。这个配置对象如下表所示的6个属性,我们可以利用“include”和“execlude”属性以Globbing Pattern表达式指定“包含”和“排除”的一组文件,也可以利用“includeFiles”和“execludeFiles”属性以文件路径(不含通配符)的形式将具体指定的文件“包含进来”或者“排除出去”。这些配置从本质上体现了针对一组项目文件的“转移”,在默认的情况源文件和目标文件具有完全一致的名称和相对路径,如果目标文件的路径或者名称不同,我们可以利用mapping属性对两者做一个映射。这些属性体现的路径都将项目所在的目录作为根路径。
属性 | 数据类型 | 描述 |
include | string/string[] | 以Globbing Pattern表达式形式指定的需要被包含进来的文件。 |
execlude | string/string[] | 以Globbing Pattern表达式形式指定的需要被排除出去的文件。它比include属性具有更高的优先级,所以如果include和exclude涉及到同一个文件,该文件会被排除出去。 |
includeFiles | string/string[] | 以文件路径形式指定的需要被包含进来的文件。它比exclude属性具有更高的优先级,所以execlude将某个文件排除出去,我们可以利用includeFiles属性将它重新包含进来。 |
execludeFiles | string/string[] | 以文件路径形式指定的需要被包含进来的文件。它的优先级比上述三个属性都高,所以include将某个文件包含进来后,我们可以利用excludeFiles属性将它重新排除出去。 |
buildIns | object | 这个对象具有include和exclude两个属性,表示系统默认提供的文件。builtIns的include和execlude属性与上述的同名属性具有相同的定义方式和作用。如果我们对include和builtIns/include(或者execlude和builtIns/execlude)都做了配置,系统在计算最终选择的文件列表时会对它们进行合并。 |
mappings | map | 转移过程源文件和目标文件在路径布局上的映射关系,其中Key代表目标文件的路径,至于Value,我们可以设置为源文件的路径,也可以设置为包含include, exclude,includeFiles and excludeFiles属性的对象。 |
接下来我们通过简单的实例来演示如何在project.json文件中对“buildOptions/embed”配置选项进行合理的设置从而将我们希望的文件内嵌到编译生成的程序集中。我们创建了一个空的.NET Core项目,并按照如下图所示的结构在根目录下创建了一个名为“root”的目录。总的来说该目录(含其子目录)一共包含4个文本文件,我们现在需要通过在project.json文件中设置它的“buildOptions/embed”配置选项,从而将相应的文本文件内嵌到项目编译生成的程序集中。
假设我们我们对“buildOptions/embed”配置选项做了如下三种不同的设置。由于include|exclude与builtIns/include|builtIns/exclude具有相同的作用,所以前三种定义方式在文件选择的角度上讲是完全等效的,最终作为内嵌资源的文件只有两个,那就是“root/dir1/foobar/foo.txt” 和“root/dir1/baz.txt”。在默认的情况下,内嵌的资源文件是根据源文件在项目中的路径来命名的,具体的命名规则为“{程序集名称}.{文件路径}”(路径分隔符替换成“.”),所以这两个资源文件的名称为“App.root.dir1.foobar.foo.txt”与“App.root.dir1.baz.txt”。对于第三种定义方式,我们通过mappings属性做了一个简单的路径映射,进而将两个资源文件的名称改成“foo.txt”和“baz.txt”。
定义1
1: {
2: ...
3: "buildOptions": {
4: ...
5: "embed": {
6: "include" : "root/**/*.txt",
7: "exclude" : "root/dir1/foobar/*.txt",
8: "includeFiles" : "root/dir1/foobar/foo.txt",
9: "excludeFiles" : "root/dir2/gux.txt"
10: }
11: }
12: }
定义2
1: {
2: ...
3: "buildOptions": {
4: ...
5: "embed": {
6: "builtIns": {
7: "include": "root/**/*.txt",
8: "exclude": "root/dir1/foobar/*.txt"
9: },
10: "includeFiles" : "root/dir1/foobar/foo.txt",
11: "excludeFiles" : "root/dir2/gux.txt"
12: }
13: }
14: }
定义3
1: {
2: ...
3: "buildOptions": {
4: ...
5: "embed": {
6: "builtIns": {
7: "include": "root/**/*.txt",
8: "exclude": "root/dir1/foobar/*.txt"
9: },
10: "includeFiles" : "root/dir1/foobar/foo.txt",
11: "excludeFiles" : "root/dir2/gux.txt"
12:
13: "mappings": {
14: "foo.txt": "root/dir1/foobar/foo.txt",
15: "baz.txt": "root/dir1/baz.txt"
16: }
17: }
18: }
19: }
除了将“buildOptions/embed”配置选项设置为上述这么一个对象之外,我们还具有一个更加简单的设置方式,那就是直接设置为一个Globbing Pattern表达式或者表达式数组。这样的设置相当于是将设置的Globbing Pattern表达式添加到incude列表中,所以如下所示的两种配置是完全等效的。
定义1
1: {
2: ...
3: "buildOptions": {
4: ...
5: "embed": {
6: "include" : ["root/**/foo.txt","root/**/bar.txt"]
7: }
8: }
9: }
定义2
1: {
2: ...
3: "buildOptions": {
4: ...
5: "embed" : ["root/**/foo.txt","root/**/bar.txt"]
6: }
7: }
8: }
二、读取资源文件
每个程序集都有一个清单文件(Manifest),它的一个重要作用就是记录组成程序集的所有文件。总的来说,一个程序集主要由两种类型的文件构成,它们分别是承载IL代码的托管模块文件和编译时内嵌的资源文件。针对图4所示的项目结果,如果我们将四个文本文件以资源文件的形式内嵌到生成的程序集(App.dll)中,程序集的清单文件将会采用如下所示的形式来记录它们。
1: .mresource public App.root.dir1.baz.txt
2: {
3: // Offset: 0x00000000 Length: 0x0000000C
4: }
5: .mresource public App.root.dir1.foobar.bar.txt
6: {
7: // Offset: 0x00000010 Length: 0x0000000C
8: }
9: .mresource public App.root.dir1.foobar.foo.txt
10: {
11: // Offset: 0x00000020 Length: 0x0000000C
12: }
13: .mresource public App.root.dir2.gux.txt
14: {
15: // Offset: 0x00000030 Length: 0x0000000C
16: }
表示程序集的Assembly对象定义了如下几个方法来提取内嵌资源的文件的相关信息和读取指定资源文件的内容。GetManifestResourceNames方法帮助我们获取记录在程序集清单文件中的资源文件名,而另一个方法GetManifestResourceInfo则获取指定资源文件的描述信息。如果我们需要读取某个资源文件的内容,我们可以将资源文件名称作为参数调用GetManifestResourceStream方法,该方法会返回一个读取文件内容的输出流。
1: public abstract class Assembly
2: {
3: public virtual string[] GetManifestResourceNames();
4: public virtual ManifestResourceInfo GetManifestResourceInfo(string resourceName);
5: public virtual Stream GetManifestResourceStream(string name);
6: }
三、EmbededFileProvider
在对内嵌于程序集的资源文件有了大致的了解之后,针对与对应的EmbeddedFileProvider的实现原理就很好理解了。虽然编译之前的原始文件以目录的形式进行组织,但是当我们内嵌到程序集之后,目录结构将不复存在,我们可以理解为所有的资源文件都保存在程序集的“根目录”下。所以在通过 EmbeddedFileProvider构建的文件系统中并没有目录层级的概念,它的FileInfo对象总是对一个具体资源文件的描述。具体来说,这个藐视资源文件的FileInfo是如下一个名为EmbeddedResourceFileInfo对象,EmbeddedResourceFileInfo类型定义在NuGet包“Microsoft.Extensions.FileProviders.Embedded”之中。
1: public class EmbeddedResourceFileInfo : IFileInfo
2: {
3: private readonly Assembly _assembly;
4: private long? _length;
5: private readonly string _resourcePath;
6:
7: public EmbeddedResourceFileInfo(Assembly assembly, string resourcePath, string name, DateTimeOffset lastModified)
8: {
9: _assembly = assembly;
10: _resourcePath = resourcePath;
11: this.Name = name;
12: this.LastModified = lastModified;
13: }
14:
15: public Stream CreateReadStream()
16: {
17: Stream stream = _assembly.GetManifestResourceStream(_resourcePath);
18: if (!this._length.HasValue)
19: {
20: this._length = new long?(stream.Length);
21: }
22: return stream;
23: }
24:
25: public bool Exists
26: {
27: get { return true; }
28: }
29:
30: public bool IsDirectory
31: {
32: get { return false; }
33: }
34:
35: public DateTimeOffset LastModified { get; private set; }
36:
37: public long Length
38: {
39: get
40: {
41: if (!this._length.HasValue)
42: {
43: using (Stream stream =_assembly.GetManifestResourceStream(this._resourcePath))
44: {
45: _length = new long?(stream.Length);
46: }
47: }
48: Return _length.Value;
49: }
50: }
51:
52: public string Name { get; private set;}
53:
54: public string PhysicalPath
55: {
56: get { return null; }
57: }
58: }
如上面的代码片段所示,我们在创建一个EmbeddedResourceFileInfo对象的时候需要指定内嵌资源文件在清单文件的中的名称(resourcePath)和所在的程序集,以及资源文件的“逻辑”名称(name)。由于一个EmbeddedResourceFileInfo对象总是对应着一个具体的内嵌资源文件,所以它的Exists属性返回True,IsDirectory属性返回False。由于资源文件系统并不具有层次还的目录结构,它所谓的物理路径毫无意义,所以PhysicalPath属性直接返回Null。CreateReadStream方法返回的是调用程序集的GetManifestResourceStream方法返回的输出流,而表示文件长度的Length返回的是这个Stream对象的长度。
如下所示的是 EmbeddedFileProvider的定义。当我们在创建一个EmbeddedFileProvider对象的时候,除了指定资源文件所在的程序集之外,还可以指定一个命名空间。对于由EmbeddedFileProvider构建的内嵌资源文件系统来说,文件的名称和这个命名空间共同组成资源文件在程序集清单中的文件名。同样以上图所示的这个项目为例,资源文件foo.txt在程序集清单中的文件名称为“App.root.dir1.foobar.foo.txt”,如果EmbeddedFileProvider采用的“App.root”作为命名空间,那么对应的资源文件在逻辑上的名称就应该是“dir1.foobar.foo.txt”,这就是我们在上面所谓的资源文件的逻辑名称。如果该命名空间没作显式设置,默认情况下会将程序集的名称“App”作为命名空间,那么这个资源文件的名称就应该是“root.dir1.foobar.foo.txt”。
1: public class EmbeddedFileProvider : IFileProvider
2: {
3: public EmbeddedFileProvider(Assembly assembly);
4: public EmbeddedFileProvider(Assembly assembly, string baseNamespace);
5:
6: public IDirectoryContents GetDirectoryContents(string subpath);
7: public IFileInfo GetFileInfo(string subpath);
8: public IChangeToken Watch(string pattern);
9: }
当我们指定资源文件的逻辑名称调用EmbeddedFileProvider的GetFileInfo方法时,该方法会将它与命名空间一起组成资源文件在程序集清单的名称(路径分隔符会被替换成“.”)。如果对应的资源文件存在,那么一个EmbeddedResourceFileInfo会被创建并返回,否则返回的将是一个NotFoundFileInfo对象。对于内嵌资源文件系统来说,根本就不存在所谓的文件更新的问题,所以它的Watch方法会返回一个HasChanged永远返回False的ChangeTokne对象。
由于 EmbeddedFileProvider构建的内嵌资源文件系统不存在层次化的目录结构,所有的资源文件可以视为统统存储在程序集的“根目录”下,所以它的GetDirectoryContents方法只有在我们指定一个空字符串或者“/”(空字符串和“/”都表示“根目录”)时才会返回一个描述这个“根目录”的DirectoryContents对象,该对象实际上是一组EmbeddedResourceFileInfo对象的集合。在其他情况下,EmbeddedFileProvider的GetDirectoryContents方法总是返回一个NotFoundDirectoryContents对象。