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

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Стаття про те, як можна використати C# Source Generator-и для реалізації IoC контейнера і що з цього вийшло. В першу чергу, вона буде цікава всім C# розробникам, щоб просто подивитися, що це за звір, а для десктопних/Xamarin/Unity розробників може бути особливо цікавою, враховуючи специфіку цих платформ.

Головна затія — це створити такий 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<Helper>();
    	bootstrapper.AddTransient<IUserService, UserService>();
	}
}

3. Користуємося нашим контейнером:

  var container = new Container();
  var userService = container.Resolve<IUserService>();

Як видно із прикладу, нічого дивного не сталося. Так, є відмінності, але все буде зрозуміло всім, хто хоч колись працював з 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<global::Helper>())));
	}

	protected Container(ImTools.ImHashMap<Type, InstanceResolver> resolvers, ImTools.ImHashMap<Type, InstanceResolver> scopedResolvers, bool scope = false)
    	: base(resolvers, scopedResolvers, scope)
	{
	}

	public override IZeroIoCResolver CreateScope()
	{
    	var newScope = ScopedResolvers
        	.Enumerate()
        	.Aggregate(ImHashMap<Type, InstanceResolver>.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<IHelper, ReleaseHelper>();
    	}
    	else
    	{
        	bootstrapper.AddSingleton<IHelper, DebugHelper>();
    	}
   	 
    	bootstrapper.AddTransient<IUserService, UserService>();
	}
}

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

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

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

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

Можливості

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

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

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

Якщо розгалядати швидкодію, то її потрібно розгладати у двох випадках. Швидкодію в «розігрітому рантаймі» і швидкодію з «холодного старту». Суперниками будуть:

  • Microsoft.Extensions.DependencyInjection — стандартний IoC в Asp.Net Core
  • Grace — IoC контейнер що займає одні із перших мість в різних бенчмарках

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

Він лежить тут: github.com/byme8/ZeroIoC

Розглянемо в першу чергу швидкодію в «розігрітому рантаймі». Цей бенчмарк буде міряти лише час затрачений на resolve інстанса. Всі бенчмарки міряються за допомогою BenchmarkDotNet і мають приблизно наступний вигляд:

[Benchmark]
public IUserService ZeroTransient()
{
	return (IUserService)_zeroioc.Resolve(typeof(IUserService));
}

Результат в «розігрітому рантаймі» мають наступний вигляд

Як ми бачимо, Grace перемагає у всіх категоріях, але в порівнянні із Microsoft.Extensions.DependencyInjection наш ZeroIoc швидший майже вдвічі. Я дивився, що ж там такого робить Grace, що він такий швидкий, і прийшов до висновку, що нічого магічного він не робить. Крім того, що в ньому використовується власний, вбудований, написаний з нуля, immutable dictionary, що дозволяє йому уникнути додаткових викликів методів. При умові, що виклик метода займає 2-3 ns (а на моїй машині він займає саме стільки), то при теперішніх 6-7 ns (ZeroIoC) проти 3 ns(Grace) виходить, що потрібно викинути хоча б один виклик і ми зрівняємося. Тому було принято рішення зайнятися цим якось пізніше.

Розглянемо тепер ж швидкодію «холодного старту». Цей бенчмарк буде в себе включати створення контейнера і доставання інстансу.

Результати мають наступний вигляд:

В цьому випадку ZeroIoC виявляється набагато швидшим за своїх конкурентів. Більше чим в 10 разів краще за Microsoft.Extensions.DependencyInjection і більш чим в 1000 раз швидше за Grace. Подібний результат досягається шляхом того, що для початку роботи контейнера ZeroIoC не потрібна рефлексія та генерація коду в рантаймі через Reflection.Emit. Все що йому потрібне уже було створено під час компіляції за допомогою source generator-а і тепер він може просто починати працювати.

Я б сказав, що в контексті asp net core сервера, на даному етапі, користі від такої швидкості буде мало, але з релізом .Net 6 Microsoft обіцяє допилить NativeAOT для всього asp net core стека, що дозволить значно зменшити розмір контейнерів за рахунок лінковки та покращити швидкість холодного старту. А класичні IoC просто не можу працювати під AOT, детальніше про це далі.

Якщо розглянути цей IoC контейнер в контексті Xamarin або Blazor застосунку, то користь буде більш конкретною. Можна значно зменшити час холодного старту. Крім того, якщо ми захочемо скомпілювати його в AOT(Ahead of time), а ми захочемо, оскільки, це ще більше покращить швидкість холодного старту, то ми отримаємо ще один бенефіт. Через те, що все працює через source generator, під час стадії лінковки, а вона викидає не потрібні методи та навіть класи, лінкеру буде простіше зрозуміти що використовується, а що ні. Як результат, опинитися в ситуації коли потрібний конструктор був просто викинутий буде набагато складніше.

На цьому буду закінчувати. На мою думку, поява Source Generator-ів стала чудовим нововведенням для .Net екосистеми в цілому. Тепер ми маємо нові інструменти для аналізу нашого коду та подальшій його модифікації. Так, це можна було робити і раніше, але це потребувало багато зусиль як зі сторони розробника бібліотек, так і зі сторони пересічного розробника. Зараз же все набагато простіше.

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

Посилання: Github, Nuget.

👍НравитсяПонравилось4
В избранноеВ избранном2
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Спасибо за статью, круто!
особенно важно я бы выделил этот пойнт из статьи с ответом на вопрос «а зачем же это нужно»

Достатьньо швидкий з мінімальним оверхедом => користувач застосунку написаного на Xamarin не помітить різниці.

ведь в мобильных приложениях очень мало памяти и CPU и любой оверхед действительно может стать проблемой

Подписаться на комментарии