Blazor 如何處裡狀態?(三)

Blazor 如何處裡狀態?(三)

文章 Logo 圖片

前言

先前分享過幾篇關於 Blazor 的狀態保存,先前關於狀態保存有許多錯誤的地方,比如:其實 .NET Core 本身就有 IMemoryCache 或者是 Singleton 物件可以做到『狀態保存』這件事情。所以,本篇再次快速地向各位尋覽一下所有關於 Blazor 裡狀態如何保存這件事情。

.NET Conf 2020 的演講內容

小弟在去年的 .NET Conf 2020 的分享:Blazor Components 開發實戰 裡面也重新的介紹 Blazor 的狀態保存,當時我分為五種:

上圖、取自我在.NET Conf 2020 中的投影片:Blazor Components 開發實戰 


今天我們來介紹我在 Blazor Components 開發實戰 的 Slide 上講到的幾種方式: 


有興趣的參考上方我在 .NET Conf  2020 Taiwan 所分享的 Blazor Components 開發實戰的投影片。

(一)、資料庫 

(二)、協力廠商支援 Cache(Redis, Others…) 

(三)、保存記憶體內容狀態 (.NET Core) 

(四)、Browser Local Storage 

(五)、URL


底下,筆者以範例程式來說明這幾種的用法、差異的比較。

(一)、資料庫 資料庫的做法,其實我相信俗果可以用資料庫,保存哪有什麼問題 XD,如果可以用資料庫,相信這大家都很熟,我們就... 略過吧?..(咦?)

(二)、協力廠商支援 Cache(Redis, Others…) 在協力廠商的支援裡,除了 Redis Cache 外,訪間其他種類的 NoSQL 產品,像是建立分散式資料庫的 Cassandra、Hadoop 等等的 key-value 形式的巨量系統其實也都可以算在內的,而這邊我們以常見的 Redis 來介紹基本的用法。

(1). 首先建立 Blazor Server 專案 (2). 建立 .NET Core/.NET Standard 類別庫專案 這裡一樣套用部分整潔架構 (Clean Architecture),為什麼說部份呢?因為這裡目前無任何商業邏輯 (Domain Layer) 與 (app service/use case layer),但是未來會使用的 Database 包括我們現在要套用的 Redis Cache 均屬於外層,而 UI 則在最外層,這裡的 UI 就是 BlazorServerRedis1 這個網站。

圖(一)、建立Infrastructure 類型的專案

Infrastructure 類型的專案


這樣一來,方便未來我們將 Configuration, Encryption, Database 或 Log 等相關 Uiltity 放置在這裡。

(3). 安裝 NuGet 套件:〔StackExchange.Redis〕 這裡,我們選擇目前最新的 v2.2.4 版。

圖(二)、安裝 StackExchange.Redis 的 NuGet 套件安裝 StackExchange.Redis

(4). 撰寫 Redis Cache Provider 如圖(二)裡面一樣,我們先建立介面 IRedisCacheProvider.cs

public interface IRedisCacheProvider
{
    /// <summary>
    /// 建立或置換快取資料值
    /// </summary>
    /// <param name="key">鍵值,如果指定的鍵值不存在則新增一筆快取項目。</param>
    /// <param name="data">資料值</param>
    /// <remarks>資料值必須為可序列化的型別。</remarks>
    void Put(string key, object data);
    /// <summary>
    /// 建立或置換快取資料值
    /// </summary>
    /// <param name="key">鍵值,如果指定的鍵值不存在則新增一筆快取項目。</param>
    /// <param name="data">資料值</param>
    /// <param name="liveTime">存活時間</param>
    /// <remarks>資料值必須為可序列化的型別。</remarks>
    void Put(string key, object data, TimeSpan? liveTime);
    /// <summary>
    /// 取得快取資料值
    /// </summary>
    /// <param name="key">鍵值</param>
    /// <returns>資料值</returns>
    object Get(string key);
    /// <summary>
    /// 移除指定的一個快取資料
    /// </summary>
    /// <param name="key">鍵值,如果鍵值不存在將被忽略。</param>
    void Remove(string key);
}

