1. 程式人生 > >[ASP.NET Core 3框架揭祕] 檔案系統[4]:程式集內嵌檔案系統

[ASP.NET Core 3框架揭祕] 檔案系統[4]:程式集內嵌檔案系統

一個物理檔案可以直接作為資源內嵌到編譯生成的程式集中。藉助於EmbeddedFileProvider,我們可以採用統一的程式設計方式來讀取內嵌的資原始檔,該型別定義在 “Microsoft.Extensions.FileProviders.Embedded”這個NuGet包中。在正式介紹EmbeddedFileProvider之前,我們必須知道如何將一個專案檔案作為資源內嵌入到編譯生成的程式集中。

一、將專案檔案變成內嵌資源

在預設情況下,我們新增到一個.NET Core專案中的靜態檔案並不會成為目標程式集的內嵌資原始檔。如果需要將靜態檔案作為目標程式集的內嵌檔案,我們需要修改當前專案對應的.csproj檔案。具體來說,我們需要按照前面例項演示的方式在.csproj檔案中新增<ItemGroup>/<EmbeddedResource>元素,並利用Include屬性顯式地將對應的資原始檔包含進來。當我們直接利用Visual Studio將資原始檔的Build Action屬性設定為“Embedded resource”,IDE會自動幫助我們修改專案檔案。

<EmbeddedResource>的Include屬性可以設定多個路徑,路徑之間採用分號(“;”)作為分隔符。以上圖所示的目錄結構為例,如果我們需要將root目錄下的四個檔案作為程式集的內嵌檔案,我們可以修改.csproj檔案並按照如下的形式將四個檔案的路徑包含進來。

<Project Sdk="Microsoft.NET.Sdk">
    ...
    <ItemGroup>
        <EmbeddedResource  
            Include="root/dir1/foobar/foo.txt;root/dir1/foobar/bar.txt;root/dir1/baz.txt;root/dir2/qux.txt"></EmbeddedResource> 
    </ItemGroup>
</Project>

除了指定每個需要內嵌的資原始檔的路徑之外,我們還可以採用基於萬用字元“*”和“**”的Globbing Pattern表示式將一組匹配的檔案批量包含進來。同樣是將root目錄下的所有檔案作為程式集的內嵌檔案,如下的定義方式就會簡潔得多。

<Project Sdk="Microsoft.NET.Sdk">
    ...
    <ItemGroup>
        <EmbeddedResource  Include="root/**"></EmbeddedResource> 
    </ItemGroup>
</Project>

<EmbeddedResource>除了具有一個Include屬性用來新增內嵌資原始檔之外,它還具有另一個Exclude屬性負責將不符合要求的檔案排除出去。還是以前面這個專案為例,對於root目錄下的四個檔案,如果我們不希望檔案baz.txt作為內嵌資原始檔,我們可以按照如下的方式將它排除。

<Project Sdk="Microsoft.NET.Sdk">
    ...
    <ItemGroup>
        <EmbeddedResource  Include="root/**" Exclude="root/dir1/baz.txt"></EmbeddedResource> 
    </ItemGroup>
</Project>

二、讀取資原始檔

每個程式集都有一個清單檔案(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物件。

[ASP.NET Core 3框架揭祕] 檔案系統[1]:抽象的“檔案系統”
[ASP.NET Core 3框架揭祕] 檔案系統[2]:總體設計
[ASP.NET Core 3框架揭祕] 檔案系統[3]:物理檔案系統
[ASP.NET Core 3框架揭祕] 檔案系統[4]:程式集內嵌檔案系統