Генерация кода во время работы приложения: реальные примеры и техники

в 12:04, , рубрики: .net, C#, dotnet, dotnext, dotnext2018, dotnext2018piter, Блог компании JUG.ru Group, Компиляторы, конференции

Генерация кода в рантайме — очень мощная и хорошо изученная техника, но многие разработчики все еще неохотно её используют. Обычно изучение Expression Trees начинают с какого-нибудь простого примера типа создания предиката (фильтра) или математического выражения. Но не Expression Trees единым жив .NET-разработчик. Совсем недавно появилась возможность генерировать код, используя сам компилятор — это делается с помощью API библиотек Roslyn/CodeAnalisys, предоставляющих, кроме всего прочего, еще и парсинг, обход и генерацию исходников.

Эта статья основана на докладе Raffaele Rialdi (Twitter: @raffaeler) на конференции DotNext 2017 Moscow. Вместе с Рафаэлем мы проанализируем реальные способы использования кодогенерации. В отдельных случаях они позволяют очень сильно улучшить производительность приложения, что в свою очередь приводит нас к дилемме — если сгенерированный код так полезен и мы собираемся его часто использовать, то как же отлаживать этот код? Это один из фундаментальных вопросов, возникающих в реальных проектах.

Рафаэль — практикующий архитектор, консультант и спикер, имеющий MVP в категории Developer Security начиная с 2003 года, который прямо сейчас занимается бэкендами enterprise-проектов, специализируясь на генерации кода и кроссплатформенной разработки для C# и C++.

Что такое генерация кода? Предположим, необходимо продемонстрировать производительность. Если просто показать бенчмарк — это был бы своего рода фокус, хитрый трюк. В статьях и докладах стоит избегать показа бенчмарков — не потому, что это опасно для автора, а потому, что бенчмарк демонстрирует только к один сценарий, и вряд ли он полезен для всех читателей. Читателя принуждают опробовать предложенные технологии и решить, подходят они или нет для его конкретных сценариев. Поэтому не стоит преувеличивать значение бенчмарков. Для себя я их делаю, они показывают приличные результаты.

Мы знаем, что программа с рефлексией по определению будет работать медленно. Ей необходимо загрузить метаданные ECMA-335 и интерпретировать их. Они представляют из себя очень компактный набор бинарных данных, их чтение достаточно сложное. Они должны быть компактными, поскольку не должны занимать слишком много памяти после сборки. После того, как эти артефакты развертываются, производительность оказывается неудовлетворительной, поскольку мы имеем дело с очень низкоуровневым API. Кстати говоря, рефлексии можно избежать, если загружать все эти артефакты напрямую из сборок. Об этом я не буду говорить в сегодняшнем докладе, но, если вам это интересно, такой метод я уже применял, чтобы избежать постоянной загрузки и сборки в памяти; можно освободить память от всего, кроме информации о типах.

Когда именно следует генерировать код? На том участке жизненного цикла приложения, когда появляется достаточно информации, позволяющей упростить алгоритм. Речь идет, например, об информации, которую можно получить из пользовательского интерфейса для фильтра, который сократил бы количество получаемых из базы данных записей. Или об информации о типах, загруженных в плагине. Крайне нежелательно тратить время на создание при помощи рефлексии общего алгоритма, который учитывал бы все возможные варианты. У разработчиков, к сожалению, есть тенденция пытаться делать разрабатываемые ими решения максимально общими, работающими во всех возможных и невозможных случаях. Для нашего программистского ума это естественный ход мыслей. Я предлагаю ровно противоположный подход: терпеливо ждать до тех пор, пока не будет достаточно информации для генерации максимально лаконичного кода.

В каких именно случаях может быть необходима генерация кода? Например, при использовании предикатов LINQ. Билдеры предикатов доступны уже давно. Или при использовании формул, скажем, из Excel. Или при загрузке типов из плагина, или при использовании Reactive Extensions. Кто из вас знаком с Reactive Extensions? Это замечательная библиотека, которая позволяет создавать потоки данных и применять выражения, способные фильтровать группы и изменять эти данные. Я покажу многие из этих примеров чтобы продемонстрировать возможности рефлексии.

Генерация кода во время работы приложения: реальные примеры и техники - 1

