Пост

Генерируем факторки. Без регистрации и СМС

Проблема

Есть некий код, который регистрирует сервис

1
builder.Add<MyService>(LifeTime.Singleton).As<IService>();

В целом все окей. Если кто-то спросит у локатара “Дай ка мне IService”, то ему должны вернуть инстанс класса MyService. Должны, но встает следующий вопрос…

Кто создаст бедный инстанс?

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

Самая сложная для решения задача (на данный момент) состояла в вопросе о создании объектов сервисов. Многие DI фрэймворки для C# используют в данном случае подходы:

  1. (Редкий случай) Попытаться скомпилировать Emit код для создания экземпляра (напрямую с помощью IlGenerator и динамических методов или же через Expressions)
  2. Создавать экземпляр с помощью рефлексии
  3. Использовать Activator.CreateInstance (который та же рефлексия)
  4. Дать пользователю самому зарегистрировать факторку или инстанцирующий метод
  5. ???

Обычно это исчерпывающий список… Меня он не то что бы совсем устраивал. Мы же помним: минимум рефлексии! Потому не опять, но снова - кодогенерация.

Немного ретроспективы

Я уже писал о видах кодогена в шарпах, но в этот бложик оно не попало. Потому не лишним будет упомянуть об этом.

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# кода

Что же я использовал, пока генерировал факторки? (ну почти)

Начало пути

Я определил несколько правил, благодаря которым, система может понять - надо ли генерировать факторку для конкретного типа или нет.

  1. Для всех типов внутри которых есть инъекции или конструкторы помеченные атрибутом инъекций (он же Inject) генерируется факторка
  2. Для всех классов используемые как дженерик аргумент в методах помеченных специальным атрибутом, будут сгенерированы факторки.
    1
    2
    
     //Для класса MyService будет сгенерирована факторка
     builder.Add<MyService>(LifeTime.Singleton).As<IService>();    
    
  3. Само собой факторки генерируются только для не абстрактных референс классов. Структуры, интерфейсы, а так же классы наследованные от UnityEngine.Object исключаются из списка генерации
  4. Для дженерик классов генерируются дженерик факторки для большей универсальности кода
  5. Факторка берет в расчет все паблик (или помеченные атрибутом) конструкторы, отдавая предпочтение помеченным, конструкторам с наибольшим количеством параметров. При этом вызван будет первый, для которого все параметры могут быть отресолвлены

В системе есть один метод, помеченный как “регистрирующий” зависимости, но у пользователя будет возможность определять свои методы, если будет такая необходимость

Далее было проведено разделегние ответственностей:

  1. Сами классы факторок генерирует 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);
             }
         }
     }
    
  2. У каждой факторки генерируется статический конструктор, который зарегистрирует данную факторку в пределах системы
  3. Для иницаилизации статического конструктора работает Mono.Cecil. Если факторка генерируется как вложенный класс (случаи, когда в классах есть помеченные с помощью Inject элементы), или факторка генерируется в той же сборке, что и создаваемый класс, то система генерирует вызов пустого статического метода Touch в пределах самого создаваемого типа. Если же факторка и создаваемый класс лежат в разных сборках, то вызов метода Touch вставляется в рамках вызова регистрации (как правило в переделах инсталлера). Так как это происходит уже на пост обработке клиента, то это никак не влияет на код, который видит пользователь, но учитывается в рантайме.
  4. Для ситуаций, если по какой то причине, у системы нет сгенерированной факторки - мы опускаемся до Emit (если платформа позволяет) и рефлексии

Трудности

Основный трудности доставил Mono.Cecil. Это такой очень мощный в плане функционала и абсолютно никакущий в плане удобства комбайн. Банальный примеры проблем с которыми я сталкивался:

  1. Есть некий виртуальный метод, и вам надо найти в коде его вызов. При этом надо понять в пределах какого типа произошел вызов… и оказывается, это не так просто. В IL коде хранится информация о вызове базового метода и системе плевать, что вы использовали суперкласс с переопределением. Пришлось раскручивать стэк вверх, чтоб понять, а кто же у нас источник вызова метода?
  2. Есть перегруз некоего виртуального метода от дженерик класса с дженерик параметром. Например:

    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. Восстанавливать реальную сигнатуру методов и сравнивать уже восстановленные данные между собой (совпадение типов, имен и так далее)

  3. Восстановление базовых классов тоже превращается в адок. Хотите получить базовый класс? Пожалуйста, вот вам он. Хотите получить его методы? Ну перейдите к его определению, потеряв при этом все знания о дженерик типах, если таковые были и работайте с unbound версией. Выход? После каждого чиха реконструировать всю цепочку наследования

И так почти во всем, что касается наследования, перегрузок и дженериков. Roslyn в этом плане на порядок приятнее, так как все эти ресолвинги дженериков и что от чего наследовано - он делает сам под капотом. Вероятно, можно потрудиться и обогатить Mono.Cecil нужным функционалом… но обычно все ограничиваются скромным набором “под свои нужды”, ибо иначе придется учитывать просто тонны возможных ситуаций.

На каком все этапе?

В данный момент готовы:

  • Базовое API биндингов (со своими миксинами, про которые как нибудь напишу отдельно)
  • Генерация внутренних структур локатора/контейнера
  • Скоупы и родительские отношения контейнеров
  • Генерация инъекторов и факторок
  • Начал работать над непосредственно ресолвингом

В планах

  • Написать отдельным модулем обертку для сценовых/объектных контекстов (без рантайм GetComponentInChildren<>())
  • Написать нормальный жизненный цикл для контейнеров, пока с этим непонятки
  • Базовая документация и тесты

Как только эти пункты будут выполнены и система заработает в каком то базовом варианте… все это дело попадет в github в виде опенсорса с MIT лицензией.

И да, в планах нет дефолтной системы всяких ITick и прочего. Мне они не особо нужны, если будет запрос - то подумаю. Ограничусь только IInitializable, с возможностью зарегестрировать свой интерфейс и поддержкой IDisposable.

Авторский пост защищен лицензией CC BY 4.0 .