C#

ZeroIoC - IoC контейнер на Source Generator-ах

Stanislav Silin Stanislav Silin 30 серпня
ZeroIoC - IoC контейнер на Source Generator-ах

ZeroIoC IoC контейнер для .NET без рефлексії

Головна затія - це створити такий IoC контейнер який би чудово працював на планформах із AOT компіляцією, таких як Xamarin, Unity та Native AOT. З виходом Roslyn Source Generator-ів реалізувати це стало набагато простіше, оскільки, вони дають зручний API для того щоб аналізувати та генерити код на етапі компіляції. В результаті, можна уникнути використання рефлексії та Reflection.Emit. Що в свою чергу, відкриває можливість використовувати їх разом з AOT компіляцією.

Як використовувати

Думаю варто почати з того як саме користуватися IoC контейнером без рефлексії в порівнянні із звичайним. Тому почнемо

  1. Установлюємо nuget пакет ZeroIoC в проект.

    dotnet add package ZeroIoC
  2. Створюємо контейнер який наслідується від ZeroIoCContainer і зробимо його partial класом(іншу частину згенерить кодогенератор)

    public interface IUserService
    {
    }
    public class UserService : IUserService
    {
        public Guid Id { get; } = Guid.NewGuid();
        public UserService(Helper helper)
        {
        }
    }
    public class Helper
    {
        public Guid Id { get; } = Guid.NewGuid();
    }
    public partial class Container : ZeroIoCContainer
    {
        protected override void Bootstrap(IZeroIoCContainerBootstrapper bootstrapper)
        {
            bootstrapper.AddSingleton();
            bootstrapper.AddTransient();
        }
    }
  3. Використовуємо наш контейнер:

    var container = new Container();
    var userService = container.Resolve();

Як видно із прикладу нічого дивного не сталося. Так є відмінності, але все буде зрозуміло всім хто хоч колись працював з IoC контейнерами.

Як це працює

Разом із nuget пакетом установлюється source generator і аналізатор. Source generator буде шукати класс який наслідувався від ZeroIoCContainer. Потім він спробує знайти метод ZeroIoCContainer.Bootstrap. Залежно від того що там написано source generator згенерить іншу частину partial класу. Якщо взяти за основу попередній приклад, то це буде виглядати наступний чином:

public partial class Container
{
    public Container()
    {
        Resolvers = Resolvers.AddOrUpdate(typeof(global::Helper), new SingletonResolver(static resolver => new global::Helper()));
        Resolvers = Resolvers.AddOrUpdate(typeof(global::IUserService), new TransientResolver(static resolver => new global::UserService(resolver.Resolve())));
    }
    protected Container(ImTools.ImHashMap resolvers, ImTools.ImHashMap scopedResolvers, bool scope = false)
        : base(resolvers, scopedResolvers, scope)
    {
    }
    public override IZeroIoCResolver CreateScope()
    {
        var newScope = ScopedResolvers
            .Enumerate()
            .Aggregate(ImHashMap.Empty, (acc, o) => acc.AddOrUpdate(o.Key, o.Value.Duplicate()));

        return new Container(Resolvers, newScope, true);
    }
}

Тут теж немає нічого складного. Вся логіка базується на словарі з ключем Type та інстансом резолвера як значення. Подібний класс буде згенерований для кожного окремого контейнера і оскільки тут немає нічого статичного, то ми можемо створювати будь яку кількість подібний контейнерів.

Обмеження

Давайте розглянемо метод ZeroIoCContainer.Bootstrap. Це не звичайний метод. На ньому вкастоватва магія. Він дозволяє нам установити відношення між інтерфейсами та їх реалізваціями, але при цьому він не буде виконуваться в рантаймі взагалі. Метод ZeroIoCContainer.Bootstrap - це лише декларація яка буде проаналізована source generat-ором і залежно від того що він там знайде, буде згенерований мапінг. В свою чергу це означає, що немає ніякого сенсу писати в ньому будь яку іншу логіку. Розглянемо наступний приклад:

 public partial class Container : ZeroIoCContainer
    {
        protected override void Bootstrap(IZeroIoCContainerBootstrapper bootstrapper)
        {
            if(Config.Release)
            {
              bootstrapper.AddSingleton();
            }
            else 
            {
              bootstrapper.AddSingleton();
            }

            bootstrapper.AddTransient();
        }
    }

