iT邦幫忙

5

【C#】分享開源小工具 ValueGetter,輕量快速取物件值

c#

Github連結 : shps951023/ValueGetter


假如想了解過程的讀者可以往下閱讀。


緣起

自己做一個集合動態生成HtmlTableHelper專案
一開始使用Reflection GetValue的方法,版友程凱大反映這樣效能不好,所以開始學習如何改為『緩存+動態生成Func換取速度』,以下是學習過程


開始

假設有一個類別,我想要動態取得它的屬性值

public class MyClass
{
	public int MyProperty1 { get; set; }
    public int MyProperty2 { get; set; }
}

一開始的直覺寫法,使用反射(Reflection.GetValue)

var data = new MyClass() { MyProperty1 = 123, MyProperty2 = "test" };
var type = data.GetType();
var props = type.GetProperties();
var values = props.Select(s => s.GetValue(data)).ToList();

1.type.GetProperties()改為從cache抓取

前者Reflection方式,可以發現每一次呼叫方法都要重新從Reflection GetProperties()造成重複抓取資料動作
所以改建立一個System.Type擴充方法,從Cache取得屬性Info資料,並且使用ConcurrentDictionary達到線程安全。

public static partial class TypePropertyCacheHelper
{
	private static readonly System.Collections.Concurrent.ConcurrentDictionary<RuntimeTypeHandle, IList<PropertyInfo>> TypeProperties
		  = new System.Collections.Concurrent.ConcurrentDictionary<RuntimeTypeHandle, IList<PropertyInfo>>();

	public static IList<PropertyInfo> GetPropertiesFromCache(this Type type)
	{
		if (TypeProperties.TryGetValue(type.TypeHandle, out IList<PropertyInfo> pis))
			return pis;
		return TypeProperties[type.TypeHandle] = type.GetProperties().ToList();
	}
}

使用方式:

var data = new MyClass() { MyProperty1 = 123, MyProperty2 = "test" };
var type = data.GetType();
var props = type.GetPropertiesFromCache();
var values = props.Select(s => s.GetValue(data)).ToList();

2.PropertyInfo.GetValue()改為Expression動態建立Function + Cache

主要邏輯

  • 使用方法參數類別反推泛型
  • 使用Expression動態生成屬性GetValue的方法,等Runctime Compile完後放進Cache
  • 生成的方法相當於 object GetterFunction(MyClass i) => i.MyProperty1 as object;
  • Cache unique key判斷方式使用類別Handle + 屬性MetadataToken,這邊有個小細節呼叫ToString避免boxing動作
public static partial class ValueGetter
{
	private static readonly ConcurrentDictionary<string, object> Functions = new ConcurrentDictionary<string, object>();

	public static object GetValueFromCache<T>(this PropertyInfo propertyInfo, T instance)
	{
		if (instance == null) throw new ArgumentNullException($"{nameof(instance)} is null");
		if (propertyInfo == null) throw new ArgumentNullException($"{nameof(propertyInfo)} is null");

		var type = propertyInfo.DeclaringType;
		var key = $"{type.TypeHandle.Value.ToString()}|{propertyInfo.MetadataToken.ToString()}";

		Func<T, object> function = null;
		if (Functions.TryGetValue(key, out object func))
			function = func as Func<T, object>;
		else
		{
			function = CompileGetValueExpression<T>(propertyInfo);
			Functions[key] = function;
		}
		return function(instance);
	}

	public static Func<T, object> CompileGetValueExpression<T>(PropertyInfo propertyInfo)
	{
		var instance = Expression.Parameter(propertyInfo.DeclaringType, "i");
		var property = Expression.Property(instance, propertyInfo);
		var convert = Expression.TypeAs(property, typeof(object));
		var lambda = Expression.Lambda<Func<T, object>>(convert, instance);
		return lambda.Compile();
	}
}

但這時候會發現使用類型反推泛型有一個問題,假如使用者傳入向上轉型變數,代表CompileGetValueExpression<T>方法的泛型T為object
這時候Compiler就會出現參數類型不符錯誤,如圖片