Начнем с Expression в C#. На экране простой пример кода, в котором генерируется вызов Console.WriteLine. Возможно, кто-то спросит — зачем использовать рефлексию, если вы только что указали на недостатки использования рефлексии? Ответ не в том, чтобы отказаться от рефлексии вообще, а в том, чтобы убрать ее из наиболее используемых участков кода. Нужно найти точку во времени, в которой при помощи рефлексии можно будет извлечь необходимое количество данных, сгенерировать код и, к примеру, использовать делегирование внутри цикла, чтобы не ждать, пока код будет выполнен.

В коде я начинаю с того, что получаю точную перегрузку WriteLine, затем я создаю параметр, который впоследствии станет входным сообщением. После этого я создаю эквивалент метода Call. В вызове Expression.Call(null, methodInfo, message), null обозначает статический метод (WriteLine является статическим методом). Кроме того, в этом вызове также нужны аргументы с информацией о методе, и с сообщением.

После этого создается лямбда. Это очень просто, нужно указать параметры и тело лямбды. У уже созданной лямбды вызывается весьма полезный метод .Compile(). Он хорош тем, что напрямую и очень простым способом создает инструкцию в памяти. Нет исходного кода, нет ничего, что нужно было бы обрабатывать способами, описанными в "Dragon Book". Нет первой ступени компиляции, т. е. долгого и сложного анализа текста. Он не нужен, поскольку в случае с Expression мы уже знаем, что оно синтаксически верно. Это очень важно. Именно поэтому дерево выражений настолько громоздкое, у него крайне неприятная строгая типизация. Если вы уже когда-то пробовали составлять несколько выражений друг с другом, вы знаете, какая это нервотрепка. Но имея сформированное выражение, его действительно можно скомпилировать. Компилятор просто берет узлы дерева (т. е. определенные выражения), и создает соответствующий узел под тот код, который мы хотим вызвать. В конечном итоге мы формируем делегат, т. е. наиболее быстрое доступное средство для исполнения кода.

Генерация кода во время работы приложения: реальные примеры и техники - 2

Покажу пример, в котором будет создан предикат. Очень простая функция, которая принимает на вход целое число и возвращает булево значение. Посмотрим на ее код. Для первого входного значения там создается параметр: Expression.Parameter(typeof(int), "x"). Один из входных аргументов этого метода — "x", не обращайте на него внимания, он нужен только для отладки. Переменная left обозначает левую часть выражения x > -10, right — правую. Из этих двух переменных создается бинарное выражение сравнения. Наконец, возвращается выражение Lambda. В данном случае это предпочтительнее, чем возвращать делегат, поскольку в случае необходимости будет возможность внести в нее изменения. Для этого можно использовать паттерн Visitor, который пронумерует все узлы внутри выражения, и изменит его очень точным образом. Не нужно никакой работы с текстом, сразу происходит переход к необходимому узлу.

Генерация кода во время работы приложения: реальные примеры и техники - 3

Приведу пример, в котором необходимо посетить некоторый вызов. Предположим, происходит извлечение предикатов из узла where, поскольку код написан на LINQ. Имея нужное Expression, к нему можно написать Visitor. А найти этот Expression можно, поскольку where является вызовом метода расширения. Первый параметр where является IQueryable<T>, и возвращает булево значение. Так что мы знаем, какая именно форма нам нужна. Если же необходимо к этому Expression что-либо добавить, это можно сделать на том месте, где на экране написано многоточие.

Генерация кода во время работы приложения: реальные примеры и техники - 4

Чтобы не заскучать, давайте перейдем к демонстрациям. Изначально я не хотел писать средство для синтаксического анализа, поскольку это скучно, такая программа обычно получается медленная, и есть библиотеки, которые выполняют эту задачу лучше, чем самописный код. Мне нужно было нечто небольшое и легко изменяемое. А при написании средств синтаксического анализа приходишь к тому, что надо писать грамматику, приходится использовать много библиотек. Кроме того, хотелось написать инструмент таким образом, чтобы созданные после анализа узлы были похожи на то, что Expressions выражают на самом деле. В итоге я пришел к тому, чтобы представить, к примеру, выражение x + y (которое вы видите в коде) в форме текста, и затем распознать его.

Генерация кода во время работы приложения: реальные примеры и техники - 5

То есть я попытался выразить параметры вручную. Я это сделал для простоты, и возможно, этого можно избежать. По крайней мере, важно указать типы, поскольку в Expression нельзя пользоваться первым этапом компиляции. К примеру, недоступно автоматическое преобразование типов или неявное преобразование типов, недоступно преобразование integer в double. Все это приходится делать вручную.