Всі if statement-и будуть просто проігноровані. Тому, щоб уникнути різноманітних WTF-ків(і створити новий) був реалізований додатковий аналізатор, який попередить що так роботи не можна.

Але якщо є необхідність щось змінити в рантацймі, то це можна зробити наступним чином:

var container = new Container();
if(Config.Release)
{
    container.AddInstance(new ReleaseHelper());
}
else 
{
    container.AddInstance(new DebugHelper());
}
var userService = container.Resolve();

Подібний підхід не потребує рефлексії і його можна безпечно використовувати навіть разом з AOT компіляцією.

Можливості

Я б сказав що на разі ZeroIoC знаходиться на стадії MVP. Під MVP я розумію що набір можливостей достатьньо широкий щоб бути корисним в реальному проекті.

Цей набір в себе включає:

  • Декілька IoC контейнерів можуть працювати одночасно.
  • Підтримка singleton, scoped, та transient lifetimes => це базові речі що покривають 99% всіх ситуацій.
  • Працює за рахунок source generat-ора для уникнення рефлексії та Reflection.Emit => може бути використаний разом з AOT Xamarin/Unity.
  • Достатьньо швидкий з мінімальним оверхедом => користувач застосунку написаного на Xamarin не помітить різниці.

Плани

  • Покращити швидкодії(він уже досить швидкий, але я думаю може бути краще)
  • Добавити більше можливостей для кастомізації
  • Створити окремі easy-to-use nuget-и для популярних платформ як Asp.Net Core, Xamarin, Unity3D.

Всім дякую за увагу! Було б цікаво почути ваші думки стосовно такого підходу.

Посилання

Github: https://github.com/byme8/ZeroIoC

Nuget: https://www.nuget.org/packages/ZeroIoC

Коментарі (6)

    1. Nice catch! Здається воно залишилось після моїх старих експереметнів. Звідти уже нічого не використовується. Запустив cleanup всього проекту щоб прибрати подібні штуки.

  1. А які проблеми з рефлексією в Xamarin, Unity?

    1. Тут більша проблема Reflection.Emit. На AOT платформах він відсутній як класс.

      В Xamarin є режим AOT компіляції і лінковка під час якої викидують методи/класси/збірки. В результаті, може виявитися що конструктор сервісу, який зареєстрований в звичайному ІоС контейнері, був просто викинутить оскільки ніхто явно його не визивав. Всі ці оптимізації можуть значно покращити швидкість холодного старту. https://docs.microsoft.com/en-us/xamarin/mac/internals/aot

      https://docs.microsoft.com/en-us/xamarin/android/deploy-test/linker

      В Unity є IL2CPP (Intermediate Language To C++) - це штука яка компілить IL код в С++ і потім білдить його уже як С++ застосунок. Цей спосіб вимушені міра, оскільки iOS дозволяє тільки AOT компіляцію от вони і викручуються.

      https://docs.unity3d.com/Manual/IL2CPP.html

      1. Дякую за пояснення. Якщо суть в тому, що AOT викидає конструктори, то чи не можна реєструвати залежності викликаючи new самостійно і обійтись вже існуючими IoC контейнерами і без кодогенераціі?

        1. Проблема в не тільки в тому що конструктор видаляється. Вона ще і в тому, що AOT не може генерувати код в рантаймі(Reflection.Emit), а це прямо необхідна штука для нормальної швидкодії. Є контейнери які можуть працювати з АОТ і без Reflection.Emit, але вони не такі зручні як звичайні. Наприклад, Splat(https://github.com/reactiveui/splat) він працює за рахунок того, що розробник повиннен самостійно передати делегат який створить інстанс. Це виглядає наступним чином:

          Locator.CurrentMutable.Register(() => new Toaster(Locator.Current.Resolve()), typeof(IToaster));

          P.S. Помітив що обрізаються \< >. @Admin, ти вкурсі про це?)

Щоб залишити коментар необхідно авторизуватися.

Війти / Зареєструватися