如何使用C#中的Lambda表示式操作Redis Hash結構,簡化快取中物件屬性的讀寫操作

2023-07-15 18:00:49

Redis是一個開源的、高效能的、基於記憶體的鍵值資料庫,它支援多種資料結構,如字串、列表、集合、雜湊、有序集合等。其中,Redis的雜湊(Hash)結構是一個常用的結構,今天跟大家分享一個我的日常操作,如何使用Redis的雜湊(Hash)結構來快取和查詢物件的屬性值,以及如何用Lambda表示式樹來簡化這個過程。

一、什麼是Redis Hash結構

Redis Hash結構是一種鍵值對的集合,它可以儲存一個物件的多個欄位和值。例如,我們可以用一個Hash結構來儲存一個人的資訊,如下所示:

HSET person:1 id 1
HSET person:1 name Alice
HSET person:1 age 20

上面的命令將一個人的資訊儲存到了一個名為person:1的Hash結構中,其中每個欄位都有一個名稱和一個值。我們可以使用HGET命令來獲取某個欄位的值,例如:

HGET person:1 name#Alice

我們也可以使用HGETALL命令來獲取所有欄位的值,例如:

HGETALL person:1id 1name Aliceage 20

二、如何使用C#來操作Redis Hash結構

為了在C#中操作Redis Hash結構,我們需要使用一個第三方庫:StackExchange.Redis。這個庫提供了一個ConnectionMultiplexer類,用於建立和管理與Redis伺服器的連線,以及一個IDatabase介面,用於執行各種命令。例如,我們可以使用以下程式碼來建立一個連線物件和一個資料庫物件:

// 連線Redis伺服器
ConnectionMultiplexer redis = ConnectionMultiplexer.Connect("localhost");
// 獲取資料庫物件IDatabase db = redis.GetDatabase();

然後,我們可以使用db物件的HashSet方法和HashGet方法來儲存和獲取Hash結構中的欄位值。

// 建立一個HashEntry陣列,存放要快取的物件屬性
HashEntry[] hashfield = new HashEntry[3];
hashfield[0] = new HashEntry("id", "1");
hashfield[1] = new HashEntry("name", "Alice");
hashfield[2] = new HashEntry("age", "20");

// 使用HashSet方法將物件屬性快取到Redis的雜湊(Hash)結構中
db.HashSet("person:1", hashfield);
// 使用HashGetAll方法從Redis的雜湊(Hash)結構中查詢物件屬性
HashEntry[] result = db.HashGetAll("person:1");
// 遍歷結果陣列,列印物件屬性
foreach (var item in result)
{
    Console.WriteLine(item.Name + ": " + item.Value);
}

但是,這種方式有一些缺點:

  • 首先,我們需要手動將物件的屬性名和值轉換為HashEntry陣列,並且保持一致性。
  • 其次,我們需要使用字串來指定要儲存或獲取的欄位名,並且還要避免拼寫錯誤或重複。
  • 最後,我們需要手動將返回的RedisValue型別轉換為我們需要的型別。

有沒有更優雅的方法來解決這個問題呢?答案是肯定的。

三、如何用Lambda表示式輕鬆操作Redis Hash結構

Lambda表示式是一種匿名函數,可以用來表示委託或表示式樹。在.NET中,我們可以使用Lambda表示式來操作實體類的屬性,比如獲取屬性的值或者更新屬性的值。

我們可以利用 Lambda表示式來指定要儲存或獲取的物件的屬性,而不是使用字串。使用表示式樹來遍歷Lambda表示式,提取出屬性名和屬性值,並轉換為HashEntry陣列或RedisValue陣列,使其更易於使用。例如:

Get<Person>(p => new { p.Name, p.Age });

如果我們只想選擇一個屬性,就可以直接寫:

Get<Person>(p => p.Name)

如果要更新物件指定的屬性,可以這樣寫了:

Update<Person>(p => p
    .SetProperty(x => x.Name, "Alice") 
    .SetProperty(x => x.Age, 25));

怎麼樣,這樣是不是優雅多了,這樣做有以下好處:

  • 程式碼更加可讀和可維護,因為我們可以直接使用物件的屬性,而不是使用字串。
  • 程式碼更加穩定和精確,因為我們可以避免拼寫錯誤或重複,並且可以利用編譯器的型別檢查和提示。

那麼,我們如何實現上面的方法呢?

1、Get方法

這個方法的目的是從快取中獲取物件的一個或多個屬性值,使用一個泛型方法和一個Lambda表示式來實現。