Если запустить в отладчике код, который вы видите на экране, то будет возвращено Expression. Лямбда представлена в отладчике Visual Studio достаточно странным образом, но ничего страшного в этом нет. Выглядит сложно, но в конце концов, это просто x + y, с этим можно жить.

Генерация кода во время работы приложения: реальные примеры и техники - 6

Посмотрим, как можно перевести написанную мной текстом функцию SUM(). Визуализатор текста показывает нам переменную e, в которой в данный момент находится результат перевода. Видно, что я определил FunctionsHelper с предопределенной функцией, так, как это делает Excel. Такого рода приложения должны предопределять своего рода словарь функций. Все это достаточно просто.

Генерация кода во время работы приложения: реальные примеры и техники - 7

Попробуем пройти немного дальше в коде. Там есть функция GetFilter().

Генерация кода во время работы приложения: реальные примеры и техники - 8

Как видим, это лямбда. Обычно в таких случаях возвращается Func<int, bool> и больше ничего. Но у компилятора есть особенная возможность, которая позволяет, в случае отсутствия в теле функции квадратных скобок, возвращать Expression<Func<int, bool>>. То есть автоматически создается Expression для этого представления. Это очень удобно, поскольку его по-прежнему можно изменять. Если вы хотите убрать число и заменить его чем-либо еще, можно просто написать Visitor для выражения и сделать при помощи него все необходимые изменения.

Генерация кода во время работы приложения: реальные примеры и техники - 9

Посмотрим на вторую демонстрацию. В ней у нас с самого начала есть предикат Expression<Func<int, bool>> predicate.

Я хочу сделать в него инъекцию, которая давала бы вывод на командную строку. Я передаю в инжектор предикаты и две лямбды и указываю при получении значения x каждый раз выводить {x} => YES или {x} => NO. Если мы посмотрим на то, как выглядит переменная injected после запуска приложения, то увидим функцию с оператором If, она была достаточно сильно изменена по сравнению со своим первоначальным значением.

Генерация кода во время работы приложения: реальные примеры и техники - 10

Итак, здесь на вход подается целое число, делается инъекция If, на консоль выводится YES или NO в зависимости от значения, и, наконец, возвращается обработанное выражением значение. Такого рода изменения кода уже вошли в практику, и они весьма мощные.

Есть проблема, на которую, вы, вероятно, уже обратили внимание — визуализатор, в котором я до сих пор показывал вам сгенерированный код, представляет информацию в достаточно странной форме. Программирование с выражениями дает определенные преимущества, но с точки зрения разработчика код получается «грязный».

Вернемся к демонстрации. Я уже говорил об отложенном выполнении: пока не будет завершено перечисление чисел, следующий код не будет выполнен. Если прямо сейчас дойти до toList, мы получим и список, и Console.WriteLine для них, которые в этом случае выполнятся автоматически.

Генерация кода во время работы приложения: реальные примеры и техники - 11

Генерация кода во время работы приложения: реальные примеры и техники - 12

Всё это выглядит неплохо, но хочется попробовать нечто более сложное. Следующий пример пришел ко мне во сне. Я хочу создать лямбду, которая, будучи скомпилированной, преобразует данные в словаре (возможно, JSON) в определенном порядке. Задача достаточно обычная.

Генерация кода во время работы приложения: реальные примеры и техники - 13

Если выполнять этот код при помощи рефлексии, результат получится такой, который вы сейчас видите на экране.

Генерация кода во время работы приложения: реальные примеры и техники - 14

Происходит итерация по свойствам отображения, поиск соответствия в словаре для каждого свойства и копирование. Очевидно, этот код будет медленным. Если он выполняется только один раз, это не проблема, но если его нужно выполнить миллион раз — ну, вы поняли. Если это будет происходит в серверном приложении, потребляющем ресурсы сервера, кому-то это может не понравиться.

