[ASP.NET Core 3框架揭秘] 文件系統[1]:抽象的「文件系統」
- 2019 年 11 月 20 日
- 筆記
ASP.NET Core應用 具有很多讀取文件的場景,比如配置文件、靜態Web資源文件(比如CSS、JavaScript和圖片文件等)以及MVC應用的View文件,甚至是直接編譯到程式集中的內嵌資源文件。這些文件的讀取都需要使用到一個IFileProvider對象。IFileProvider對象構建了一個抽象的文件系統,我們不僅可以利用它提供的統一API來讀取各種類型的文件,還能及時監控目標文件的變化。
一、樹形層次結構
IFileProvider對象為我們構建了一個具有層次化目錄結構的文件系統。由於IFileProvider是一個介面,所以由它構建的是一個抽象化的文件系統,這裡所謂的目錄和文件都是一個抽象的概念。具體的文件可能對應一個物理文件,也可能保存在資料庫中,或者來源於網路,甚至有可能根本就不存在,其內容需要在讀取時動態生成。目錄也僅僅是組織文件的邏輯容器。為了讓讀者朋友們對這個文件系統有一個大體認識,我們先來演示幾個簡單的實例。
文件系統管理的所有文件以目錄的形式進行組織,一個IFileProvider對象可以視為針對一個根目錄的映射。目錄除了可以存放文件之外,還可以包含子目錄,所以目錄/文件在整體上呈現出樹形化層次化結構。接下來我們將一個IFileProvider對象映射到一個物理目錄,並利用它將所在目錄的結構呈現出來。
我們演示實例是一個普通的控制台程式。我們在演示實例中定義了如下一個IFileManager介面,它利用一個唯一的ShowStructure方法將文件系統的整體結構顯示出來。該方法具有一個類型為Action<int, string>的參數負責將文件系統的節點(目錄或者文件)名稱呈現出來。這個Action<int, string>對象的兩個參數分別代表縮進的層級和目錄/文件的名稱。
public interface IFileManager { void ShowStructure(Action<int, string> render); }
我們定義如下這個FileManager類作為對IFileManager介面的默認實現,它利用只讀_fileProvider欄位表示的IFileProvider對象來提取目錄結構。目標文件系統的整體結構通過Render方法以遞歸的方式呈現出來,其中涉及到對IFileProvider對象的GetDirectoryContents方法的調用。該方法返回一個IDirectoryContents對象表示指定目錄的內容,如果對應的目錄存在,我們可以遍歷該對象得到它的子目錄和文件。目錄和文件最終體現為一個IFileInfo對象來,至於IFileInfo對象對應的就是一個目錄還是一個文件,則通過其IsDirectory屬性來區分。
public class FileManager : IFileManager { private readonly IFileProvider _fileProvider; public FileManager(IFileProvider fileProvider) => _fileProvider = fileProvider; public void ShowStructure(Action<int, string> render) { int indent = -1; Render(""); void Render(string subPath) { indent++; foreach (var fileInfo in _fileProvider.GetDirectoryContents(subPath)) { render(indent, fileInfo.Name); if (fileInfo.IsDirectory) { Render($@"{subPath}{fileInfo.Name}".TrimStart('\')); } } indent--; } } }
接下來我們構建一個本地物理目錄「c:test」,並按照如下圖所示的結構在它下面創建相應的子目錄和文件。我們會將這個目錄映射到一個IFileProvider對象上,並進一步利用它創建出上面這個FileManager對象。我們最終調用這個FileManager對象的ShowStructure方法將目錄結構呈現出來。

整個演示程式體現在如下的程式碼片段中。我們針對目錄「c:test」創建了一個表示物理文件系統的PhysicalFileProvider對象,並將其註冊到創建的ServiceCollection對象上。除此之外,ServiceCollection對象上還添加了針對IFileManager/FileManager的服務註冊。
class Program { static void Main() { static void Print(int layer, string name) => Console.WriteLine($"{new string(' ', layer * 4)}{name}"); new ServiceCollection() .AddSingleton<IFileProvider>(new PhysicalFileProvider(@"c:test")) .AddSingleton<IFileManager, FileManager>() .BuildServiceProvider() .GetRequiredService<IFileManager>() .ShowStructure(Print); } }
我們最終利用ServiceCollection生成的IServiceProvider對象得到FileManager對象,並調用該對象的ShowStructure方法將PhysicalFileProvider對象映射的目錄結構呈現出來。當我們運行該程式之後,控制台上將呈現出如下圖所示的輸出結果,該結果為我們展示了映射物理目錄的真實結構。(S501)

二、讀取文件內容
前面我們演示了如何利用IFileProvider對象將文件系統的結構完整地呈現出來,接下來我們來演示如何利用它來讀取一個物理文件的內容。我們為IFileManager定義如下一個ReadAllTextAsync方法以非同步的方式讀取指定文件內容,方法的參數表示文件的路徑。如下面的程式碼片段所示,ReadAllTextAsync方法將指定的文件路徑作為參數調用IFileProvider對象的GetFileInfo方法得到一個IFileInfo對象。我們最終調用這個IFileInfo對象的CreateReadStream方法得到讀取文件的輸出流,進而得到文件的真實內容。
public interface IFileManager { ... Task<string> ReadAllTextAsync(string path); } public class FileManager : IFileManager { ... public async Task<string> ReadAllTextAsync(string path) { byte[] buffer; using (var stream = _fileProvider.GetFileInfo(path).CreateReadStream()) { buffer = new byte[stream.Length]; await stream.ReadAsync(buffer, 0, buffer.Length); } return Encoding.Default.GetString(buffer); } }
假設我們依然將FileManager使用的IFileProvider映射為目錄「c:test」,現在我們在該目錄中創建一個名為data.txt的文本文件,並在該文件中任意寫入一些內容。接下來我們在Main方法中編寫了如下的程式利用依賴注入的方式得到FileManager對象,並讀取文件data.txt的內容。最終的調試斷言旨在確定通過IFileProvider讀取的確實就是目標文件的真實內容。(S502)
class Program { static async Task Main() { var content = await new ServiceCollection() .AddSingleton<IFileProvider>(new PhysicalFileProvider(@"c:test")) .AddSingleton<IFileManager, FileManager>() .BuildServiceProvider() .GetRequiredService<IFileManager>() .ReadAllTextAsync("data.txt"); Debug.Assert(content == File.ReadAllText(@"c:testdata.txt")); } }
三、內嵌文件系統
我們一直在強調由IFileProvider結構構建的是一個抽象的具有目錄結構的文件系統,具體文件的提供方式取決於對具體的IFileProvider對象是怎樣一個類型。我們演示實例定義的FileManager並沒有限定具體使用何種類型的IFileProvider,該對象是在應用中通過依賴注入的方式指定的。由於上面的應用程式注入的是一個PhysicalFileProvider對象,所以我們可以利用它來讀取對應物理目錄下的某個文件。假設現在將這個data.txt直接以資源文件的形式編譯到程式集中,我們就需要使用另一個名為EmbeddedFileProvider的實現類型。現在我們直接將這個data.txt文件添加到控制台應用的項目根目錄下。在默認的情況下,當我們編譯項目的時候這樣的文件並不能成為內嵌到目標程式集的資源文件,我們需要利用VS將該文件的「Build Action」屬性按照如下所示的方式設置為「Embedded resource」。

上圖所示的設置將會體現在項目文件(.csproj文件)上。具體來說,項目文件會以如下的形式添加一個<EmbeddedResource>元素將文件data.txt設置為內嵌到編譯後生成的程式集的內嵌資源文件。
<Project Sdk="Microsoft.NET.Sdk"> ... <ItemGroup> <EmbeddedResource Include="data.txt"/> </ItemGroup> </Project>
我們編寫了如下的程式來演示針對內嵌於程式集中的資源文件的讀取。我們首先得到當前入口程式集,並利用它創建了一個EmbeddedFileProvider對象,它代替原來的PhysicalFileProvider對象被註冊到ServiceCollection之中。我們接下來採用了完全一致的編程方式得到FileManager對象並利用它讀取內嵌文件data.txt的內容。為了驗證讀取的目標文件準確無誤,我們採用直接讀取資源文件的方式得到了內嵌文件data.txt的內容,並利用一個調試斷言確定兩者的一致性。(S503)
class Program { static async Task Main() { var assembly = Assembly.GetEntryAssembly(); var content1 = await new ServiceCollection() .AddSingleton<IFileProvider>(new EmbeddedFileProvider(assembly)) .AddSingleton<IFileManager, FileManager>() .BuildServiceProvider() .GetRequiredService<IFileManager>() .ReadAllTextAsync("data.txt"); var stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.data.txt"); var buffer = new byte[stream.Length]; stream.Read(buffer, 0, buffer.Length); var content2 = Encoding.Default.GetString(buffer); Debug.Assert(content1 == content2); } }
四、監控文件的變化
在文件讀取場景中,確定載入到記憶體中的數據與源文件的一致性並自動同步是一個很常見的需求。比如說我們將配置定義在一個JSON文件中,應用啟動的時候會讀取該文件並將其轉換成對應的Options對象。在很多情況下,如果我們改動了配置文件, 最新的配置數據只有在應用重啟之後才能生效。如果我們能夠以一種高效的方式對配置文件進行監控,並在其發生改變的情況下嚮應用發送通知,那麼應用就能在不用重啟的情況下重新讀取配置文件,進而實現Options對象承載的內容和原始配置文件完全同步。
對文件系統實施監控並在其發生改變時發送通知也是IFileProvider對象提供的核心功能之一。接下來我們依然使用前面這個程式來演示如何使用PhysicalFileProvider對某個物理文件實施監控,並在目標文件的內容發生改變的時候重新讀取新的內容。
class Program { static async Task Main() { using (var fileProvider = new PhysicalFileProvider(@"c:test")) { string original = null; ChangeToken.OnChange(() => fileProvider.Watch("data.txt"), Callback); while (true) { File.WriteAllText(@"c:testdata.txt", DateTime.Now.ToString()); await Task.Delay(5000); } async void Callback() { var stream = fileProvider.GetFileInfo("data.txt").CreateReadStream(); { var buffer = new byte[stream.Length]; await stream.ReadAsync(buffer, 0, buffer.Length); string current = Encoding.Default.GetString(buffer); if (current != original) { Console.WriteLine(original = current); } } } } } }
如上面的程式碼片段所示,我們針對目錄「c:test」創建了一個PhysicalFileProvider對象,並調用其Watch方法對指定的文件data.txt實施監控。該方法的返回一個IChangeToken對象,我們正是利用這個對象接收文件改變的通知。我們調用ChangeToken的靜態方法OnChange針對這個對象註冊了一個回調實現對源文件的重新讀取和顯示,當源文件發生改變的時候,註冊的回調會自動執行。我們以每隔5秒的間隔對文件data.txt作一次修改,而文件的內容為當前時間。所以當我們的程式啟動之後,每隔5秒鐘當前時間就會以如下圖的方式呈現在控制台上。