object data = new MyClass() { MyProperty1 = 123, MyProperty2 = "test" };

20190322125948-image.png

3.修正向上轉型變數問題,使用GetValue()改為Emit動態建立強轉型Function + Cache

主要邏輯

  • 原本的 function = func as Func<object, object>; 可以省掉轉型動作
  • 生成方法相當於 object GetterFunction(object i) => ((MyClass)i).MyProperty1 as object ;
  • 缺點在多了強轉型動作,但能避免參數類型不符問題(因為都是object型態)。
public static partial class ValueGetter
{
	private static readonly ConcurrentDictionary<object, Func<object, object>> Functions = new ConcurrentDictionary<object, Func<object, object>>();

	public static object GetObject<T>(this PropertyInfo propertyInfo, T instance)
	{
		if (instance == null) return null;
		if (propertyInfo == null) throw new ArgumentNullException($"{nameof(propertyInfo)} is null");

		var key = $"{propertyInfo.DeclaringType.TypeHandle.Value.ToString()}|{propertyInfo.MetadataToken.ToString()}";

		if (Functions.TryGetValue(key, out Func<object, object> function))
			return function(instance);

		return (Functions[key] = GetFunction(propertyInfo))(instance);
	}

	public static Func<object, object> GetFunction(PropertyInfo prop)
	{
		var type = prop.DeclaringType;
		var propGetMethod = prop.GetGetMethod(nonPublic: true);
		var propType = prop.PropertyType;

		var dynamicMethod = new DynamicMethod("m", typeof(object), new Type[] { typeof(object) }, type.Module);

		ILGenerator iLGenerator = dynamicMethod.GetILGenerator();
		LocalBuilder local0 = iLGenerator.DeclareLocal(propType);

		iLGenerator.Emit(OpCodes.Ldarg_0);
		iLGenerator.Emit(OpCodes.Castclass, type);
		iLGenerator.Emit(OpCodes.Call, propGetMethod);
		iLGenerator.Emit(OpCodes.Box, propType);
		iLGenerator.Emit(OpCodes.Ret);

		return (Func<object, object>)dynamicMethod.CreateDelegate(typeof(Func<object, object>));
	}
}

使用方式:

public static IEnumerable<object> GetValues<T>(T instance)
{
	var type = instance.GetType();
	var props = type.GetPropertiesFromCache();
	return props.Select(s => s.GetObjectValue(instance));
}

4.測試發現效率低問題

圖片中CompilerFunction效率居然輸給Reflection GetValue
關鍵點在從Cache取值動作太繁雜
20190323170857-image.png

所以寫了最後一個版本,主要修改邏輯

  1. 取Cache動作分離到類別(ValueGetterCache),並藉由類別泛型來建立緩存字典,Key取值不使用組合字串,直接使用int類型的PropertyInfo.MetadataToken當緩存key值
  2. 有些專案只需要返回字串型態,所以增加一個GetToStringValue方法,在生成的Func動作裡加上ToString動作,這樣可以少一次Boxing動作
  3. 做成擴充方法,方便使用
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;

namespace ValueGetter
{
    public static partial class ValueGetter
    {
        /// <summary>
        /// Compiler Method Like:
        /// <code>string GetterFunction(object i) => (i as MyClass).MyProperty1.ToString() ; </code>
        /// </summary>
        public static Dictionary<string, string> GetToStringValues<T>(this T instance) 
            => instance?.GetType().GetPropertiesFromCache().ToDictionary(key => key.Name, value => value.GetToStringValue<T>(instance));

        /// <summary>
        /// Compiler Method Like:
        /// <code>string GetterFunction(object i) => (i as MyClass).MyProperty1.ToString() ; </code>
        /// </summary>
        public static string GetToStringValue<T>(this PropertyInfo propertyInfo, T instance)
            => instance != null ? ValueGetterCache<T, string>.GetOrAddToStringFuntionCache(propertyInfo)(instance) : null;
    }
    
