上回提到 ASP.NET Core 架構大幅革新,大量以介面取代直接引用類別,並透過依賴注入(DI)取得服務或元件,付出複雜化的代價,為的是在關鍵時刻換取擴充彈性,前篇文章神不知鬼不覺地將 .html、.css、.png 移進 JSON 檔,取代用 wwwroot 存放靜態檔案,便是一個範例。用 JSON 只為方便展示,實質意義不大,將靜態檔案存進資料庫,方便管理換版又能讓多台網站共享,才是我的終極目標。

於是,以 StaticFileJsonProvider 概念為基礎,我又寫了以資料表當作資料來源的版本 - StaticFileDbProvider。

我先設計以下資料表結構:(以 SQLite 示範)

StaticFileIndices 用來放目錄或檔案項目,StaticFileDatas 則存放實際檔案內容,檔案更新時不覆寫舊內容,StaticFileIndices 指向新內容,不清除舊檔案或刪除檔案,只將 StaticFileDatas.Status 改為 'D',並在 Remark 欄位註記何時被哪個使用者、哪個 IP 異動,如有疑義較好追查歷程。

將上回 JSON 的三個檔案存入資料庫,index.html、css/site.css、imgs/logo.gif 分別指向 FileIndexId 4, 2, 3,FileIdexId = -1 的項目是資料夾:

/index.html 更新過一次,第一筆的 Status = 'D',後方 Remark 則有被置換的記錄,第四筆的 Path 與第一筆相同取而代之:

原始碼已放上 Github,實做細節這裡就不花篇幅解釋,基本概念不外乎:定義 Model,宣告 EF Core DbContext,寫 Repository 處理查詢資料夾、刪除資料夾、上傳/更新/刪除及讀取檔案內容等邏輯,實作 IDirectoryContents、IFileInfo、IFileProvider... 想參考的同學可前往 Github 挖掘(若發現 Bug 也請回報給我),我們直接看如何使用它。

網路上查到的 EF Core DbContext 範例大多把 DbContext 寫在 Web 專案裡,我把 StaticFileDbProvider 寫成獨立專案 - Drk.AspNetCore.FileProviders,並設定輸出 Drk.AspNetCore.FileProviders.x.nupkg,實際應用時可透過 NuGet 安裝(已上傳到 NuGet Gallery可直接測試),那這樣要怎麼設定用什麼資料庫?如何建立資料表?

很簡單,在 ASP.NET Core 專案從 NuGet 參考 Drk.AspNetCore.FileProviders,在 Program.cs 裡 AddDbContext() 註冊 StaticFileDbContext,用 UseSqlite() 或 UseSqlServer() 決定要使用的資料庫並加上 b => b.MigrationAssembly("Web專案DLL名稱"),如:options.UseSqlite($"data source={sqliteDbPath}", b => b.MigrationsAssembly("demo-web"));),然後參考前篇文章介紹的技巧doent ef migrations add InitialCreate --context StaticFileDbContext --output-dir Migrations\StatFileProvider 建立 Migrations 程式,再執行 dotnet ef database update --context StaticFileDbContext 或在程式 DbContext.Database.Migrate() 即可建立資料表。元件只提供 EF Core Model 定義,要使用 SQLite、SQL、PostgreSQL 或 Oracle,可依應用程式專案決定,執行 dotnet ef migration add 時會依不用資料庫對映適合的欄位資料表,實現跨資料庫,很酷吧! (註:我實測了 SQLite 及 SQL Server,理論上其他資料庫也可直接使用不需修改,如有問題請再回饋給我)

Github 原始碼裡有個展示用的 DemoWeb 專案,其 Program.cs 如下,預設使用 SQLite:

