ASP.NET Core 練習 - 檔案更改自動刷新 IMemoryCache
2 |
Side Project 是練習新技術的好地方,工作上還以 MVC 為主,自己玩倒是全面改用 ASP.NET Core。遇到編輯 UI 主分類與次分類下拉欄位連動的需求,這玩意兒之前在玩前端時幾乎是必修課程,寫過 Knockout 版、AngularJS 版,這回試寫了 Razor Page 版,用 jQuery.getJSON() 搭配 OnGetSubCategories() 傳回次分類項目就能搞定,比預期的簡單:
Razor Page 下拉選單連動範例下次再聊,今天先講開發過程的副產品。考量主次分類更新的頻率不高,就不花時間進 DB 跟寫維護 UI 了,我選擇用 .json 檔提供資料:
{
"Categories": [
{
"Key": "系統開發",
"Value": [ "$SYSLIST" ]
},
{
"Key": "系統維運",
"Value": [ "$SYSLIST" ]
},
{
"Key": "系統分析",
"Value": [ "$SYSLIST" ]
},
{
"Key": "會議",
"Value": [ "例行會議", "臨時會議", "專案會議", "其他" ]
},
{
"Key": "請假",
"Value": [ "特休", "事假", "病假", "公假", "其他" ]
},
{
"Key": "教育訓練",
"Value": [ "上課", "自學", "其他" ]
},
{
"Key": "其他",
"Value": [ "其他" ]
}
],
"Systems": [
"系統A",
"系統B",
"系統C",
"系統D"
]
}
基本上宣告對映資料結構再讀取檔案用 JsonConvert.DeserializeObject 反序列化就好,.json 檔如要更新,照理說重新啟網站就好:
class CatgJsonStruc
{
public List<KeyValuePair<string, string[]>> Categories { get; set; }
public string[] Systems { get; set; }
}
const string settingFileName = "categories.json";
CatgJsonStruc CatgSettings => JsonConvert.DeserializeObject<CatgJsonStruc>(
File.ReadAllText(Path.Combine(env.ContentRootPath, settingFileName)));
但對我來說,Side Project 如同練功房,是練習進階技巧的好所在,即使沒必要,我也決定小題大做 - 仿效 appSettings.json 更新會自動重載的設計,將主、次分類資料存成「檔案修改後會立即自動更新的 IMemoryCache」。在雞毛蒜皮錯了不會死人的個人專案練好手感,未來工作專案想用才有信心上場。
先說明原理:
.NET Core 的 IMemoryCache 有個 GetOrCreate<T>() 方法,概念就像我以前土砲過的 GetCachableData<T>,當 Cache 已有該 Key 值資料時直接回傳,若沒有資料或過期就用傳入的 Func<T> 現場產生資料存入 Cache 再回傳。IMemoryCache.GetOrCreate() 需傳一個 Func<ICacheEntry,TItem> factory 參數,由 factory 函式傳回要存入 Cache 的資料,而 factory 還會拿到一個 ICacheEntry 物件,可用來設定 Cache 的保存優先順序、到期時間(即大家熟知的 AbsoluteExpiration、SlidingExpiration)、回收觸發事件、可引發資料過期的 IChangeToken... 等等。要實現檔案一異動 Cache 就過期的效果,靠的是 IChangeToken,類似先前介紹過的 .NET Framework HostFileChangeMonitor 機制,這在 .NET Core 也有,只是寫法略有不同。
Talk is cheap. Show me the code! 廢話少說,放碼過來!
我寫了一個 FileDataStore,打算在 Startup services.AddSigleton<FileDataStore> 註冊成 Signleton (延伸閱讀:不可不知的 ASP.NET Core 依賴注入),讀取 categories.json 反序列化後再取出主分類 (string[] Categories) 及主次分類對映表 (Dictionary<stirng, string[]> SubCategories),其中 Categories 及 SubCategories 使用 GetOrCreate() 方式建立 IMemoryCache 快取,並使用 env.ContentRootFileProvider.Watch("categories.json") 產生檔案修改時觸發快取過期的 IChangeToken。完整程式範例如下:
using DocumentFormat.OpenXml.Wordprocessing;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace PTSWeb.Models.Data
{
public class FileDataStore
{
private readonly IWebHostEnvironment env;
private readonly IMemoryCache cache;
private readonly ILogger logger;
class CatgJsonStruc
{
public List<KeyValuePair<string, string[]>> Categories { get; set; }
public string[] Systems { get; set; }
}
const string settingFileName = "categories.json";
CatgJsonStruc CatgSettings => JsonConvert.DeserializeObject<CatgJsonStruc>(
File.ReadAllText(Path.Combine(env.ContentRootPath, settingFileName)));
IChangeToken FileChangeToken;
public FileDataStore(IWebHostEnvironment env, IMemoryCache cache, ILogger<FileDataStore> logger)
{
this.env = env;
this.cache = cache;
this.logger = logger;
FileChangeToken = env.ContentRootFileProvider.Watch(settingFileName);
}
//https://blog.novanet.no/asp-net-core-memory-cache-is-get-or-create-thread-safe/
public string[] Categories => cache.GetOrCreate<string[]>(nameof(Categories),
(ICacheEntry entry) =>
{
logger.LogDebug("Create Categories for cache");
entry.AddExpirationToken(FileChangeToken);
return CatgSettings.Categories.Select(o => o.Key).ToArray();
});
public Dictionary<string, string[]> SubCategories =>
cache.GetOrCreate<Dictionary<string, string[]>>(nameof(SubCategories), (entry) =>
{
entry.AddExpirationToken(FileChangeToken);
logger.LogDebug("Create SubCategories for cache");
return CatgSettings.Categories
.ToDictionary(
o => o.Key,
o => o.Value.First() == "$SYSLIST" ? CatgSettings.Systems : o.Value
);
});
}
}
我在 Index.cshtml.cs 加入 OnGetSubCatgories 方便測試:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PTSWeb.Models.Data;
namespace PTSWeb.Pages
{
public class IndexModel : PageModel
{
private readonly ILogger<IndexModel> _logger;
private readonly FileDataStore _fileStore;
public IndexModel(ILogger<IndexModel> logger, FileDataStore fileStore)
{
_logger = logger;
_fileStore = fileStore;
}
public void OnGet()
{
}
public ActionResult OnGetJsonData()
{
return new JsonResult(new
{
Blog = "darkthread",
Name = "黑暗執行緒"
});
}
public ActionResult OnGetSubCategories()
{
return new JsonResult(_fileStore.SubCategories);
}
}
}
實測使用 PowerShell 取回 SubCategories,我在重新產生 Cache 時加寫 Log 以利觀察。如以下展示,第二次查詢時未出現「Create SubCategories for cache」字樣,代表是沿用前次 Cache 的內容,之後用 PowerShell 把 categories.json 中的 "特休" 改成 "特休假",再次 Invoke-WebRequest,看到「Create SubCategories for cache」代表 Cache 已重新產生,而傳回結果也已更新成 "特休假",證明 entry.AddExpirationToken(FileChangeToken) 發揮作用,測試成功!
A practise to use IMemoryCache.GetOrCreate() with IChangeToken generated by ContentRootFileProvider.Watch() to implement a cache updated immediately after file modified.
Comments
# by yi571
請教黑大,我仿您的寫法,發現檔案變更雖可以重新產生Cashe,但之後每次呼叫都會重新產生Cashe, 如果FileChangeToken不在建構子中初始化,改在(entry) =>{IChangeToken FileChangeToken = fileProvider.Watch(settingFileName );} 就可避免每次呼叫都重新產生Cashe,我這樣理解對嗎?
# by Jeffrey
to yi571, 我不太理解造成「之後每次呼叫都會重新產生Cache」的原因,檔案若沒變更理論上不該反覆觸發重新產生Cache,你遇到的狀況不合理,應該要排除掉。