[ASP.NET Core 3框架揭秘] 文件系统[4]:程序集内嵌文件系统

一个物理文件可以直接作为资源内嵌到编译生成的程序集中。借助于EmbeddedFileProvider,我们可以采用统一的编程方式来读取内嵌的资源文件,该类型定义在 “Microsoft.Extensions.FileProviders.Embedded”这个NuGet包中。在正式介绍EmbeddedFileProvider之前,我们必须知道如何将一个项目文件作为资源内嵌入到编译生成的程序集中。

一、将项目文件变成内嵌资源

在默认情况下,我们添加到一个.NET Core项目中的静态文件并不会成为目标程序集的内嵌资源文件。如果需要将静态文件作为目标程序集的内嵌文件,我们需要修改当前项目对应的.csproj文件。具体来说,我们需要按照前面实例演示的方式在.csproj文件中添加/元素,并利用Include属性显式地将对应的资源文件包含进来。当我们直接利用Visual Studio将资源文件的Build Action属性设置为“Embedded resource”,IDE会自动帮助我们修改项目文件。

的Include属性可以设置多个路径,路径之间采用分号(“;”)作为分隔符。以上图所示的目录结构为例,如果我们需要将root目录下的四个文件作为程序集的内嵌文件,我们可以修改.csproj文件并按照如下的形式将四个文件的路径包含进来。

    ...
    
         
    

除了指定每个需要内嵌的资源文件的路径之外,我们还可以采用基于通配符“*”和“**”的Globbing Pattern表达式将一组匹配的文件批量包含进来。同样是将root目录下的所有文件作为程序集的内嵌文件,如下的定义方式就会简洁得多。

    ...
    
         
    

除了具有一个Include属性用来添加内嵌资源文件之外,它还具有另一个Exclude属性负责将不符合要求的文件排除出去。还是以前面这个项目为例,对于root目录下的四个文件,如果我们不希望文件baz.txt作为内嵌资源文件,我们可以按照如下的方式将它排除。

    ...
    
         
    

二、读取资源文件

每个程序集都有一个清单文件(Manifest),它的一个重要作用就是记录组成程序集的所有文件成员。总的来说,一个程序集主要由两种类型的文件构成,它们分别是承载IL代码的托管模块文件和编译时内嵌的资源文件。针对上图所示的项目结构,如果我们将四个文本文件以资源文件的形式内嵌到生成的程序集(App.dll)中,程序集的清单文件将会采用如下所示的形式来记录它们。

.mresource public App.root.dir1.baz.txt
{
  // Offset: 0x00000000 Length: 0x0000000C
}
.mresource public App.root.dir1.foobar.bar.txt
{
  // Offset: 0x00000010 Length: 0x0000000C
}
.mresource public App.root.dir1.foobar.foo.txt
{
  // Offset: 0x00000020 Length: 0x0000000C
}
.mresource public App.root.dir2.qgux.txt
{
  // Offset: 0x00000030 Length: 0x0000000C
}

虽然文件在原始的项目中具有层次化的目录结构,但是当它们成功转移到编译生成的程序集中之后,目录结构将不复存在,所有的内嵌文件将统一存放在同一个容器中。如果我们通过Reflector打开程序集,资源文件的扁平化存储将会一目了然。为了避免命名冲突,编译器将会根据原始文件所在的路径来对资源文件重新命名,具体的规则是“{BaseNamespace}.{Path}”,目录分隔符将统一转换成“.”。值得强调的是资源文件名称的前缀不是程序集的名称,而是我们为项目设置的基础命名空间的名称。

表示程序集的Assembly对象定义了如下几个方法来提取内嵌资源的文件的相关信息和读取指定资源文件的内容。GetManifestResourceNames方法帮助我们获取记录在程序集清单文件中的资源文件名,而另一个方法GetManifestResourceInfo则用于获取指定资源文件的描述信息。如果我们需要读取某个资源文件的内容,我们可以将资源文件名称作为参数调用GetManifestResourceStream方法,该方法会返回一个读取文件内容的Stream对象。

public abstract class Assembly
{   
    public virtual string[] GetManifestResourceNames();
    public virtual ManifestResourceInfo GetManifestResourceInfo(string resourceName);
    public virtual Stream GetManifestResourceStream(string name);
}

同样是针对前面这个演示项目对应的目录结构,当四个文件作为内嵌文件被成功转移到编译生成的程序集中后,我们可以调用程序集对象的GetManifestResourceNames方法获取这四个内嵌文件的资源名称。如果以资源名称(“App.root.dir1.foobar.foo.txt”)作为参数调用GetManifestResourceStream方法,我们可以读取资源文件的内容,具体的演示如下所示。

class Program
{
    static void Main()
    {
        var assembly = typeof(Program).Assembly;
        var resourceNames = assembly.GetManifestResourceNames();
        Debug.Assert(resourceNames.Contains("App.root.dir1.foobar.foo.txt"));
        Debug.Assert(resourceNames.Contains("App.root.dir1.foobar.bar.txt"));
        Debug.Assert(resourceNames.Contains("App.root.dir1.baz.txt"));
        Debug.Assert(resourceNames.Contains("App.root.dir2.qgux.txt")); 

        var stream = assembly.GetManifestResourceStream("App.root.dir1.foobar.foo.txt");
        var buffer = new byte[stream.Length];
        stream.Read(buffer, 0, buffer.Length);
        var content = Encoding.Default.GetString(buffer);  
        Debug.Assert(content == File.ReadAllText("App/root/dir1/foobar/foo.txt"));
    }
}