Попробуем решить эту проблему другим образом. Здесь в коде создается объект `Order, элементы которого будут поставлены в соответствие с поступающим на вход в класс словарем.

Генерация кода во время работы приложения: реальные примеры и техники - 15

Из словаря извлекаются значения, затем они приводятся к необходимому типу, копируются, и это все совершенно жутко и скучно.

Но что, если я создам лямбду, которая уже будет знать объект Order?

Генерация кода во время работы приложения: реальные примеры и техники - 16

Важно, что я указываю тип этого объекта. Заметьте, что я не использую <Order>. Это было бы здорово, но что, если мы не знаем этот тип? Что, если Order определяется в плагине с отложенной загрузкой? В некоторых случаях дженерики могут помочь, но в данном случае их использование было бы нежелательным, поскольку нам может понадобиться абстрагироваться от этой информации.

Итак, посмотрим на нашу лямбду после компиляции.

Генерация кода во время работы приложения: реальные примеры и техники - 17

Правда, она хороша? Код приятно читать. Он был сгенерирован при помощи Expressions. Давайте посмотрим на то, как они написаны в классе ExpressionGeneration.

Генерация кода во время работы приложения: реальные примеры и техники - 18

Мы видим, что код похож на то, что я написал при помощи рефлексии. Определяется Expression.Parameter(), определяется переменная result, создается новый newEntityType с помощью Activator.CreateInstance, новый экземпляр присваивается переменной assign. Всё очень скучно. Затем я получаю метод через type.getMethod() и после этого обхожу свойства entityProps.

Генерация кода во время работы приложения: реальные примеры и техники - 19

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

Генерация кода во время работы приложения: реальные примеры и техники - 20

В следующей строке вызывается метод Expression.Convert(), для него необходимо сделать приведение к типу, поскольку типы могут быть разные. Далее для доступа к свойству делается вызов Expression.MakeMemberAccess(). После этого делается вызов Expression.IfThen() для конструкции try-catch. Наконец, создается блок, т. е. открывающая и закрывающая скобка. И в результате мы получаем лямбду.

Генерация кода во время работы приложения: реальные примеры и техники - 21

Я написал инструмент ExpressionsSorcerer. Можно взять его код и поместить в директорию %USERPROFILE%/Visual Studio 2017/Visualizers, и снова запусть отладку только что рассмотренного кода. На этот раз я смогу увидеть лямбду через визуализатор, она будет представлена в виде дерева.

Генерация кода во время работы приложения: реальные примеры и техники - 22

Такого рода операция может быть очень полезной, помогает задуматься, а что за чертовщину я тут написал? При выделении отдельного узла дерева в окне справа появляются свойства и их значения, что весьма удобно. Открываем вкладку «Show the decompiled source» («Показать декомпилированный источник»). Перед нами код, который я написал бы, если бы обладал той информацией, которая была передана генератору кода.

Генерация кода во время работы приложения: реальные примеры и техники - 23

Но я и пальцем не притрагивался к этому коду. Я даже не генерировал C#-код. Я написал Expressions, т. е. в памяти находились только синтаксические узлы, и мне нужно было проводить декомпиляцию. Благодаря Roslyn здесь так же есть цветовая разметка, при необходимости она может быть изменена. Кроме того, я добавил атрибут DebuggableAttribute, поскольку мне не нужны оптимизации, которые могут возникнуть в ходе компиляции. Возможно, вы спросите, почему они мне не нужны? А в ответ у меня для вас будет еще один сюрприз.

Если скомпилируем с отладкой (нажатием «F11»), мы войдем в автоматически сгенерированный метод, которого мы своими руками не писали. Впечатляет, не правда ли? Здесь видны текущие значения переменных, можно проверить, нет ли ошибок в Expressions. Как видим, значения Description во входном аргументе не было, так что метод TryGetValue был использован не зря.

Генерация кода во время работы приложения: реальные примеры и техники - 24

В конце рассматриваемой функции мы получаем переменную order с правильным числом значений.

Подведу промежуточный итог. Expressions охватывают практически весь язык, при помощи них можно генерировать операторы if, throw, catch, можно создавать сложные конструкции. Но для этого, скорее всего, понадобится специальный инструмент. В моем инструменте наиболее сложной для написания частью были неявные преобразования типов. Если вы создали переменную double x, и попытаетесь присвоить переменной с типом integer ее значение, вы получите исключение InvalidCastException. Причина заключается в том, что неявное преобразование выполняется компилятором, а у нас его не было. Поэтому пришлось заниматься некоторыми вещами, которые обычно выполняет компилятор.

Давайте я продемонстрирую некоторые более сложные выражения. На экране код, в котором создается очень простой объект var newObject = ExpressionInterop.BuildNewObject(ctor).

Генерация кода во время работы приложения: реальные примеры и техники - 25

Если посмотреть на него в средстве визуализации, будет видно, как создается новый объект new Order().

Генерация кода во время работы приложения: реальные примеры и техники - 26

По уже упомянутым причинам, я всегда рекомендую использовать метод typeof(). Далее, через метод GetConstructor я получаю нужный мне конструктор, а затем через методы GetMethod — нужные методы. После этого создается новый объект, которому передается информация о конструкторе: ExpressionInterop.BuildNewObject(ctor). И так далее.

Я не буду подробнее на этом останавливаться. Но я хотел бы показать вам, как выглядит выражение, когда вы присваиваете значение свойству…

Вот артефакты компиляции:

Генерация кода во время работы приложения: реальные примеры и техники - 27

Генерация кода во время работы приложения: реальные примеры и техники - 28

Но если мы вернемся к настоящему Expression, оно выглядит достаточно запутанно. Одно из самых сложных созданных мной Expressions используется для маршалирования. Я сгенерировал код, который позволяет мне выполнить асинхронный код для AddAsync...

Генерация кода во время работы приложения: реальные примеры и техники - 29

… даже если в выражении нет кода, который мог бы представлять Task<T>.

Генерация кода во время работы приложения: реальные примеры и техники - 30

Код достаточно запутанный, перекомпилировать его не удастся, поскольку компилятор, Mono.Cecil, не может создать идеальную декомпиляцию. Возможно, ему это будет по силам в будущем. Кроме того, проблема здесь в том, что для Task<int> необходимо сделать инъекцию внешней функции. Это нужно потому, что Expressions были созданы до асинхронных библиотек и до изменений в компиляторе для поддержки async/await. Поэтому невозможно провести генерацию с компилятором и воспользоваться await. Компилятор выполняет все волшебство, так что если вы воспользуетесь ILSpy и посмотрите на артефакт, созданный при помощи await, то увидите там колбек с продолжением. Код получается очень усложненным.

Итак, на чем мы остановились? Мы создали Expressions для генерирования определенных предикатов, функций, достаточно сложных кусков кода с конструкциями if-then-else, throw-catch и многого другого. Давайте теперь поговорим о Roslyn.

Генерация кода во время работы приложения: реальные примеры и техники - 31

Roslyn — это платформа компилятора .NET, в течение уже нескольких лет работает как основной компилятор для C#. Иначе говоря, он правит нашим миром. Раньше мы мало что могли делать, но Roslyn открыл API для нас. Теперь при помощи API этого компилятора мы можем напрямую делать кучу всего. У нас есть форматирование, информация о символах, можно компилировать разные вещи, интерпретировать символы, влезать в метаданные, лежащие за ассемблером, и многое другое. Что касается цветовой разметки, то напрямую ей Roslyn не управляет. Он не указывает: «это должно быть зеленым, а то — синим». Просто есть классификация анализируемых лексем, и их можно по-разному отображать.

Таким образом, у нас доступно достаточно много инструментов, но есть проблема. В Roslyn нет строгой типизации. Есть синтаксические узлы, и они очень просты в использовании, поскольку любой элемент является синтаксическим узлом. Нет необходимости тратить внимание на то, чтобы соединять узлы друг с другом. Но у этого есть обратная сторона. Без той самой жесткой типизации, которая так сильно действует на нервы, когда работаешь с Expressions, мы никогда не знаем наверняка, будет ли правильно работать код, который мы пишем. Поэтому с Roslyn больше шансов возникновения ошибок, чем в коде, написанном с Expressions.
И все же, преимущества Roslyn велики. Им покрывается весь язык, т. е. создавать можно любые конструкции. Например, к Roslyn можно обратиться, если необходимо создавать новые типы во время выполнения программы. Предположим, я хочу создать DTO (Data Transfer Objects) несуществующего объекта во время выполнения. Я не хочу прибегать к помощи AutoMapper, поскольку AutoMapper обычно пользуются во время разработки. Созданный тип должен будет способен фильтровать события, каждое из которых будет различного типа. Если вы хотите указать Expression, его необходимо создать и затем работать с типом, представляющим эти данные. А для их десериализации понадобится DTO.

Первый и наиболее простой способ генерировать код при помощи Roslyn — средство синтаксического анализа, у которого есть API.

Генерация кода во время работы приложения: реальные примеры и техники - 32

Оно анализирует текст, создает синтаксическое дерево, с которым дальше можно совершать самые разные операции: менять формат, делать красивые отступы, преобразовывать. Предположим, необходимо провести рефакторинг API, поменять имена переменных или заменить вызов, скажем, Console.WriteLine на Console.Write. Вместо того, чтобы создавать все с нуля, можно прочитать имеющийся код, использовать его как шаблон, и заменить только необходимое. Для этой цели очень хорошо подходит шаблон Visitor. Можно посетить некоторые из лексем в приложении, и, найдя нужную, заменить. Как видно из слайда, форматирование осуществляется весьма просто.

Если этой функциональности недостаточно, можно воспользоваться SyntaxGenerator. Это мощный высокоуровневый API, под ним есть синтаксическая фабрика. В нем можно объявлять пространства имен, классы, атрибуты, параметры, иначе говоря, это полноценный язык. А при помощи команды node.AdjustWhitespace() можно сделать стандартные пробелы между узлами.

Генерация кода во время работы приложения: реальные примеры и техники - 33

Для начала посмотрим на несколько примеров работы этого инструмента. В первом из них мы используем SyntaxFactory, из которой получаем SyntaxTrivia, QualifiedName, CompilationUnit, UsingDirective. Возможно, вы скажете — это же еще хуже, чем деревья Expressions. Но то, что вы видите здесь — это низкоуровневый API. Знать его полезно, и его можно исследовать при помощи SDK для Roslyn. В нем можно увидеть, как создается синтаксическое дерево кода, как друг к другу присоединяются узлы в Roslyn. Это очень важно, поскольку, если вы не понимаете, как представить вызов метода, какие там дженерики, возвращаемое значение или еще что-то, это все можно увидеть здесь, в визуализаторе синтаксического дерева. Итак, это мощное средство, при помощи него можно создавать красивые диаграммы и многое другое.

Вернемся к нашему примеру, я снова запускаю в нем отладчик. После получения последнего синтаксического узла оказывается доступен исходный код. В теории, можно обойтись только синтаксическими узлами и избавиться от исходного кода, поскольку при компиляции, конечно, не хочется заново повторять синтаксический анализ всего дерева. Тем не менее, наличие кода может быть полезно по двум причинам. Во-первых, это единственный способ указать кодировку. Компилятор может ошибиться, если неправильно понимает кодировку исходного кода. Во-вторых, наличие кода очень важно при отладке. Даже в продакшне исходники сгенерированного кода стоит какое-то время хранить. Он может служить своего рода журналом.

Второй пример с Roslyn также очень простой.

Генерация кода во время работы приложения: реальные примеры и техники - 34

Генерация кода во время работы приложения: реальные примеры и техники - 35

Мы можем сравнить сгенерированный код в переменной text:

Генерация кода во время работы приложения: реальные примеры и техники - 36

И то, как он стал выглядеть после форматирования (text2):

Генерация кода во время работы приложения: реальные примеры и техники - 37

Как видим, больше не осталось предлогов пользоваться StringBuilder-ом для создания кода.

Следующий пример.

Генерация кода во время работы приложения: реальные примеры и техники - 38

Я беру кусок сгенерированного кода:

Генерация кода во время работы приложения: реальные примеры и техники - 39

И преобразовываю его с помощью PostProcess(SyntaxNode root). Это возможно благодаря тому, что LINQ ищет узлы, которые мне нужны, и заменяет их на другие. Скажем, мне необходимо заменить команду Console.WriteLine на Console.Write. После этого нужно найти блок в скобках и добавить метод Console.ReadKey(). В итоге преобразованный код будет выполнять Console.Write и сразу за ним Console.ReadKey.

Генерация кода во время работы приложения: реальные примеры и техники - 40

Таким образом, код можно изменять в зависимости от необходимости. Это несложно и дает огромное количество возможностей.

И еще один пример.

Генерация кода во время работы приложения: реальные примеры и техники - 41

Я создал здесь высокоуровневый синтаксис, представленный объектом CodeGenerationHelper(). При помощи SyntaxGenerator можно абстрагироваться, чтобы создавать классы, их свойства и др.

Генерация кода во время работы приложения: реальные примеры и техники - 42

В данном примере создается POCO в качестве DTO, очень удобно.

Я хотел бы продемонстрировать вам еще один генератор, более продвинутый.

Генерация кода во время работы приложения: реальные примеры и техники - 43

Мы все любим абстракции, не правда ли? Генератор синтаксиса, который может практически все, скорее всего, будет сложен в использовании. Для простоты его можно несколько ограничить под отдельный вариант использования. В коде вы видите два класса. В первом из них просто создаются свойства, как именно это происходит — я покажу в тесте. Но вначале я продемонстрирую работу генератора.

Генерация кода во время работы приложения: реальные примеры и техники - 44

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

Во втором примере в моей абстракции делается вызов AddImplementINotifyPropertyChanged().

Генерация кода во время работы приложения: реальные примеры и техники - 45

Генерация кода во время работы приложения: реальные примеры и техники - 46

Так что, если посмотреть в визуализаторе на поле result.DiagnosticReport, мы увидим полную реализацию класса INotifyPropertyChanged.

Генерация кода во время работы приложения: реальные примеры и техники - 47

Тут есть объявление события, сеттер для string _name с вызовом OnPropertyChanged(), и реализация метода OnPropertyChanged, с [CallerMemberName] — т. е. так, как учат в руководствах. Это весьма неплохой результат. Некоторые из этих примеров я размещу на GitHub, чтобы вы могли напрямую с ними поработать.

Возникает вопрос — а как все это волшебство происходит? Мне для этого пришлось написать достаточно много кода, как видите — в моем генераторе много классов, это просто оболочки для всех возможных действий SyntaxGenerator. Сейчас я всех их рассматривать не буду, но, если хотите, их можно обсудить позже. Кстати говоря, в наиболее простом из приведенных примеров дополнительных классов совсем немного. Посмотрим на класс SimpleClassGenerator.

Генерация кода во время работы приложения: реальные примеры и техники - 48

В конструкторе генерируется код, в поле HashSet<PortableExecutableReference> Reference хранятся нужные ссылки, например System.Runtime. Кстати говоря, большая часть продемонстрированного кода может работать и на .NET Core, и на .NET Framework, но я — поклонник .NET Core, поэтому демонстрации обычно делаю на основе него.

Генерация кода во время работы приложения: реальные примеры и техники - 49

Возвращаясь к классу SimpleClassGenerator, там есть также словарь строк IDictionary<string, Properties> Properties, в котором накапливается информация о синтаксических узлах. В методе GetSource() делается вызов к другому методу того же класса, BuildClass(), в котором просто добавляются объявления классов к синтаксическим узлам.

Генерация кода во время работы приложения: реальные примеры и техники - 50

Затем в этом методе для каждого элемента в словаре я указываю лямбда-выражение, телом которого является вызов метода CreateProperty().

Генерация кода во время работы приложения: реальные примеры и техники - 51

Там, в свою очередь, происходит создание тривиального свойства. Несмотря на то, что свойство тривиальное, метод получился большой. Причина этого проста. Обычно у свойств есть backfield. Чтобы сделать свойство тривиальным мне необходимо удалить часть кода и заменить его более простой аннотацией. Именно поэтому получение и указание Accessor занимает по три строчки.

Потратим последние несколько абзацев статьи на очевидный вопрос. Раз уж зашла речь о генерации кода, почему ничего не сказано об IL? Вы, наверное, представляли, что в докладе будет речь про Reflection.Emit, про ковыряние в памяти напрямую и такого рода вещи. Признаюсь, что обожаю ассемблер. Я родился с ним. Я начинал программировать на x86-ассемблере. На том этапе он действительно был нужен, была большая разница между тем, чтобы работать с переменной в памяти или с регистрами. Рассчитывать на компилятор было нельзя. Но теперь-то все иначе. Теперь нужен очень серьезный повод, чтобы обратиться к ассемблеру, а не просто желание «кодить ближе к железу». Мне-то это нравится, но это другой вопрос.

Возможность изменения кода IL может, действительно, быть очень полезной. Представьте, что есть сторонний файл .dll, и мы не знаем, что он делает. При помощи ILSpy можно открыть этот файл и посмотреть на код. Но здесь возникает знакомое ощущение: код перед глазами, но все равно непонятно, что именно будет происходить во время выполнения. Доступно огромное количество классов, которые поначалу ничего не говорят. Чтобы как-то разобраться, можно установить плагин Visual Studio для кода IL, который позволяет делать рефлексию во время выполнения и отлаживать код. Но теперь для каждого запуска нужно нажимать «F11», и после нескольких часов такой работы уже не остается терпения, а смысл кода все равно не ясен.

Есть вещи, которые в такой ситуации можно автоматизировать. Ведь, в сущности, наша работа сводится к автоматизации различных задач. Mono.Cecil — очень мощное средство. Оно позволяет извлекать инструкции, декомпилировать, фильтровать, просматривать, анализировать их содержимое, рассматривать аргументы инструкций. Представим сценарий, в котором необходимо опознать все вызовы из определенного участка кода. Похожих сценариев очень много, на GitHub много инструментов, выполняющих похожие задачи. Итак, я буду ждать вызовы и искать выполняющий их код. Но вместо того, чтобы при отладке нажимать «F11» на каждый вызов, я хочу инструментировать этот код IL, чтобы осуществлялось логирование.

Как именно будет это выглядеть? У меня есть библиотека sample1.dll, в ней класс DataHelper, который эмулирует вызов к базе данных.

Генерация кода во время работы приложения: реальные примеры и техники - 52

Класс Employee является DTO. Есть класс Person, класс Printer, дающий вывод на консоль. И, наконец, класс Main, который в зависимости от аргумента выполняет один из двух методов запуска.

Генерация кода во время работы приложения: реальные примеры и техники - 53

Метод Start1 просто выводит данные Person на экран. Start2 загружает данные, создает лямбда-выражение, которое преобразует входной аргумент в строку, и, получив список строк, выводит их на экран при помощи Printer. Стоит отметить, что конструкция for-each в методе ToList() запускает множество вещей. Там происходит много внутренних для Linq процессов, `Enumerable и многое другое.