using System.Text;
using Drk.AspNetCore.FileProviders;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMemoryCache();
var sqliteDbPath = Path.Combine(builder.Environment.ContentRootPath, "static-files.sqlite");
builder.Services.AddDbContext<StaticFileDbContext>(options =>
{
    //options.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Initial Catalog=StaticFiles;Integrated Security=True", b => b.MigrationsAssembly("DemoWeb"));
    options.UseSqlite($"data source={sqliteDbPath}", b => b.MigrationsAssembly("DemoWeb"));
});
builder.Services.AddScoped<StaticFileDbRepository>();
var app = builder.Build();

// init db
if (!File.Exists(sqliteDbPath))
{
    using (var scope = app.Services.CreateScope())
    {
        scope.ServiceProvider.GetRequiredService<StaticFileDbContext>()
              .Database.Migrate();
    }
}

// insert the demo data on-demand
if (app.Configuration.GetValue<string>("insertDemoData", "false") == "true")
{
    using (var scope = app.Services.CreateScope())
    {
        var rsp = scope.ServiceProvider.GetRequiredService<StaticFileDbRepository>();
        var userId = "jeffrey";
        var clientIp = "::1";
        rsp.UpdateFile("/index.html", Encoding.UTF8.GetBytes("<html><body><link href=css/site.css rel=stylesheet /> Web in JSON<img src=imgs/logo.gif /></body></html>"), userId, clientIp);
        rsp.UpdateFile("/css/site.css", Encoding.UTF8.GetBytes("body { font-size: 9pt } img { display: block; margin-top: 5px; }"), userId, clientIp);
        rsp.UpdateFile("/imgs/logo.gif", Convert.FromBase64String("R0lGODlhSABI...略...IAEWIAGCIABAQA7"), userId, clientIp);
    }
}

app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new Drk.AspNetCore.FileProviders.StaticFileDbProvider(app.Services),
    RequestPath = "/web-in-db"
});

app.MapGet("/", () => Results.Redirect("~/web-in-db/index.html"));

app.Run();

若要用 .NET CLI 一氣喝成,指令如下:

dotnet new web -o DemoWeb
cd DemoWeb
dotnet add package Drk.AspNetCore.FileProviders
dotnet add package Microsoft.EntityFrameworkCore.Design
rem 將 Program.cs 換成上面的程式碼
dotnet ef migrations add InitialCreate --context StaticFileDbContext
dotnet run -- --insertDemoData true

然後,將 Migrations 目錄刪除,修改程式改用 SQL LocalDB :

builder.Services.AddDbContext<StaticFileDbContext>(options =>
{
    options.UseSqlServer(
        @"Server=(localdb)\mssqllocaldb;Initial Catalog=StaticFiles;Integrated Security=True", 
        b => b.MigrationsAssembly("DemoWeb"));
    //options.UseSqlite($"data source={sqliteDbPath}", b => b.MigrationsAssembly("DemoWeb"));
});
builder.Services.AddScoped<StaticFileDbRepository>();
var app = builder.Build();

// init db
using (var scope = app.Services.CreateScope())
{
    scope.ServiceProvider.GetRequiredService<StaticFileDbContext>()
            .Database.Migrate();
}

重新執行 dotnet ef migrations add,程式就改在 SQL LocalDB 建立資料表順利運作!

元件只定義 Model、DbContext,至於要用什麼資料庫由你決定,是不是很美妙?

呼口號時間:EF Core 真棒,.NET 好威呀!

A simple implementation to store static files in database for ASP.NET Core.


Comments

# by Lauyea

把圖片存進資料庫裏面共用,會不會有效能的疑慮呢?

# by Jeffrey

to Lauyea,這是系統設計的取捨,存在資料庫一定比直接放磁碟慢,但如果方便、彈性是優先要滿足的目標,則效能略差便是要付出的代價。不過,有個迷思要打破:資料容量通常不是 DB 效能不佳關鍵,資料筆數跟索引設計才是,而網路傳輸通常也不是瓶頸,並可透過加入快取機制有效改善。因此,靜態檔移入資料庫影響效能是一定的,除非流量極高,我認為不至嚴重到「疑慮」等級。

Post a comment