[創意料理] 用 Expression 做一個簡易的 Object-Object Mapping

  • 1304
  • 0
  • C#
  • 2020-10-05

在 C# 講到 Object-Object Mapping,AutoMapper 絕對是在解決方案清單的前幾名,也是我推薦的首選,不過如果我們只是偶爾在程式的某個小角落,需要把一個類別對應成另一個類別,這時候我們可能不會想要去安裝 AutoMapper、寫 Mapping Configuration,會想說是不是有一個更輕量的方法來解決我們當前的問題?

假定我要互相做對應類別如下:

public class MyClass1
{
    public int Property1 { get; set; }

    public string Property2 { get; set; }

    public long Property3 { get; set; }

    public decimal Property4 { get; set; }

    public DateTime Property5 { get; set; }

    public short Property6 { get; set; }

    public ulong Property7 { get; set; }

    public byte Property8 { get; set; }

    public char Property9 { get; set; }

    public double Property10 { get; set; }
}

public class MyClass2
{
    public int Property1 { get; set; }

    public string Property2 { get; set; }

    public long Property3 { get; set; }

    public decimal Property4 { get; set; }

    public DateTime Property5 { get; set; }

    public ulong Property7 { get; set; }

    public byte Property8 { get; set; }

    public char Property9 { get; set; }

    public double Property10 { get; set; }
}

Object Initializer

如果屬性不多,其實我們都會用 Object Initializer 處理掉,因為沒幾行。

class Program
{
    private static void Main(string[] args)
    {
        var myClass1 = new MyClass1
                       {
                           Property1 = 1,
                           Property2 = "2",
                           Property3 = 3,
                           Property4 = 4m,
                           Property5 = DateTime.Now,
                           Property6 = 6,
                           Property7 = 7,
                           Property8 = 8,
                           Property9 = '9',
                           Property10 = 10d
                       };

        var myClass2 = new MyClass2
                       {
                           Property1 = myClass1.Property1,
                           Property2 = myClass1.Property2,
                           Property3 = myClass1.Property3,
                           Property4 = myClass1.Property4,
                           Property5 = myClass1.Property5,
                           Property7 = myClass1.Property7,
                           Property8 = myClass1.Property8,
                           Property9 = myClass1.Property9,
                           Property10 = myClass1.Property10
                       };
    }
}

但是屬性多的話會覺得煩,這時候如果有一個擴充方法 myClass1.To<MyClass2>() 一行解決類別對應的問題,我們應該會覺得輕鬆很多,而且這樣的類別對應,或許我們在應用程式中只寫會個兩三次。

Expression

一般來說要實作 myClass1.To<MyClass2>() 這個擴充方法,我們可能會用 Reflection 來做,Reflection 很方便,但是它比起其他更進階的方法來說,慢了一點,到這邊有些朋友可能會想說,如果要快的話可以 Emit IL Code,直接 Emit IL Code 雖然強大,但可讀性很低,如果沒有非常極端的效能要求,我們還是選擇本篇文章的主角 - Expression

我個人覺得 Expression 的門檻是在找到正確的 Expression 來表達我們要做的事,跨過那道門檻,除了一些比較特殊的情況之外,其他的就跟平常撰寫程式一樣,底下我們就把 myClass1.To<MyClass2>() 的擴充方法實作出來,說明就在註解中。

private static readonly ConcurrentDictionary<string, Delegate> ObjectConverters = new ConcurrentDictionary<string, Delegate>();

public static TTarget To<TTarget>(this object source)
{
    var sourceType = source.GetType();
    var targetType = typeof(TTarget);

    var converterKey = string.Concat(sourceType.FullName, " -> ", targetType.FullName);

    // 將編譯後的結果 Cache 起來
    var converter = (Func<object, TTarget>)ObjectConverters.GetOrAdd(
        converterKey,
        key =>
            {
                var sourceProperties = sourceType.GetProperties().ToDictionary(p => p.Name, p => p);
                var targetProperties = targetType.GetProperties().ToDictionary(p => p.Name, p => p);

                // 建立一個型別為 object、名為 obj 的參數。
                var sourceParam = Expression.Parameter(typeof(object), "obj");

                // 建立一個型別為 sourceType、名為 source 的變數。
                var sourceVariable = Expression.Variable(sourceType, "source");

                // 將參數 obj 明確轉換為 sourceType,指定給變數 source。
                var sourceAssign = Expression.Assign(sourceVariable, Expression.Convert(sourceParam, sourceType));

                // 將 sourceType 中與 targetType 中型別相同、名稱相同的屬性值,指定給 targetType 中對應的屬性。
                var memberBindings = targetProperties.Where(
                        p =>
                            {
                                if (!sourceProperties.ContainsKey(p.Key)) return false;
                                if (p.Value.PropertyType != sourceProperties[p.Key].PropertyType) return false;

                                return true;
                            })
                    .Select(p => Expression.Bind(p.Value, Expression.Property(sourceVariable, sourceProperties[p.Key])));

                // 產生 targetType 的 Object Initializer
                var memberInit = Expression.MemberInit(Expression.New(targetType), memberBindings);

                // 產生 targetType 的 Return Label
                var returnLabel = Expression.Label(targetType);

                // Return targetType 的 Object Initializer
                var returnExpr = Expression.Return(returnLabel, memberInit);

                // 將上述語法塞進一個程式碼區塊
                var block = Expression.Block(
                    new[] { sourceVariable },
                    sourceAssign,
                    returnExpr,
                    Expression.Label(returnLabel, Expression.Default(targetType)));

                // 產生 Lambda Expression 並編譯
                return Expression.Lambda(block, sourceParam).Compile();
            });

    return converter(source);
}

Expression.Return() 要特別講一下,它產生出來的其實是一個 Goto 語法,所以必須要再建立一個可以用來跳躍執行的 Label,而這個用來跳躍執行的 Label 通常都是擺在最後面,並且要給一個預設值,這個預設值本身不會被回傳,它只是用來定義這個 Block 是有回傳值的。

到這邊基本上算是大功告成了,myClass1.To<MyClass2>() 這個擴充方法可以正常執行了,底下是跟 Object Initializer 做效能比較,如果對這點效能上的差距可以接受的朋友,就直接把程式碼複製去用。

相關資源

C# 指南
ASP.NET 教學
ASP.NET MVC 指引
Azure SQL Database 教學
SQL Server 教學
Xamarin.Forms 教學