Get 方法:

public object Get(string key)
{
    object result = null;
    try
    {
        RedisValue cacheResult = Current.StringGet(key);
        result = JsonConvert.DeserializeObject(cacheResult, _jsonSerializerSettings);
    }
    catch (RedisConnectionException rex)
    {
        int reTry = 0;
        do
        {
            //延遲 1 秒鐘.
            Thread.Sleep(100);
            try
            {
                RedisValue cacheResult = Current.StringGet(key);
                result = JsonConvert.DeserializeObject(cacheResult, _jsonSerializerSettings);
                //成功即跳出迴圈.
                break;
            }
            catch
            {
                RedisState.CloseMultiplexer();

                _redis = RedisState.CreateMultiplexer();
                //失敗則在迴圈中繼續 ReTry
            }
            reTry++;

        } while (reTry < 5);

        if (reTry == 5)
        {
            //紀錄 Log,請修改為 e 保網紀錄 Log 的方式.
            Trace.WriteLine(string.Format("{0}, {1}", rex.GetType().Name, rex.Message));
        }
    }
    catch (Exception ex)
    {
        //紀錄 Log,請修改為 e 保網紀錄 Log 的方式.
        Debug.WriteLine(string.Format("Fail to Get(\"{0}\")! Treat as a null result.\nError Detail:\n", key, ex.ToString()));
    }

    return result;
}

Put 方法:

/// <summary>
/// 將一個物件存放進 Redis Cache Server 中
/// </summary>
/// <param name="key"></param>
/// <param name="data"></param>
public void Put(string key, object data)
{
    Put(key, data, DateTime.Now.AddMinutes(10) - DateTime.Now);
}
/// <summary>
/// 將一個物件存放進 Redis Cache Server 中
/// </summary>
/// <param name="key"></param>
/// <param name="data"></param>
/// <param name="liveTime"></param>
public void Put(string key, object data, TimeSpan? liveTime)
{
    try
    {
        Current.StringSet(key, JsonConvert.SerializeObject(data, _jsonSerializerSettings), liveTime);
    }
    catch (RedisConnectionException rex)
    {
        int reTry = 0;
        do
        {
            //延遲 1 秒鐘.
            Thread.Sleep(200);
            try
            {
                Current.StringSet(key, JsonConvert.SerializeObject(data, _jsonSerializerSettings), liveTime);
                //成功即跳出迴圈.
                break;
            }
            catch
            {
                RedisState.CloseMultiplexer();

                _redis = RedisState.CreateMultiplexer();
                //失敗則在迴圈中繼續 ReTry
            }
            reTry++;

        } while (reTry < 5);

        if (reTry == 5)
        {
            //紀錄 Log,請修改為 e 保網紀錄 Log 的方式.
            Trace.WriteLine(string.Format("{0}, {1}", rex.GetType().Name, rex.Message));
        }
    }
}

(5). 在 Blazor 專案裡建立 Models 資料夾並建立 CounterModel.cs 與 CacheModelContainer.cs

由於因為我不希望直接將 RedisCacheProvider 直接注入到 View 中,所以另外抽離一個 CacheModelContainer 包裝對 Redis 的存取動作,並利用 .NET Core 內建的 DI 注入 IRedisCacheProvider 的實體。

public class CacheModelContainer
{
    private readonly IRedisCacheProvider _redisCacheProvider;

    public CacheModelContainer(IRedisCacheProvider redisCacheProvider)
    {
        _redisCacheProvider = redisCacheProvider;
    }
    /// <summary>
    /// 
    /// </summary>
    /// <param name="key"></param>
    /// <param name="data"></param>
    public void SetModelData(string key, object data)
    {
        _redisCacheProvider.Put(key, data);
    }
    /// <summary>
    /// 
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="key"></param>
    /// <returns></returns>
    public T GetModelData<T>(string key) 
        where T: class
    {
        return _redisCacheProvider.Get(key) as T;
    }
}

最後,當然不要忘了最重要的,保存 Counter 值的 Model 叫做 CoutnerModel

