Генерируем факторки. Без регистрации и СМС
Проблема
Есть некий код, который регистрирует сервис
1
builder.Add<MyService>(LifeTime.Singleton).As<IService>();
В целом все окей. Если кто-то спросит у локатара “Дай ка мне IService”, то ему должны вернуть инстанс класса MyService. Должны, но встает следующий вопрос…
Кто создаст бедный инстанс?
Ранее я уже показывал, как я генерирую инжекторы, дабы избежать рефлексии. Как оказалось это была самая простая задача в долгой череде проблем, которые пришлось решать.
Самая сложная для решения задача (на данный момент) состояла в вопросе о создании объектов сервисов. Многие DI фрэймворки для C# используют в данном случае подходы:
- (Редкий случай) Попытаться скомпилировать Emit код для создания экземпляра (напрямую с помощью IlGenerator и динамических методов или же через Expressions)
- Создавать экземпляр с помощью рефлексии
- Использовать Activator.CreateInstance (который та же рефлексия)
- Дать пользователю самому зарегистрировать факторку или инстанцирующий метод
- ???
Обычно это исчерпывающий список… Меня он не то что бы совсем устраивал. Мы же помним: минимум рефлексии! Потому не опять, но снова - кодогенерация.
Немного ретроспективы
Я уже писал о видах кодогена в шарпах, но в этот бложик оно не попало. Потому не лишним будет упомянуть об этом.
Emit рефлексия
Сюда можно отнести Linq.Expressions и более низкий ILGenerator зависимый код. Данный подход позволяет в JIT платформах генерировать методы, типы и прочие радости жизни, которых изначально не было в скомпилированном AST. Например реализовывать биндинг MVVM модели и вью на лету генерируя вью-модель используя некий язык атрибуточной разметки. Либо генерация методов для “быстрой рефлексии” в DI контейнерах. Либо еще много чего, где в целом может быть полезно расширить функционал непрямым образом.
Постпроцессинг dll
Так как dll в шарпах имеют четкую и хорошо задокумментированную структуру, всегда можно влезть внутрь и подкорректировать ее. Основным и единственным игроком на этом “рынке” является утилита Mono.Cecil. Полное чтение dll, со всеми потрохами без загрузки в память самой библиотеки как чего-то исполняемого. В юнити это можно сделать в момент CompilationPipeline.assemblyCompilationFinished эвента либо посредством встроенного пост процессинга. Фича не документирована, но как старт можно использовать вот этот тред и пакет Entities, в котором данный постпроцессинг активно используется. Что позволяет Mono.Cecil? Да все что душе угодно, были бы руки достаточно прямыми. Можно полностью переписать код, можно добавлять и удалять классы. Из минусов - работа ведется на очень низком уровне, по сути идет все та же генерация или изменение IL кода ручками. Есть утилиты хелперы, но их не так много и они позволяют тоже не то чтоб прям обширный функционал действий.
Для более быстрого погружения крайне рекомендую сайты: cecilifier.me и sharplab.io. Первый поможет понять, какой код надо написать, чтоб сгенерировать какой-то конкретный код. Второй в целом очень полезен для просмотра IL кода и не только
Генерация cs файлов
Тут уже ничего особо хитрого. Некая утилитка, по некоему колбэку генерирует cs файл и складывает его в папочку в проекте. Юнити это регистрирует и перекомпилирует сборку уже с этим файлом.
Roslyn Code Generator
Смысл примерно такой: согласно инструкции создается библиотечка, которая вызывается каждый раз, как юнити начинает компилировать код. Данная библиотечка может добавить (но не поменять) исходники в компилируемую библиотеку. Контекст исполнения как правило имеет внутри себя информацию о всем AST дереве в идейно похожем на рефлексию формате. Помимо генерации можно реализовать анализаторы кода и спамить кастомные ошибки компиляции, поддерживающие какие то внутренние договоренности (например не называть классы с большой буквы B). Данные классы и ошибки будут учитываться при компиляции. У данного подхода есть ограничения. Нельзя менять исходники написанные вручную. Никак. Только добавление кода. Потому если вам нужны точки входа, о них надо позаботиться в написанном руками коде. Нельзя получать доступ к типам, которые сгенерированы другими генераторами. Это все не минусы, а скорее ограничения самой системы.
Здесь так же может быть полезен sharplab.io, так как он умеет показывать синтаксическое дерево, которое будет сгенерировано из C# кода
Начало пути
Я определил несколько правил, благодаря которым, система может понять - надо ли генерировать факторку для конкретного типа или нет.
- Для всех типов внутри которых есть инъекции или конструкторы помеченные атрибутом инъекций (он же
Inject) генерируется факторка - Для всех классов используемые как дженерик аргумент в методах помеченных специальным атрибутом, будут сгенерированы факторки.
1 2
//Для класса MyService будет сгенерирована факторка builder.Add<MyService>(LifeTime.Singleton).As<IService>();
- Само собой факторки генерируются только для не абстрактных референс классов. Структуры, интерфейсы, а так же классы наследованные от
UnityEngine.Objectисключаются из списка генерации - Для дженерик классов генерируются дженерик факторки для большей универсальности кода
- Факторка берет в расчет все паблик (или помеченные атрибутом) конструкторы, отдавая предпочтение помеченным, конструкторам с наибольшим количеством параметров. При этом вызван будет первый, для которого все параметры могут быть отресолвлены
В системе есть один метод, помеченный как “регистрирующий” зависимости, но у пользователя будет возможность определять свои методы, если будет такая необходимость
Далее было проведено разделегние ответственностей:
- Сами классы факторок генерирует Roslyn. Так например для такого кода
1 2 3 4 5 6 7 8 9 10 11 12 13
public class MyService<T> : IMyService where T: class { private readonly T t; public MyService(T t) { this.t = t; } } //И где-нибудь в инсталлерах builder.Add<MyService<object>>().As<IMyService>();
будет сгенерирован код факторки
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
[Obsolete("Generated code. For Neonject.DI internal use only.")] [GeneratedFactory] internal class GeneratedNeonjectFactory_MyService__1<T>: ConstructorFactory<G_NS_1.MyService<T>> where T: class { static GeneratedNeonjectFactory_MyService__1() { var factory = new GeneratedNeonjectFactory_MyService__1<T>(); var registration = factory.RegisterSelf(); registration.AddBaseType<object>(); registration.AddBaseType<G_NS_1.IMyService>(); } private GeneratedNeonjectFactory_MyService__1() { { var constructorData = new ConstructorData<G_NS_1.MyService<T>>(ConstructorPriority.Normal, (container, args) => { var val0 = ((ConstructorArg<T>)args[0]).Resolve(container); return new G_NS_1.MyService<T>(val0); }); var arg0 = new RefConstructorArg<T>("t", false, null, false); constructorData.AddArg(arg0); AddConstructor(constructorData); } } }
- У каждой факторки генерируется статический конструктор, который зарегистрирует данную факторку в пределах системы
- Для иницаилизации статического конструктора работает Mono.Cecil. Если факторка генерируется как вложенный класс (случаи, когда в классах есть помеченные с помощью
Injectэлементы), или факторка генерируется в той же сборке, что и создаваемый класс, то система генерирует вызов пустого статического методаTouchв пределах самого создаваемого типа. Если же факторка и создаваемый класс лежат в разных сборках, то вызов методаTouchвставляется в рамках вызова регистрации (как правило в переделах инсталлера). Так как это происходит уже на пост обработке клиента, то это никак не влияет на код, который видит пользователь, но учитывается в рантайме. - Для ситуаций, если по какой то причине, у системы нет сгенерированной факторки - мы опускаемся до Emit (если платформа позволяет) и рефлексии
Трудности
Основный трудности доставил Mono.Cecil. Это такой очень мощный в плане функционала и абсолютно никакущий в плане удобства комбайн. Банальный примеры проблем с которыми я сталкивался:
- Есть некий виртуальный метод, и вам надо найти в коде его вызов. При этом надо понять в пределах какого типа произошел вызов… и оказывается, это не так просто. В IL коде хранится информация о вызове базового метода и системе плевать, что вы использовали суперкласс с переопределением. Пришлось раскручивать стэк вверх, чтоб понять, а кто же у нас источник вызова метода?
Есть перегруз некоего виртуального метода от дженерик класса с дженерик параметром. Например:
1 2 3 4 5 6 7 8 9
public class Foo<T> { public virtual void Method(T some); } public class Boo: Foo<object> { public override void Method(object some); }
И вот казалось бы, очевидно что метод из
Booэто перегруз… Но ни один метод в рамках Mono.Cecil вам понять это не поможет. В IL коде нет пометок, что от чего произошло. Все что можно сделать, это отслеживать атрибуты методов (не шарповые, а из метаданных) на предмет, что метод виртуальный и есть ли в нем артибут NewSlot. Восстанавливать реальную сигнатуру методов и сравнивать уже восстановленные данные между собой (совпадение типов, имен и так далее)- Восстановление базовых классов тоже превращается в адок. Хотите получить базовый класс? Пожалуйста, вот вам он. Хотите получить его методы? Ну перейдите к его определению, потеряв при этом все знания о дженерик типах, если таковые были и работайте с unbound версией. Выход? После каждого чиха реконструировать всю цепочку наследования
И так почти во всем, что касается наследования, перегрузок и дженериков. Roslyn в этом плане на порядок приятнее, так как все эти ресолвинги дженериков и что от чего наследовано - он делает сам под капотом. Вероятно, можно потрудиться и обогатить Mono.Cecil нужным функционалом… но обычно все ограничиваются скромным набором “под свои нужды”, ибо иначе придется учитывать просто тонны возможных ситуаций.
На каком все этапе?
В данный момент готовы:
- Базовое API биндингов (со своими миксинами, про которые как нибудь напишу отдельно)
- Генерация внутренних структур локатора/контейнера
- Скоупы и родительские отношения контейнеров
- Генерация инъекторов и факторок
- Начал работать над непосредственно ресолвингом
В планах
- Написать отдельным модулем обертку для сценовых/объектных контекстов (без рантайм
GetComponentInChildren<>()) - Написать нормальный жизненный цикл для контейнеров, пока с этим непонятки
- Базовая документация и тесты
Как только эти пункты будут выполнены и система заработает в каком то базовом варианте… все это дело попадет в github в виде опенсорса с MIT лицензией.
И да, в планах нет дефолтной системы всяких ITick и прочего. Мне они не особо нужны, если будет запрос - то подумаю. Ограничусь только IInitializable, с возможностью зарегестрировать свой интерфейс и поддержкой IDisposable.