private static TResult Get<T, TResult>(IDatabase db, int id, Expression<Func<T, TResult>> selector)
{
    if (selector == null)
        throw new ArgumentNullException(nameof(selector));

    // 使用擴充套件方法獲取要查詢的屬性名陣列
    var hashFields = selector.GetMemberNames().Select(m => new RedisValue(m)).ToArray();
    // 從快取中獲取對應的屬性值陣列
    var values = db.HashGet($"person:{id}", hashFields);
    // 使用擴充套件方法將HashEntry陣列轉換為物件
    var obj = values.ToObject<T>(hashFields);
    // 返回查詢結果
    return selector.Compile()(obj);
}

private static TResult Get<TResult>(IDatabase db, int id, Expression<Func<Person, TResult>> selector)
    => Get<Person, TResult>(db, id, selector);
  • 首先,定義一個泛型方法Get<T, TResult>,它接受一個資料庫物件db,一個物件id,和一個Lambda表示式selector作為引數。這個Lambda表示式的型別是Expression<Func<T, TResult>>,表示它接受一個T型別的物件,並返回一個TResult型別的結果。這個Lambda表示式的作用是指定要查詢的屬性。
  • 然後,在Get<T, TResult>方法中,首先判斷selector是否為空,如果為空,則丟擲異常。然後,使用擴充套件方法GetMemberNames來獲取selector中的屬性名陣列,並轉換為RedisValue陣列hashFields。這個擴充套件方法使用了ExpressionVisitor類來遍歷表示式樹,並重寫了VisitMember方法來獲取屬性名。接下來,使用db.HashGet方法從快取中獲取對應的屬性值陣列values,使用id作為鍵。然後,使用擴充套件方法ToObject來將values陣列轉換為T型別的物件obj。這個擴充套件方法使用了反射來獲取T型別的屬性,並設定對應的屬性值和型別轉換。最後,返回selector編譯後並傳入obj作為引數的結果。
  • 接下來,定義一個私有方法Get<TResult>,它接受一個資料庫物件db,一個物件id,和一個Lambda表示式selector作為引數。這個Lambda表示式的型別是Expression<Func<Person, TResult>>,表示它接受一個Person型別的物件,並返回一個TResult型別的結果。這個Lambda表示式的作用是指定要查詢的Person物件的屬性。
  • 然後,在Get<TResult>方法中,直接呼叫Get<T, TResult>方法,並傳入db,id,selector作為引數,並指定T型別為Person。這樣,就可以得到一個TResult型別的結果。

2、MemberExpressionVisitor擴充套件類

這個類的作用是遍歷一個表示式樹,收集其中的成員表示式的名稱,並儲存到一個列表中。

public class MemberExpressionVisitor : ExpressionVisitor
{
    private readonly IList<string> _names;

    public MemberExpressionVisitor(IList<string> list)
    {
        _names = list;
    }

    protected override Expression VisitMember(MemberExpression node)
    {
        var name = node.Member.Name; 
        if (node.Expression is MemberExpression member)
        {
            Visit(member); 
            name = member.Member.Name + "." + name; 
        }
        _names.Add(name); 

        return base.VisitMember(node);
    }
}
  • 首先,定義一個類MemberExpressionVisitor,它繼承自ExpressionVisitor類。這個類有一個私有欄位_names,用於儲存屬性名。它還有一個建構函式,接受一個IList<string>型別的引數list,並將其賦值給_names欄位。
  • 然後,在MemberExpressionVisitor類中,重寫了VisitMember方法,這個方法接受一個MemberExpression型別的引數node。這個方法的作用是存取表示式樹中的成員表示式節點,並獲取其屬性名。
  • 接下來,在VisitMember方法中,首先獲取node節點的屬性名,並賦值給name變數。然後判斷node節點的表示式是否是另一個成員表示式,如果是,則遞迴地存取該表示式,並將其屬性名和name變數用"."連線起來,形成一個屬性路徑。然後將name變數新增到_names集合中。最後返回基礎類別的VisitMember方法的結果。

3、Update方法

這個方法目的是將一個物件指定的屬性名和值更新到快取中,使用一個泛型方法和一個委託函數來實現。

public static Dictionary<string, object> Update<TSource>(Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>> setPropertyCalls)
{
    if (setPropertyCalls == null)
        throw new ArgumentNullException(nameof(setPropertyCalls));

    var nameValues = new Dictionary<string, object>(100); // 建立一個字典用於儲存屬性名和值

    var calls = new SetPropertyCalls<TSource>(nameValues); // 建立一個SetPropertyCalls物件

    setPropertyCalls(calls); // 呼叫傳入的函數,將屬性名和值新增到字典中

    return nameValues; // 返回字典
}