三、EmbeddedFileProvider

在对内嵌于程序集的资源文件有了大致的了解之后,针对EmbeddedFileProvider的实现原理就很好理解了。由于内嵌于程序集的资源文件采用扁平化存储形式,所以在通过 EmbeddedFileProvider构建的文件系统中并没有目录层级的概念。我们可以认为所有的资源文件都保存在程序集的“根目录”下。对于EmbeddedFileProvider构建的文件系统来说,它提供的IFileInfo对象总是对一个具体资源文件的描述,这是一个具有如下定义的EmbeddedResourceFileInfo对象。

public class EmbeddedResourceFileInfo : IFileInfo
{
    private readonly Assembly     _assembly;
    private long? _length;
    private readonly string  _resourcePath;

    public EmbeddedResourceFileInfo(Assembly assembly, string resourcePath, string name, DateTimeOffset lastModified)
    {
        _assembly = assembly;
        _resourcePath = resourcePath;
        this.Name = name;
        this.LastModified = lastModified;
    }

    public Stream CreateReadStream()
    {
        Stream stream = _assembly.GetManifestResourceStream(_resourcePath);
        if (!this._length.HasValue)
        {
            this._length = new long?(stream.Length);
        }
        return stream;
    }
    
    public bool Exists => true;
    public bool IsDirectory => false;
    public DateTimeOffset LastModified { get; }    

    public string Name { get; }
    public string PhysicalPath => null;
    public long Length
    {
        get
        {
            if (!_length.HasValue)
            {
                using (Stream stream =_assembly.GetManifestResourceStream(this._resourcePath))
                {
                    _length = stream.Length;
                }
            }
            rReturn _length.Value;
        }
    }
}

如上面的代码片段所示,我们在创建一个EmbeddedResourceFileInfo对象的时候需要指定内嵌资源文件在清单文件的中的路径(resourcePath)、所在的程序集、资源文件的名称(name)和作为文件最后修改时间的DateTimeOffset对象。由于一个EmbeddedResourceFileInfo对象总是对应着一个具体的内嵌资源文件,所以它的Exists属性总是返回True,IsDirectory属性则返回False。由于资源文件系统并不具有层次化的目录结构,它所谓的物理路径毫无意义,所以PhysicalPath属性直接返回Null。CreateReadStream方法返回的是调用程序集的GetManifestResourceStream方法返回的输出流,而表示文件长度的Length返回的是这个Stream对象的长度。

如下所示的是 EmbeddedFileProvider的定义。当我们在创建一个EmbeddedFileProvider对象的时候,除了指定资源文件所在的程序集之外,还可以指定一个基础命名空间。如果该命名空间没作显式设置,默认情况下会将程序集的名称作为命名空间,也就是说如果我们为项目指定了一个不同于程序集名称的基础命名空间,那么当创建这个EmbeddedFileProvider对象的时候必须指定这个命名空间。

public class EmbeddedFileProvider : IFileProvider
{   
    public EmbeddedFileProvider(Assembly assembly);
    public EmbeddedFileProvider(Assembly assembly, string baseNamespace);

    public IDirectoryContents GetDirectoryContents(string subpath);
    public IFileInfo GetFileInfo(string subpath);
    public IChangeToken Watch(string pattern);
}

当我们调用EmbeddedFileProvider的GetFileInfo方法并指定资源文件的逻辑名称时,该方法会将它与命名空间一起组成资源文件在程序集清单的名称(路径分隔符会被替换成“.”)。如果对应的资源文件存在,那么一个EmbeddedResourceFileInfo会被创建并返回,否则返回的将是一个NotFoundFileInfo对象。对于内嵌资源文件系统来说,根本就不存在所谓的文件更新的问题,所以它的Watch方法会返回一个HasChanged属性总是False的IChangeToken对象。

由于内嵌于程序集的资源文件总是只读的,它所谓的最后修改时间实际上是程序集的生成日期,所以EmbeddedFileProvider在提供EmbeddedResourceFileInfo对象的时候会采用程序集文件的最后更新时间作为资源文件的最后更新时间。如果不能正确地解析出这个时间,EmbeddedResourceFileInfo的LastModified属性将被设置为当前UTC时间。

由于 EmbeddedFileProvider构建的内嵌资源文件系统不存在层次化的目录结构,所有的资源文件可以视为统统存储在程序集的“根目录”下,所以它的GetDirectoryContents方法只有在我们指定一个空字符串或者“/”(空字符串和“/”都表示“根目录”)时才会返回一个描述这个“根目录”的DirectoryContents对象,该对象实际上是一组EmbeddedResourceFileInfo对象的集合。在其他情况下,EmbeddedFileProvider的GetDirectoryContents方法总是返回一个NotFoundDirectoryContents对象。