[Serializable]
public class CounterModel
{
    public int AddCounter { get; set; }
}

(6). 修改 Counter.razor

這我們直接看程式碼吧~先前我也有 PO 在軟體開發之路 的 FB 社團裡。

@page "/counter"
@using BlazorServerRedis1.Models
@using StackExchange.Redis
@inject CacheModelContainer container
@inject CounterModel counter

<h1>Counter</h1>

<p>Current count: @counter.AddCounter</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        counter.AddCounter++;
        container.SetModelData("_COUNTER_MODEL", counter);
    }

    protected override void OnInitialized()
    {
        counter = container.GetModelData<CounterModel>("_COUNTER_MODEL") ?? new CounterModel();

        base.OnInitialized();
    }
}

完成之後當然是來測試一下囉,果然!我可以在 Redis 裡同步看見我更新進去的 "_COUNTER_MODEL" 這個 key/value

圖(三)、使用 redis-cli 查詢 "_COUNTER_MODEL" 使用 redis-cli 查詢

圖(四)、順利地保存 Counter 狀態 順利地保存 Counter 狀態

如上圖,即便我們切換 Page 也會一直保存 Counter 狀態。

(三)、保存記憶體內容狀態 (.NET Core)

保存記憶內容狀態應該是最這裡面介紹的所有方式裡面最簡單的了,不過這裡有可以分為兩種:

(1). 透過 .NET Core 內建的 DI 容器的服務留存期(生命週期)來保留狀態

這應該是最簡單的,應用的是 .NET Core 從一開始推出時、甚至在還稱作 ASP.NET 5 的時代就有了,用的就是內建的 Reguest Life-Cycle 生命週期,若將其設定為 Singleton 後,便有狀態保存的效果。

圖(五)、.NET Core/5 的 Request life Cycle 狀態保存 .NET Core/5 的 Request life Cycle 狀態保存

注意: 不過需要注意的是,此方式是保留在自身的記憶體中 (Work Process) 處裡序中,所以不支援橫向擴展 (Scale-out)、平行處裡 (Load Balance)、分散式環境 等等,如果 Ap Server 重新啟動 或 Crash 後,所有資料就都會消失,就像預設將 Session 保存在自身記憶體中一樣。

(2). 使用 .NET Core 就內建的 IMemoryCache 快取保存狀態

這個方法同上,也是保存在自身記憶中,所以也存在著上方第 (1). 項的所有缺點唷,需要注意一下。

作法也是與上方 (1) 差不多,首先 ConfigureServices() 先引用 MemoryCache

services.AddMemoryCache();

宣告一個相同的 Model 用以保存 Counter 內容:

public class CounterModel
{
    public int? Counter { get; set; }
}

接著在 Counter.razor 只需撰寫如下程式碼即可:

@page "/counter"
@using BlazorServerIMemoryCache1.Models
@using Microsoft.Extensions.Caching.Memory
@inject IMemoryCache memoryCache
@inject CounterModel counter

<h1>Counter</h1>

<p>Current count: @counter.Counter</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        counter.Counter++;
        memoryCache.Set("_COUNTER_MODEL", counter.Counter);
    }

    protected override void OnInitialized()
    {
        counter.Counter = memoryCache.Get("_COUNTER_MODEL") as Nullable<int> ?? 0;
        base.OnInitialized();
    }
}

關於詳細的程式碼可參考 Guthub:https://github.com/wugelis/BlazorServerIMemoryCache1

(3). 透過 Session 來保存(較少使用)

雖然 Session 也有上述缺點,但是 Session 可能比較好一些,因為它支援相當多種類的 Provider ,舉個例子:你可以將 Session 導向 Redis 或 任何第三方的【集中管理快取/資料庫】中,這麼一來就具備分散式應用能裡 與 橫向擴展 Scale-out 等能力了。

不過要在 .NET Core 或 5 中使用 Session 會稍微麻煩一點,因為在 .NET Core 裡任何功能都由 Middleware 提供,為了可擴充性 與 跨平台 等特性,以前在 IIS 上的功能並不復存在,取而代之的是自己序列化保存的方式,所以你必須自己將 Object 序列化才能存放到 Session,取而代之的是 ISession 所提供的功能。

