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,你遇到的狀況不合理,應該要排除掉。

Post a comment