В одной из своих статей я уже описывал как можно реализовать рефлексию при помощи source generator-ов. Тогда цель была продемонстрировать что такое эти ваши генераторы, а сама рефлексия была лишь примером. Сейчас же, я предлагаю сконцентрироваться на рефлексии, и узнать что из этого получиться.
Изначальной идеей было создать библиотеку которая бы реализовывала часть стандартной рефлексии, работала бы быстрей чем стандартная и при этом прекрасно себя чувствовала бы во время AOT компиляции. И source generator-ы нам в этом очень помогут.
Как использовать
Думаю стоит начать с того как пользоваться этой новой рефлексией. Чтобы подключить её, достаточно установить NuGet пакет Apparatus.AOT.Reflection
:
dotnet add package Apparatus.AOT.Reflection
Дальше мы сможем воспользоваться им следующим образом:
public class User
{
[Required]
public string FirstName { get; set; }
[Required]
public string LastName { get; set; }
}
public static void Main()
{
var user = new User();
var properties = user.GetProperties().Values;
foreach (var property in properties)
{
Console.WriteLine(property.Name);
}
}
В результате мы увидим имена пропертей.
FirstName
LastName
Также это работает и для enum-ов:
public enum UserKind
{
User,
Admin
}
// ...
public static void Main()
{
var values = EnumHelper.GetEnumInfo<UserKind>();
foreach (var value in values)
{
Console.WriteLine(value.Name);
}
}
Результат:
User
Admin
Получить имена полей это не все что можно сделать. Мы можем читать и менять их значения, получать атрибуты, которые приатачены к ним.
Вот как это выглядит:
var requiredProperties = _user
.GetProperties()
.Values
.Where(o => o.Attributes.Any(attr => attr is RequiredAttribute))
.ToArray();
foreach (var requiredProperty in requiredProperties)
{
if (requiredProperty.TryGetValue(_user, out var value))
{
Console.WriteLine($"{requiredProperty.Name} => {value}");
}
}
Это применимо и к enum-ам:
public enum AccountKind
{
[Description("User account")]
User,
[Description("Admin account")]
Admin,
[Description("Customer account")]
Customer,
[Description("Manager account")]
Manager
}
// ...
var values = EnumHelper.GetEnumInfo<AccountKind>();
foreach (var value in values)
{
var description = value.Attributes
.OfType<DescriptionAttribute>()
.First();
Console.WriteLine($"{value.Name} => {description.Description}");
}
Как работает
Есть source generator который ищет использование методов таких как GetProperties
и GetEnumInfo
. Смотрит с какими типами они используются и генерит extensions методы. Для пропертей он будет иметь следующий вид:
using System;
using System.Linq;
namespace Apparatus.AOT.Reflection
{
public static class exp_UserExtensions
{
[global::System.Runtime.CompilerServices.ModuleInitializer]
public static void Bootstrap()
{
MetadataStore<global::exp.User>.Data = _lazy;
}
private static global::System.Lazy<global::System.Collections.Generic.IReadOnlyDictionary<string, IPropertyInfo>> _lazy = new global::System.Lazy<global::System.Collections.Generic.IReadOnlyDictionary<string, IPropertyInfo>>(new global::System.Collections.Generic.Dictionary<string, IPropertyInfo>
{
{ "FirstName", new global::Apparatus.AOT.Reflection.PropertyInfo<global::exp.User,string>(
"FirstName",
new global::System.Attribute[]
{
new global::System.ComponentModel.DataAnnotations.RequiredAttribute(),
},
instance => instance.FirstName, (instance, value) => instance.FirstName = value)
},
{ "LastName", new global::Apparatus.AOT.Reflection.PropertyInfo<global::exp.User,string>(
"LastName",
new global::System.Attribute[]
{
new global::System.ComponentModel.DataAnnotations.RequiredAttribute(),
},
instance => instance.LastName, (instance, value) => instance.LastName = value)
},
});
public static global::System.Collections.Generic.IReadOnlyDictionary<string, IPropertyInfo> GetProperties(this global::exp.User value)
{
return _lazy.Value;
}
}
}
Для enum-а:
using System;
using System.Linq;
namespace Apparatus.AOT.Reflection
{
public static class exp_AccountKindExtensions
{
[global::System.Runtime.CompilerServices.ModuleInitializer]
public static void Bootstrap()
{
EnumMetadataStore<global::exp.AccountKind>.Data = _lazy;
}
private static global::System.Lazy<global::System.Collections.Generic.IReadOnlyDictionary<global::exp.AccountKind, IEnumValueInfo<global::exp.AccountKind>>> _lazy = new global::System.Lazy<global::System.Collections.Generic.IReadOnlyDictionary<global::exp.AccountKind, IEnumValueInfo<global::exp.AccountKind>>>(new global::System.Collections.Generic.Dictionary<global::exp.AccountKind, IEnumValueInfo<global::exp.AccountKind>>
{
{ global::exp.AccountKind.User, new EnumValueInfo<global::exp.AccountKind>("User", global::exp.AccountKind.User, new Attribute[]
{
new global::System.ComponentModel.DescriptionAttribute("User account"),
})
},
{ global::exp.AccountKind.Admin, new EnumValueInfo<global::exp.AccountKind>("Admin", global::exp.AccountKind.Admin, new Attribute[]
{
new global::System.ComponentModel.DescriptionAttribute("Admin account"),
})
},
{ global::exp.AccountKind.Customer, new EnumValueInfo<global::exp.AccountKind>("Customer", global::exp.AccountKind.Customer, new Attribute[]
{
new global::System.ComponentModel.DescriptionAttribute("Customer account"),
})
},
{ global::exp.AccountKind.Manager, new EnumValueInfo<global::exp.AccountKind>("Manager", global::exp.AccountKind.Manager, new Attribute[]
{
new global::System.ComponentModel.DescriptionAttribute("Manager account"),
})
},
});
}
}
Производительность
Предлагаю рассмотреть производительность на следующем примере. Давайте представим что нам нужно найти проперти с атрибутом Required
и под именем FirstName
.
Если таковая существует, то достаем значение этой проперти. В противном случае возвращаем пустую строку. Реализация будет немного странноватой. Это потому что не хотелось бы измерять скорость выполнения LINQ запроса, но основная идея должна быть предельно ясна.
Вот пример того как это выглядит если использовать стандартную рефлексию:
var type = _user.GetType();
var property = type.GetProperty(nameof(User.FirstName));
var required = false;
foreach (var o in property.GetCustomAttributes())
{
if (o.GetType() == typeof(RequiredAttribute))
{
required = true;
break;
}
}
if (required)
{
return (string)property.GetMethod?.Invoke(_user, null);
}
return string.Empty;
А вот пример с aot рефлексией:
var entries = _user.GetProperties();
var firstName = entries[nameof(User.FirstName)];
var required = false;
foreach (var o in firstName.Attributes)
{
if (o is RequiredAttribute)
{
required = true;
break;
}
}
if (required)
{
if (firstName.TryGetValue(_user, out var value))
{
return (string)value;
}
return string.Empty;
}
return string.Empty;
И результаты бенчмарка:
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19043.1165 (21H1/May2021Update)
11th Gen Intel Core i7-11700KF 3.60GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK=6.0.100-preview.7.21379.14
[Host] : .NET 5.0.7 (5.0.721.25508), X64 RyuJIT
DefaultJob : .NET 5.0.7 (5.0.721.25508), X64 RyuJIT
| Method | Mean | Error | StdDev | Gen 0 | Allocated |
|-------------- |------------:|---------:|---------:|-------:|----------:|
| Reflection | 1,758.91 ns | 2.714 ns | 2.406 ns | 0.1278 | 1,072 B |
| AOTReflection | 16.01 ns | 0.090 ns | 0.075 ns | - | - |
Как мы видим AOT.Reflection на много быстрей в сравнении с обычной рефлексией.
Теперь посмотрим на производительность для enum-ов в ситуации когда нам нужно достать значения атрибута DescriptionAttribute
из значения перечисления.
Это будет иметь следующий вид:
var attributes = _account.GetEnumValueInfo().Attributes;
for (int i = 0; i < attributes.Length; i++)
{
var attribute = attributes[i];
if (attribute is DescriptionAttribute descriptionAttribute)
{
return descriptionAttribute.Description;
}
}
return "";
Результаты:
| Method | Mean | Error | StdDev | Gen 0 | Allocated |
|-------------------- |-----------:|----------:|----------:|-------:|----------:|
| GetValuesAOT | 6.253 ns | 0.0394 ns | 0.0329 ns | - | - |
| GetValuesReflection | 734.563 ns | 2.3173 ns | 1.9351 ns | 0.0324 | 272 B |
И опять AOT рефлексия работает на много быстрей.
Полный код бенчмарков можно найти тут.
Ограничения
Я бы рекомендовал быть очень осторожным во время использования AOT рефлексии внутри generic методов, поскольку, на данный момент, нету эффективного способа чтобы их проанализировать и понять необходимые сигнатуры. Это значить что никакой кодогенерации не случится вовсе. В результате мы получим ошибку во время выполнения.
Рассмотрим следующий пример:
public class Program
{
public static string? GetDescription<T>(T enumValue)
where T : Enum
{
return enumValue
.GetEnumValueInfo()
.Attributes
.OfType<DescriptionAttribute>()
.FirstOrDefault()
?.Description;
}
public static void Main()
{
var account = AccountKind.Admin;
Console.WriteLine(GetDescription(account));
}
}
Если мы его запустим, то получим exception, поскольку source generator не смог понять и используемые сигнатуры. Тип T
для него загадка.
Но мы можем это починить небольшим трюком:
public class Program
{
private void DontCallMe()
{
EnumHelper.GetEnumInfo<AccountKind>();
}
public static string? GetDescription<T>(T enumValue)
where T : Enum
{
return enumValue
.GetEnumValueInfo()
.Attributes
.OfType<DescriptionAttribute>()
.FirstOrDefault()
?.Description;
}
public static void Main()
{
var account = AccountKind.Admin;
Console.WriteLine(GetDescription(account));
}
}
Обратите внимание на метод DontCallMe
. Мы не собираемся его использовать вовсе. Он здесь, чтобы помочь source generator-у понять что от него хотят. Теперь, если запустить пример, все отработает как нужно.
Также проблема существует и с рефлексией пропертей, и мы можем использовать такой же трюк чтобы её избежать.
Что работает
На данный момент работают только публичные проперти и enum-ы. Если рассматривать поддержку на приватных членов, то тут не так все просто. Их добавление будет означать просадку в производительности. Я собираюсь посмотреть на это чуть позже.
Конец
Комментарии (13)
byme Автор
16.09.2021 00:59А для enum-ов почему аттрибуты не сгенерировались? Нужны же по идее.
Упс. Мой косяк. Скопировал не из того тестового проєкта. Уже поправил.
Первый вызов конечно медленноват, но последующие будут на уровне вашего решения.
Да. Если не брать в расчет инициализацию должно быть быстро, но в теории могут быть проблемы с AOT компиляцией. Я ещё буду смотреть что и как там.
ARad
16.09.2021 06:51Думаю что намного лучше не строить АОТ рефлексию заранее, а по необходимости, если быстродействия стандартной не хватает.
byme Автор
16.09.2021 10:11Думаю что намного лучше не строить АОТ рефлексию заранее
AOT рефлексия строится зарание, но не инициализируется, и только для типов для которых мы её вызываем. Сама инициализация происходит в момент первого вызова.
если быстродействия стандартной не хватает.
Этот момент не понял. Как мы можем узнать, что быстродействия не хватает на этапе компиляции? Эта информация хранится в голове разботчика и это уже его задача решать что использовать и в какой ситауции.
ARad
16.09.2021 13:08AOT рефлексия строится зарание, но не инициализируется, и только для типов для которых мы её вызываем. Сама инициализация происходит в момент первого вызова.
Зачем она строится заранее? Для быстрого первого вызова? Строится заранее для всех типов? Она занимает место в коде для всех типов?
Так то можно из стандартной построить, по какой то из стратегий кэширования и не будет проблем с универсальными (generic) типами.Этот момент не понял. Как мы можем узнать, что быстродействия не хватает на этапе компиляции? Эта информация хранится в голове разботчика и это уже его задача решать что использовать и в какой ситауции.
Мы можем кэшировать, не на первый вызов, а только при многочисленных вызовах и т. д. Разработчик может выбирать стратегии кэширования и т.д.
ARad
16.09.2021 14:02Генераторы кода штука хорошая, но так их использовать конечно не стоит. Быструю рефлексию легче построить их стандартной рефлексии без использования генераторов.
ARad
16.09.2021 07:04Такая ускоренная рефлексия по необходимости будет строиться по запросу и к тому же может использовать при необходимости разные виды кеширования, например осбождать ресурсы по запросу или по таймеру или использовать слабые ссылки и т.д.
Dotarev
16.09.2021 07:54+1var entries = _user.GetProperties();
А как объявлен _user? Если его тип известен, то рефлексия по сути дела не нужна, ибо результат (
o is RequiredAttribute) известен на этапе компиляции. Гораздо интереснее если _user объявлен как реализация интерфейса, например
IHaveFirstName _user; .....
Как такое работает?
byme Автор
16.09.2021 10:16Если его тип известен, то рефлексия по сути дела не нужна, ибо результат (
o is RequiredAttribute) известен на этапе компиляции
Он то известен, но удобного способа им воспользоваться нет. Разве что разработчик должен сам за этим следить и вовремя подправлять.
Как такое работает?
Сейчас AOT рефлексия зависит от типов над которыми происходят манипуляции, но я уже придумал как можно сделать, так чтобы она понимала, что спрятано за интерфейсом.
mezastel
28.09.2021 16:06Я делаю нечно аналогичное в одном проекте, только вместо сорс генераторов использую Т4. Результат такой же: куча нагенеренных конструктов. А причина: вызов метода через рефлекшн, если его не кэшировать через Delegate.CreateDelegate, является непомерно дорогим удовольствием, особенно когда вызываешь итеративно.
anonymous
byme Автор
Добавил примеры.