之前玩過將 html/js/css/png 等靜態檔案轉成內嵌資源讓 Minimal API 程式徹底實現單一檔案。更進一步,有沒有可能自訂其他靜態檔案來源呢?例如像 Sharepoint 把 html、css、js 存進資料庫,但用起來跟一般網站靜態檔案沒有兩樣。能依需求輕易擴充修改,將網站捏成你想要的形狀,是 ASP.NET Core 標榜的一大特色,就來看看要如何實現這個想法吧。

從 ASP.NET 改用 ASP.NET Core 的朋友一定明顯感受到架構的革命性改變,存取服務要靠介面 (ILogger、IConfiguration、IMemoryCache),要走依賴注入,需花點時間熟悉適應,一旦遇到特殊需求,便能體會到新架構看似複雜的設計,就是為了在關鍵時刻換取必要的彈性。

動手之前,需對 ASP.NET Core 靜態檔案有基本了解,推薦這篇 Static files in ASP.NET Core。要使用靜態檔案,Program.cs 要依需求完成設定,例如:

  1. app.UseStaticFiles()
    讓 wwwroot 下的資料夾及檔案可直接透過 URL 讀取,例如:~/css/site.css 對映 wwwroot/css/site.css。
  2. 若要使用 wwwroot 以外的資料夾保存靜檔案
     app.UseStaticFiles(new StaticFileOptions
     {
         FileProvider = new PhysicalFileProvider(
                Path.Combine(builder.Environment.ContentRootPath, "MyStaticFiles")),
         RequestPath = "/StaticFiles"
     });
    
    允許以 ~/StaticFiles/ URL 存取專案 MyStaticFiles 資料夾內的靜態檔案 參考
  3. app.UseDefaultFiles() 會啟用預設文件,尋找 index.html、index.htm、default.html、default.htm 等檔案作為目錄 URL 的預設網頁
  4. app.UseFileServer() 整合了 UseStaticFiles、UseDefaultFiles及 UseDirectoryBrowser 功能 (目錄內容瀏覽網頁預設關閉,基於安全不建議開啟) 參考
  5. UseStaticFiles 與 UseFileServer 可執行多次,由多個來源取得檔案 參考

UseStaticFiles 是依賴實作 IFileProvider 的物件讀取檔案,預設使用指向 wwwroot 實體目錄的 PhysicalFileProvider 物件,之前內嵌資源用的 ManifestEmbeddedFileProvider 是另一顆實作 IFileProvider 介面的物件。換言之,只要寫一顆元件實作 IFileProvider,便能依我們想要的方式儲存及取得靜態檔案內容。這篇先來個簡單練習,我打算把 html、css、gif 塞進 JSON 裡,實作一個由 JSON 取得檔案內容的 IFileProvider :

[
    {
        "path": "/index.html",
        "content": "<html><body><link href=css/site.css rel=stylesheet /> Web in JSON<img src=imgs/logo.gif /></body></html>"
    },
    {
        "path": "/imgs/logo.gif",
        "content": "data:image/gif;base64,R0lGODlhSABIAH...略...AGCIABAQA7"
    },
    {
        "path": "/css/site.css",
        "content": "body { font-size: 9pt } img { display: block; margin-top: 5px; }"
    }
]

我寫了一個精簡版 StaticFileJsonProvider 實作 IFileProvider,主要提供 IDirectoryContents GetDirectoryContents(string subpath)、public IFileInfo GetFileInfo(string subpath) 兩個方法,傳回型別需為 IDirectoryContents、IFileInfo 介面,也要宣告類別實作。大致概念就這樣,邏輯很單純,讀取並解析 JSON 成物件陣列,比對 Path 找到檔案。

using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;

namespace Guineapig.AspNetCore.FileProviders
{
    public class FileEntry
    {
        public string Path { get; set; }
        public string Content { get; set; }
        public byte[] ToByteArray() =>
            Content.StartsWith("data:") && Content.Contains(";base64,") ?
            Convert.FromBase64String(Content.Substring(Content.IndexOf(',') + 1)) :
            System.Text.Encoding.UTF8.GetBytes(Content);
        public Stream GetStream() => new MemoryStream(ToByteArray());
        public static DateTime LastModified = DateTime.UtcNow;
    }