    public static partial class ValueGetter
    {
        /// <summary>
        /// Compiler Method Like:
        /// <code>object GetterFunction(object i) => (i as MyClass).MyProperty1 as object ; </code>
        /// </summary>
        public static Dictionary<string, object> GetObjectValues<T>(this T instance)
            => instance?.GetType().GetPropertiesFromCache().ToDictionary(key => key.Name, value => value.GetObjectValue(instance));

        /// <summary>
        /// Compiler Method Like:
        /// <code>object GetterFunction(object i) => (i as MyClass).MyProperty1 as object ; </code>
        /// </summary>
        public static object GetObjectValue<T>(this PropertyInfo propertyInfo, T instance) 
            => instance!=null?ValueGetterCache<T, object>.GetOrAddFunctionCache(propertyInfo)(instance):null;
    }

    internal partial class ValueGetterCache<TParam, TReturn>
    {
        private static readonly ConcurrentDictionary<int, Func<TParam, TReturn>> ToStringFunctions = new ConcurrentDictionary<int, Func<TParam, TReturn>>();
        private static readonly ConcurrentDictionary<int, Func<TParam, TReturn>> Functions = new ConcurrentDictionary<int, Func<TParam, TReturn>>();
    }

    internal partial class ValueGetterCache<TParam, TReturn>
    {
        internal static Func<TParam, TReturn> GetOrAddFunctionCache(PropertyInfo propertyInfo)
        {
            var key = propertyInfo.MetadataToken;
            if (Functions.TryGetValue(key, out Func<TParam, TReturn> func))
                return func;
            return (Functions[key] = GetCastObjectFunction(propertyInfo));
        }

        private static Func<TParam, TReturn> GetCastObjectFunction(PropertyInfo prop)
        {
            var instance = Expression.Parameter(typeof(TReturn), "i");
            var convert = Expression.TypeAs(instance, prop.DeclaringType);
            var property = Expression.Property(convert, prop);
            var cast = Expression.TypeAs(property, typeof(TReturn));
            var lambda = Expression.Lambda<Func<TParam, TReturn>>(cast, instance);
            return lambda.Compile();
        }
    }

    internal partial class ValueGetterCache<TParam, TReturn>
    {
        internal static Func<TParam, TReturn> GetOrAddToStringFuntionCache(PropertyInfo propertyInfo)
        {
            var key = propertyInfo.MetadataToken;
            if (ToStringFunctions.TryGetValue(key, out Func<TParam, TReturn> func))
                return func;
            return (ToStringFunctions[key] = GetCastObjectAndToStringFunction(propertyInfo));
        }

        private static Func<TParam, TReturn> GetCastObjectAndToStringFunction(PropertyInfo prop)
        {
            var propType = prop.PropertyType;
            var toStringMethod = propType.GetMethods(BindingFlags.Public | BindingFlags.Instance).Where(p => p.Name == "ToString").First();

            var instance = Expression.Parameter(typeof(TParam), "i");
            var convert = Expression.TypeAs(instance, prop.DeclaringType);
            var property = Expression.Property(convert, prop);
            var tostring = Expression.Call(property, toStringMethod);
            var lambda = Expression.Lambda<Func<TParam, TReturn>>(tostring, instance);

            return lambda.Compile();
        }
    }

    public static partial class PropertyCacheHelper
    {
        private static readonly Dictionary<RuntimeTypeHandle, IList<PropertyInfo>> TypePropertiesCache = new Dictionary<RuntimeTypeHandle, IList<PropertyInfo>>();

        public static IList<PropertyInfo> GetPropertiesFromCache(this Type type)
        {
            if (TypePropertiesCache.TryGetValue(type.TypeHandle, out IList<PropertyInfo> pis))
                return pis;
            return TypePropertiesCache[type.TypeHandle] = type.GetProperties().ToList();
        }
    }
}


