iT邦幫忙

10

【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查看歷史紀錄。


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
小碼農米爾
iT邦高手 1 級 ‧ 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邦大師 1 級 ‧ 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動作

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

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

/images/emoticon/emoticon12.gif

我要留言

立即登入留言