    public class StaticFileJsonProvider : IFileProvider
    {
        FileEntry[] files;
        public StaticFileJsonProvider(string jsonPath)
        {
            FileEntry.LastModified = File.GetLastWriteTimeUtc(jsonPath);
            files = JsonSerializer.Deserialize<FileEntry[]>(File.ReadAllText(jsonPath), new JsonSerializerOptions
            {
                PropertyNamingPolicy = null,
                PropertyNameCaseInsensitive = true
            })!;
        }
        public IDirectoryContents GetDirectoryContents(string subpath)
        {
            Debug.WriteLine($"GetDirectoryContents - {subpath}");
            return new DirectoryContents(files.Where(o => o.Path.StartsWith(subpath)));
        }
        public IFileInfo GetFileInfo(string subpath)
        {
            Debug.WriteLine($"GetFileInfo - {subpath}");
            var find = files.FirstOrDefault(o => o.Path == subpath);
            if (find != null) return new FileInfo(find);
            return new NotFoundFileInfo(subpath);
        }
        public IChangeToken Watch(string filter) => NullChangeToken.Singleton;
    }

    public class DirectoryContents : IDirectoryContents
    {
        IFileInfo[] files;
        public DirectoryContents(IEnumerable<FileEntry> files)
        {
            this.files = files.Select(o => new FileInfo(o)).ToArray();
        }
        public bool Exists => true;
        public IEnumerator<IFileInfo> GetEnumerator()
        {
            foreach (var f in files) yield return f;
        }
        IEnumerator IEnumerable.GetEnumerator() => files.GetEnumerator();
    }
    public class FileInfo : IFileInfo
    {
        Stream stream;
        public FileInfo(FileEntry file)
        {
            stream = file.GetStream();
            Name = Path.GetFileName(file.Path);
        }
        public bool Exists => true;
        public bool IsDirectory => false;
        public DateTimeOffset LastModified => FileEntry.LastModified;
        public long Length => stream.Length;
        public string Name { get; private set; }
        public string PhysicalPath => null!;
        public Stream CreateReadStream() => stream;
    }
}

Program.cs 部分只需加上一條 UseStaticFiles():

using Guineapig.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseStaticFiles(new StaticFileOptions {
    FileProvider = new StaticFileJsonProvider(
        Path.Combine(app.Environment.ContentRootPath, "StaticFiles.json")),
    RequestPath = "/web-in-json"
});

app.MapGet("/", () => "Hello World!");

app.Run();

補充幾則眉角:

  1. RequestPath = "/web-in-json" 參數會過濾以 /web-in-json 起首的 URL,傳入 GetDirectoryContents() 或 GetFileInfo() 的 subpath 不包含 /web-in-json,例如,URL 若為 /web-in-json/css/site.css,subpath 參數為 /css/site.css。
  2. 若要輸入目錄 URL 自動導向 index.html,UseStaticFiles() 可改寫為 UseFileServer();或是在 UseStaticFiles() 前加上 UseDefaultFiles(),但二者的 FileProvider 必須共用。
     var fp= new StaticFileJsonProvider(
         Path.Combine(app.Environment.ContentRootPath, "StaticFiles.json"));
     app.UseDefaultFiles(new DefaultFilesOptions {
         FileProvider = fp,
         RequestPath = "/web-in-json"
     });
     app.UseStaticFiles(new StaticFileOptions {
         FileProvider = fp,
         RequestPath = "/web-in-json"
     });
    
  3. 啟用 UseFileServer() 或 UseDefaultFiles(),GetDirectoryContents() 及 GetFileInfo() 呼叫頻率會提高,要留意效能。

實測成功!

範例專案已放上 Github,需要的同學請自取。

A example of custom IFileProvider for ASP.NET Core, it uses JSON file to store static files and shows how IFileProvidere works.


Comments

Be the first to post a comment

Post a comment