В одной из своих статей я уже описывал как можно реализовать рефлексию при помощи 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-ы. Если рассматривать поддержку на приватных членов, то тут не так все просто. Их добавление будет означать просадку в производительности. Я собираюсь посмотреть на это чуть позже.


Конец


Ссылки: Github, Nuget

Комментарии (13)


  1. anonymous
    00.00.0000 00:00


    1. byme Автор
      16.09.2021 00:38

      Добавил примеры.


  1. anonymous
    00.00.0000 00:00


  1. byme Автор
    16.09.2021 00:59

    А для enum-ов почему аттрибуты не сгенерировались? Нужны же по идее.

    Упс. Мой косяк. Скопировал не из того тестового проєкта. Уже поправил.

    Первый вызов конечно медленноват, но последующие будут на уровне вашего решения.

    Да. Если не брать в расчет инициализацию должно быть быстро, но в теории могут быть проблемы с AOT компиляцией. Я ещё буду смотреть что и как там.


  1. ARad
    16.09.2021 06:51

    Думаю что намного лучше не строить АОТ рефлексию заранее, а по необходимости, если быстродействия стандартной не хватает.


    1. byme Автор
      16.09.2021 10:11

      Думаю что намного лучше не строить АОТ рефлексию заранее

      AOT рефлексия строится зарание, но не инициализируется, и только для типов для которых мы её вызываем. Сама инициализация происходит в момент первого вызова.

      если быстродействия стандартной не хватает.

      Этот момент не понял. Как мы можем узнать, что быстродействия не хватает на этапе компиляции? Эта информация хранится в голове разботчика и это уже его задача решать что использовать и в какой ситауции.


      1. ARad
        16.09.2021 13:08

        AOT рефлексия строится зарание, но не инициализируется, и только для типов для которых мы её вызываем. Сама инициализация происходит в момент первого вызова.

        Зачем она строится заранее? Для быстрого первого вызова? Строится заранее для всех типов? Она занимает место в коде для всех типов?
        Так то можно из стандартной построить, по какой то из стратегий кэширования и не будет проблем с универсальными (generic) типами.

        Этот момент не понял. Как мы можем узнать, что быстродействия не хватает на этапе компиляции? Эта информация хранится в голове разботчика и это уже его задача решать что использовать и в какой ситауции.

        Мы можем кэшировать, не на первый вызов, а только при многочисленных вызовах и т. д. Разработчик может выбирать стратегии кэширования и т.д.


      1. ARad
        16.09.2021 14:02

        Генераторы кода штука хорошая, но так их использовать конечно не стоит. Быструю рефлексию легче построить их стандартной рефлексии без использования генераторов.


  1. ARad
    16.09.2021 07:04

    Такая ускоренная рефлексия по необходимости будет строиться по запросу и к тому же может использовать при необходимости разные виды кеширования, например осбождать ресурсы по запросу или по таймеру или использовать слабые ссылки и т.д.


  1. Dotarev
    16.09.2021 07:54
    +1

    var entries = _user.GetProperties();

    А как объявлен _user? Если его тип известен, то рефлексия по сути дела не нужна, ибо результат (o is RequiredAttribute) известен на этапе компиляции. Гораздо интереснее если _user объявлен как реализация интерфейса, например

    IHaveFirstName _user;
    .....

    Как такое работает?


    1. byme Автор
      16.09.2021 10:16

      Если его тип известен, то рефлексия по сути дела не нужна, ибо результат (o is RequiredAttribute) известен на этапе компиляции

      Он то известен, но удобного способа им воспользоваться нет. Разве что разработчик должен сам за этим следить и вовремя подправлять.

      Как такое работает?

      Сейчас AOT рефлексия зависит от типов над которыми происходят манипуляции, но я уже придумал как можно сделать, так чтобы она понимала, что спрятано за интерфейсом.


  1. anonymous
    00.00.0000 00:00


  1. mezastel
    28.09.2021 16:06

    Я делаю нечно аналогичное в одном проекте, только вместо сорс генераторов использую Т4. Результат такой же: куча нагенеренных конструктов. А причина: вызов метода через рефлекшн, если его не кэшировать через Delegate.CreateDelegate, является непомерно дорогим удовольствием, особенно когда вызываешь итеративно.