使用方式:

    var data = new MyClass() { MyProperty1 = 123, MyProperty2 = "test" };
    var result = data.GetObjectValues();
    //Result:
    Assert.AreEqual(123, result["MyProperty1"]);
    Assert.AreEqual("test", result["MyProperty2"]);

最後做一次效能測試

邏輯:

public class BenchmarkBase
{
    private static List<MyClass> Data = Enumerable.Range(1,100).Select(s=>new MyClass() { MyProperty1 = 123, MyProperty2 = "test" }).ToList();

    [Benchmark()]
    public  void Reflection() => Data.Select(instance => {
        var type = instance.GetType();
        var props = type.GetProperties();
        return props.ToDictionary(key => key.Name, value => value.GetValue(instance));
    }).ToList();

    [Benchmark()]
    public void ReflectionToString() => Data.Select(instance => {
        var type = instance.GetType();
        var props = type.GetProperties();
        return props.ToDictionary(key => key.Name, value => value.GetValue(instance).ToString());
    }).ToList();

    [Benchmark()]
    public void GetObjectValues() => Data.Select(s => s.GetObjectValues()).ToList();

    [Benchmark()]
    public void GetObjectToStringValues() => Data.Select(s => s.GetToStringValues()).ToList();
}

結果:

BenchmarkDotNet=v0.11.1, OS=Windows 10.0.17134.648 (1803/April2018Update/Redstone4)
Intel Core i7-7700 CPU 3.60GHz (Kaby Lake), 1 CPU, 8 logical and 4 physical cores
  [Host]   : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3362.0
  ShortRun : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3362.0
Method Mean Gen 0 Allocated
GetObjectValues 32.93 us 9.8750 40.51 KB
GetObjectToStringValues 38.23 us 10.0625 41.29 KB
Reflection 54.40 us 10.0625 41.29 KB
ReflectionToString 60.24 us 10.8125 44.42 KB

結語

這邊有做成一個NuGet套件ValueGetter,方便自己日後使用,也分享給有需要的版友
假如版友、前輩們有更好作法,期待留言、討論。

最後過程中在S.O尋求很多幫助,有興趣的讀者可以在我S.O查看歷史紀錄。


1 則留言

1
fysh711426
iT邦研究生 4 級 ‧ 2019-03-23 23:35:07

這篇講的很棒,超級詳細
自己有在專案中使用反射去產生 SQL 語法
不過沒有快取,剛好可以用這個套件優化
/images/emoticon/emoticon12.gif


還有個問題想問

var key = $"{type.TypeHandle.Value.ToString()}|{propertyInfo.MetadataToken.ToString()}";

文中提到使用 .ToString() 可以避免 Boxing
是因為上面程式會被轉為

string.Concat(new object[] {type.TypeHandle.Value , propertyInfo.MetadataToken});

MetadataToken 因為是 int 所以會先 Boxing 嗎?
還是 TypeHandle.Value 的關係? (查了文件 TypeHandle.Value 型態是 IntPtr)

暐翰 iT邦大師 2 級‧ 2019-03-23 23:44:58 檢舉

MetadataToken 因為是 int 所以會先 Boxing 嗎?
還是 TypeHandle.Value 的關係?

對,因為string.Concat需要接收參數為object,要對非字串變數做(object)boxing動作

public static string Concat(object arg0)
{
	if (arg0 == null)
	{
		return Empty;
	}
	return arg0.ToString();
}

舉例:

var result = "123"+123;

編譯代碼是

string text = "123" + 123;

往下IL

IL_0000:  nop         
IL_0001:  ldstr       "123"
IL_0006:  ldc.i4.s    7B 
IL_0008:  box         System.Int32
IL_000D:  call        System.String.Concat
IL_0012:  stloc.0     // result
IL_0013:  ret         

可以在IL_0003看到box動作

fysh711426 iT邦研究生 4 級‧ 2019-03-23 23:57:14 檢舉

了解,直接看 IL 很清楚,感謝大大。

暐翰 iT邦大師 2 級‧ 2019-03-24 00:09:03 檢舉

/images/emoticon/emoticon12.gif

我要留言

立即登入留言