private static void Update(IDatabase db, int id, Func<SetPropertyCalls<Person>, SetPropertyCalls<Person>> setPropertyCalls)
{
    var hashEntries = Update(setPropertyCalls)
        .Select(kv => new HashEntry(kv.Key, kv.Value != null ? kv.Value.ToString() : RedisValue.EmptyString))
        .ToArray();

    // 將HashEntry陣列儲存到快取中,使用物件的Id作為鍵
    db.HashSet(id.ToString(), hashEntries);
}}
  • 首先,定義一個泛型方法Update<TSource>,它接受一個函數作為引數,這個函數的型別是Func<SetPropertyCalls<TSource>, SetPropertyCalls<TSource>>,表示它接受一個SetPropertyCalls<TSource>物件,並返回一個SetPropertyCalls<TSource>物件。這個函數的作用是設定要更新的屬性名和值。
  • 然後,在Update<TSource>方法中,建立一個字典nameValues,用於儲存屬性名和值。建立一個SetPropertyCalls<TSource>物件calls,傳入nameValues作為構造引數。呼叫傳入的函數setPropertyCalls,並傳入calls作為引數。這樣,setPropertyCalls函數就可以通過呼叫calls的SetProperty方法來新增屬性名和值到nameValues字典中。最後,返回nameValues字典。
  • 接下來,定義一個私有方法Update,它接受一個資料庫物件db,一個物件id,和一個函數setPropertyCalls作為引數。這個函數的型別是Func<SetPropertyCalls<Person>, SetPropertyCalls<Person>>,表示它接受一個SetPropertyCalls<Person>物件,並返回一個SetPropertyCalls<Person>物件。這個函數的作用是設定要更新的Person物件的屬性名和值。
  • 然後,在Update方法中,呼叫Update(setPropertyCalls)方法,並傳入setPropertyCalls作為引數。這樣,就可以得到一個字典nameValues,包含了要更新的Person物件的屬性名和值。將nameValues字典轉換為HashEntry陣列hashEntries,使用屬性值的字串表示作為HashEntry的值。如果屬性值為空,則使用RedisValue.EmptyString作為HashEntry的值。最後,使用db.HashSet方法將hashEntries陣列儲存到快取中,使用id作為鍵。

4、SetPropertyCalls泛型類

這個類的作用是收集一個源物件的屬性名稱和值的對應關係,並提供一個鏈式呼叫的方法,用於設定屬性的值。

public class SetPropertyCalls<TSource>
{
    private readonly Dictionary<string, object> _nameValues;

    public SetPropertyCalls(Dictionary<string, object> nameValues)
    {
        _nameValues = nameValues;
    }

    public SetPropertyCalls<TSource> SetProperty<TProperty>(Expression<Func<TSource, TProperty>> propertyExpression, TProperty valueExpression)
    {
        if (propertyExpression == null)
            throw new ArgumentNullException(nameof(propertyExpression));

        if (propertyExpression.Body is MemberExpression member && member.Member is PropertyInfo property)
        {
            if (!_nameValues.TryAdd(property.Name, valueExpression))
            {
                throw new ArgumentException($"The property '{property.Name}' has already been set.");
            }
        }
        return this;
    }
}
  • 首先,這個類有一個建構函式,接受一個Dictionary<string, object>型別的引數,作為儲存屬性名稱和值的對應關係的字典,並賦值給一個私有欄位_nameValues。
  • 然後,這個類有一個泛型方法,叫做SetProperty。這個方法接受兩個引數,一個是表示源物件屬性的表示式,另一個是表示屬性值的表示式。
  • 在這個方法中,首先判斷第一個引數是否為空,如果為空,則丟擲ArgumentNullException異常。
  • 然後判斷第一個引數的表示式體是否是一個成員表示式,並且該成員表示式的成員是否是一個屬性,如果是,則獲取該屬性的名稱,並賦值給一個區域性變數property。
  • 然後嘗試將該屬性名稱和第二個引數的值新增到_nameValues字典中,如果新增失敗,則說明該屬性已經被設定過了,丟擲ArgumentException異常。
  • 最後,返回當前物件的參照,實現鏈式呼叫的效果。

這樣,我們就可以得到一個包含所有要更新的屬性名和值的字典,然後我們就可以根據這些屬性名和值來更新實體類的屬性了。

Demo範例

讓我們來看一下程式碼範例,為了方便演示和閱讀,這是臨時碼的,實際中大家可以根據自己習慣來進行封裝,簡化呼叫,同時也可以使用靜態字典來快取編譯好的委託及物件屬性,提高效能。