Итак, попробуем запустить это приложение. Я воспользуюсь AssemblyHooker, который выступает в качестве ловушки (trap) для библиотеки .dll. Теперь можно открыть VisualStudio Code, и посмотреть на возникший в результате работы приложения журнал. Этот журнал был создан кодом, который я вставил в sample1.dll.

Генерация кода во время работы приложения: реальные примеры и техники - 54

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

Генерация кода во время работы приложения: реальные примеры и техники - 55

В итоге мы получаем диаграмму потока того, что происходит во время выполнения программы. Это не статический анализ, в котором черт ногу сломит. Это то, что мы имеем после запуска. Мы видим на диаграмме момент, когда были заданы значения имени и возраста, а затем, после метода WriteLine, мы получаем эти значения и преобразуем их в строки. В диаграмме также указаны акторы каждого действия: Program, Person, Console. На мой взгляд, это потрясающее средство.

Генерация кода во время работы приложения: реальные примеры и техники - 56

Попробуем теперь запустить программу через второй метод класса Main в sample1.dll. Здесь все будет значительно сложнее. Как вы помните, во втором методе выполняется много различных действий. Выполнив те же операции, что и с первым методом, получаем диаграмму PlantUML. Как видим, одна из осей в диаграмме обозначена как Enumerable, т. е. мы видим даже то, что происходит внутри фреймворка. Я ничего не записывал на жесткий диск, вставка кода происходила в памяти, во время выполнения программы. Поэтому вы видели тот вызов с рефлексией в классе Main.