圖(六)、ISession 提供的內容 ISession 提供的內容

因此,要使用也不難。

首先一樣在 ConfigureServices() 引用分散式快取記憶體 AddDistributedMemoryCache() 如下:

public void ConfigureServices(IServiceCollection services)
{
    // 略......

    services.AddDistributedMemoryCache();

    int timeoutMinutes;
    int.TryParse(Configuration.GetSection("session").GetSection("Timeout").Value, out timeoutMinutes);

    services.AddSession(options =>
    {
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.Name = "mywebsite";
        options.IdleTimeout = TimeSpan.FromMinutes(timeoutMinutes!=0?timeoutMinutes:10);
    });
}

接著,加入一個對 ISession 處裡的擴充方法的類別,並命名為 SessionWrapperExtensions

/// <summary>
/// 〔擴充方法類別〕ISession 型態的擴充方法
/// </summary>
public static class SessionWapperExtensions
{
    private static JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings()
    {
        TypeNameHandling = TypeNameHandling.Objects
    };
    /// <summary>
    /// 將 物件 以 JsonConvert 序列化為字串後放入 Session 中
    /// </summary>
    /// <param name="sessionAccessor"></param>
    /// <param name="key"></param>
    /// <param name="inputData"></param>
    public static void SetObject(this ISession sessionAccessor, string key, object inputData)
    {
        sessionAccessor.SetString(key, JsonConvert.SerializeObject(inputData, _jsonSerializerSettings));
    }
    /// <summary>
    /// 從 Session 取回字串後並以 JSON 反序列化後傳回物件執行個體
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="sessionAccessor"></param>
    /// <param name="key"></param>
    /// <returns></returns>
    public static T GetObject<T>(this ISession sessionAccessor, string key)
    {
        string jsonString = sessionAccessor.GetString(key);
         T result = JsonConvert.DeserializeObject<T>(jsonString ?? string.Empty);
        return result;
    }
}

然後,如法炮製的撰寫 Blazor 預設範本中的 Counter.razor 即可。

(四)、Browser Local Storage (五)、URL

下次介紹最後兩種方法。待續...... 會持續的更新 XDDD


ASP.NET Blazor 系列文章導讀

(一)、ASP.NET Blazor 關鍵報告

(二)、Blazor 怎麼處裡狀態? ==> 更新為本篇:【Blazor 怎麼處裡狀態?(三)】

(三)、Blazor 怎麼處裡狀態?【保存預設範本的 Count 值】

(四)、Blazor 的 CRUD 應用程式 – Use Entity Framework Core

(五)、Blazor 如何使用自定義的 ApiHostBase?【接續 Clean Architecture 的 API 框架設計 (1)】

(六)、Blazor 如何使用自定義的 ApiHostBase?【接續 Clean Architecture 的 API 框架設計 (2)】

(七)、… 還在想 XDD

關於 Gelis:

資深 .NET 技術顧問

FB 社團 (軟體開發之路):

https://www.facebook.com/groups/361804473860062/

FB 粉絲團 (Gelis 的程式設計訓練營):

https://www.facebook.com/gelis.dev.learning/

我講授過的課程 SlideShare:

https://www.slideshare.net/GelisWu


以下是我經營的項目與內容:

(1). 企業內訓課程

(2). 專業顧問


企業內訓課程:

1. .NET Core 3.1 從入門到進階

先前實體課程連結

2. 跨平台的 Web API Framework 框架設計

先前實體課程連結

3. 決戰 OOAD 系列課程 - 使用 UML

先前實體課程連結

4. 單元測試 UnitTest 與 Moq 物件實務課程

先前實體課程連結

5. 快速開發系列 - C# Project Templates 範本設計

留言

這個網誌中的熱門文章

軟體工程師 - 成長的 10 個階段

常見的程式碼壞味道(Code Smell or Bad Smell)

什麼是 gRPC ?