Итак, в этом, на мой взгляд, заключается потенциал использования IL напрямую. Генерировать методы с его помощью, по-моему, занятие бесполезное, для этого есть более подходящие средства. Но вот модификация существующего кода IL может помочь в определенных ситуациях.

В заключение скажу, что, в моем видении, будущее генерирования кода скорее всего будет связано с искусственным интеллектом. Признаю, в этом есть нечто пугающее. Я вижу такую схему: своего рода контракт из очень маленьких библиотек, способных генерировать необходимый код, и над ними алгоритмы, движимые ИИ. Это может стать очень мощной машиной. Что из этого возникнет? Сказать не могу. Я пока только изучаю это поле, если вы хотели бы заняться этим вместе со мной, я буду только рад.

Подведу итоги. Не используйте генерацию кода где попало, освобождайте от нее наиболее часто используемые участки кода. Есть важный инструмент, который я предлагаю вам протестировать — Roslyn Quoter. Если в него вбить некоторое выражение, он покажет вызовы, которые понадобятся Roslyn, чтобы создать это выражение. Мне пришла в голову жуткая мысль, попробовать вбить в Roslyn Quoter полученные из него же вызовы, и это привело к переполнению стека генерации кода.

Минутка рекламы. Как вы, наверное, знаете, мы делаем конференции. Ближайшая конференция по .NET — DotNext 2018 Piter. Она пройдет 22-23 апреля 2018 года в Санкт-Петербурге. Какие доклады там бывают — можно посмотреть в нашем архиве на YouTube. На конференции можно будет вживую пообщаться с докладчиками и лучшими экспертами по .NET в специальных дискуссионных зонах после каждого доклада. Короче, заходите, мы вас ждём.

Автор: Олег Чирухин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js