Деревья выражений в enterprise-разработке

в 12:09, , рубрики: .net, .net clr, C#, clr, dotnext, dotnext2018moscow, expression tree, expression trees, linq, Блог компании JUG.ru Group, Компиляторы, Программирование, системное программирование

Для большинства разработчиков использование expression tree ограничивается лямбда-выражениями в LINQ. Зачастую мы вообще не придаем значения тому, как технология работает «под капотом».

В этой статье я продемонстрирую вам продвинутые техники работы с деревьями выражений: устранение дублирования кода в LINQ, кодогенерация, метапрограммирование, транспиляция, автоматизация тестирования.

Вы узнаете, как пользоваться expression tree напрямую, какие подводные камни приготовила технология и как их обойти.

Деревья выражений в enterprise-разработке - 1

Под катом — видео и текстовая расшифровка моего доклада с DotNext 2018 Piter.

Меня зовут Максим Аршинов, я соучредитель аутсорс-компании «Хайтек Груп». Мы занимаемся разработкой ПО для бизнеса, и сегодня я расскажу о том, какое применение нашлось технологии expression tree в повседневной работе и как она стала нам помогать.

Я никогда специально не хотел изучать внутреннее устройство деревьев выражений, казалось, что это какая-то внутренняя технология для .NET Team, чтобы LINQ работал, а его API прикладным программистам знать не надо. Получалось так, что появлялись какие-то прикладные задачи, требующие решения. Чтобы решение мне нравилось, приходилось лезть «в кишочки».

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

Деревья выражений в enterprise-разработке - 2

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

Деревья выражений в enterprise-разработке - 3

Берем какой-нибудь DbContext или NHibernate, пишем Where(), IsForSale выводим.

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

Деревья выражений в enterprise-разработке - 4

Легко добавляем такое свойство. Теперь наши бизнес-правила инкапсулированы, можем их повторно использовать.

Деревья выражений в enterprise-разработке - 5

Попробуем отредактировать LINQ. Все ли здесь хорошо?
Нет, это не будет работать, потому что IsAvailable не мапится никак на базу данных, это наш код, и query-провайдер не знает, как его разобрать.

Деревья выражений в enterprise-разработке - 6

Мы можем подсказать ему, что в нашем свойстве такая история. Но теперь эта лямбда продублирована и в linq-выражении, и в свойстве.

Where(x => x.IsForSale && x.InStock > 0) 
IsAvailable => IsForSale && InStock > 0; 

Значит, когда в следующий раз эта лямбда изменится, нам придется делать Ctrl+Shift+F по проекту. Естественно, все мы не найдем — баги и время. Хочется такого избежать.

Деревья выражений в enterprise-разработке - 7

Можем зайти с такой стороны и поставить перед Where() еще один ToList(). Это плохое решение, потому что если в базе миллион товаров, все поднимаются в оперативную память и фильтруются там.

Деревья выражений в enterprise-разработке - 8

Если у вас три товара в магазине, решение хорошее, но в E-commerce их обычно больше. Сработало это лишь потому, что, несмотря на схожесть лямбд между собой, тип у них абсолютно разный. В первом случае это делегат Func, а во втором — дерево выражений. Выглядит одинаково, типы разные, байт-код абсолютно разный.

Деревья выражений в enterprise-разработке - 9

Чтобы перейти от expression к делегату, надо просто вызвать метод Compile(). Это API предоставляет .NET: есть expression — скомпилировали, получили делегат.

А вот как перейти обратно? Есть ли в .NET что-то для перехода от делегата к деревьям выражений? Если вы знакомы с LISP, например, то там есть механизм цитирования, который позволяет код интерпретировать как структуру данных, но в .NET такого нет.

Экспрешны или делегаты?

Учитывая, что у нас есть два типа лямбд, можно пофилософствовать, что же первично: expression tree или делегаты.

// so slo-o-o-o-o-o-o-ow 
var delegateLambda = expressionLambda.Compile();

На первый взгляд ответ очевиден: раз есть прекрасный метод Compile(), expression tree первичен. А делегат мы должны получать, компилируя выражение. Но компиляция — процесс медленный, и если мы начнем повсеместно это делать, то получим деградацию производительности. Кроме того, мы ее получим в случайных местах, там где пришлось скомпилировать expression в делегат, будет проседание по производительности. Отыскивать эти места можно, но они будут влиять на время ответа сервера, причем случайным образом.

Деревья выражений в enterprise-разработке - 10

Поэтому их надо как-то кэшировать. Если вы слушали доклад про concurrent-структуры данных, то вы знаете про ConcurrentDictionary (или просто про него знаете). Я опущу детали про способы кэширования (с блокировками, не блокировками). Просто у ConcurrentDictionary есть простой метод GetOrAdd(), и самая простая реализация: засунуть в ConcurrentDictionary и закэшировать. В первый раз мы получим компиляцию, но потом все будет быстро, потому что делегат уже скомпилирован.

Деревья выражений в enterprise-разработке - 11

Дальше можно использовать такой метод расширения можно использовать и отрефакторить наш код с IsAvailable(), описать expression, свойства IsAvailable() скомпилировать и вызвать относительно текущего объекта this.

Есть, по крайней мере, два пакета, которые это реализуют: Microsoft.Linq.Translations и Signum Framework (опенсорсный фреймворк, написанный коммерческой компанией). И там, и там примерно одна и та же история с компиляцией делегатов. Немного разное API, но все как я показал на предыдущем слайде.

Тем не менее, это не единственный подход, и можно идти от делегатов к выражениям. Достаточно давно на хабре есть статья про Delegate Decompiler, где автор утверждает, что все компиляции это плохо, потому что долго.

Вообще делегаты были раньше выражений, и можно от делегатов переходить к ним. Для этого автор использует метод methodBody.GetILAsByteArray(); из Reflection, который действительно возвращает в качестве массива байтов весь IL-код метода. Если это засунуть дальше в Reflection, то можно получить объектное представление этого дела, пройтись по нему циклом и построить expression tree. Таким образом, обратный переход тоже возможен, но его приходится делать руками.

Деревья выражений в enterprise-разработке - 12

Для того чтобы не бегать по всем свойствам, автор предлагает повесить атрибут Computed, чтобы пометить, что это свойство надо инлайнить. Перед запросом залезаем в IsAvailable(), вытаскиваем его IL-код, преобразуем к expression tree и заменяем вызов IsAvailable() на то, что написано в этом геттере. Получается такой ручной инлайнинг.

Деревья выражений в enterprise-разработке - 13

Чтобы это сработало, прежде чем передавать все в ToList(), вызываем специальный метод Decompile(). Он предоставляет декоратор для оригинального queryable и осуществляет инлайнинг. Только после этого мы передаем все в query-провайдер, и все у нас хорошо.

Деревья выражений в enterprise-разработке - 14

Единственная проблема с этим подходом заключается в том, что Delegate Decompiler 0.23.0 не собирается двигаться вперед, поддержки Core нет, и сам автор говорит, что это глубокая alpha, там много недописанного, поэтому в продакшне использовать нельзя. Хотя к этой теме мы еще вернемся.

Булевы операции

Получается, что проблему дублирования конкретных условий мы решили.

Деревья выражений в enterprise-разработке - 15

Но условия часто необходимо комбинировать с помощью булевой логики. У нас был IsForSale(), InStock() > 0, а между ними условие «И». Если есть еще какое-то условие, или потребуется «ИЛИ»-условие.

Деревья выражений в enterprise-разработке - 16

В случае с «И» можно схитрить и свалить всю работу на query-провайдер, то есть написать много Where() подряд, это он делать умеет.

Деревья выражений в enterprise-разработке - 17

Если же потребуется «ИЛИ», это не пройдет, потому что WhereOr() в LINQ нет, а у выражений не перегружен оператор «||».

Спецификации

Если вы знакомы с книгой Эванса «DDD» или просто знаете что-то про паттерн Спецификация, то есть шаблон проектирования, предназначенный ровно для этого. Есть несколько бизнес-правил и вы хотите комбинировать операции в булевой логике — реализуйте Спецификацию.

Деревья выражений в enterprise-разработке - 18

Спецификация — это такой термин, старый паттерн из Java. А в Java, тем более в старом, никакого LINQ не было, поэтому он реализован там только в виде метода isSatisfiedBy(), то есть только делегаты, а про выражения там речи нет. В интернете есть реализация, которая называется LinqSpecs, на слайде вы ее увидите. Я ее немного подпилил напильником под себя, но идея принадлежит библиотеке.

Здесь перегружены все булевые операторы, перегружены операторы true и false, чтобы работали два оператора «&&» и «||», без них будет работать только одинарный амперсанд.

Деревья выражений в enterprise-разработке - 19

Дальше дописываем implicit-операторы, которые заставят компилятор считать, что спецификация — это и выражения, и делегаты. В любом месте, где в функцию должен прийти тип Expression<> или Func<>, вы можете передавать спецификацию. Так как перегружен оператор implicit, компилятор разберется и подставит либо свойства Expression, либо IsSatisfiedBy.

Деревья выражений в enterprise-разработке - 20

IsSatisfiedBy() можно реализовать с помощью кэширования выражения, которое пришло. В любом случае получается, что мы идем от Expression, делегат ему соответствует, мы добавили поддержку булевых операторов. Теперь это все можно компоновать. Бизнес-правила можно вынести в статические спецификации, их объявить и комбинировать.

public static readonly Spec<Product>
IsForSaleSpec = new Spec<Product>(x => x.IsForSale); 
public static readonly Spec<Product> 
IsInStockSpec = new Spec<Product>(x => x.InStock > 0);

Деревья выражений в enterprise-разработке - 21

Каждое бизнес-правило написано только один раз, оно никуда не потеряется, не продублируется, их можно комбинировать. Люди, приходя на проект, могут посмотреть, что у вас есть, какие условия, разобраться в предметной модели.

Деревья выражений в enterprise-разработке - 22

Есть небольшая проблема: методов And(), Or() и Not() у Expression нет. Это extension-методы, их надо реализовать самостоятельно.

Деревья выражений в enterprise-разработке - 23

Первая попытка реализации была такой. Про expression tree довольно мало документации в интернете, и она вся не подробная. Поэтому я попробовал просто взять Expression, нажал Ctrl+Space, увидел OrElse(), прочитал про него. Передал два Expression, чтобы скомпилировать и дальше получить лямбду. Так не будет работать.

Деревья выражений в enterprise-разработке - 24

Дело в том, что данный Expression состоит из двух частей: параметра и тела. Второй также состоит из параметра и тела. В OrElse() надо передавать тела выражений, то есть бесполезно сравнивать по «И» и «ИЛИ» лямбды, так не будет работать. Исправляем, но так снова не будет работать.

Но если в прошлый раз было NotSupportedException, что лямбда не поддерживается, то теперь странная история про параметр 1, параметр 2, «что-то неправильно, работать не буду».

С# 7.0 in a Nutshell

Тут я подумал, что метод научного тыка не пройдет, надо разобраться. Начал гуглить и нашел сайт книжки Албахари «С# 7.0 in a Nutshell».

Деревья выражений в enterprise-разработке - 25

Джозеф Албахари, он же разработчик популярной библиотеки LINQKit и LINQPad, как раз описывает эту проблему. что нельзя просто взять и скомбинировать Expression, а если взять волшебный Expression.Invoke(), работать будет.

Вопрос: что такое Expression.Invoke()? Опять идем в Google. Он создает InvocationExpression, который применяет делегат или лямбда-выражение к списку аргументов.

Деревья выражений в enterprise-разработке - 26

Если я вам сейчас этот код зачитаю, что мы берем Expression.Invoke(), параметры передаем, то там написано тоже самое по-английски. Понятнее не становится. Есть какой-то волшебный Expression.Invoke(), который почему-то решает эту проблему с параметрами. Надо поверить, понимать не надо.

Деревья выражений в enterprise-разработке - 27

При этом, если попробовать скормить EF такие скомбинированные Expression, он опять упадет и скажет, что Expression.Invoke() не поддерживается. Кстати, EF Core начал поддерживать, а EF 6 не держит. Но Албахари предлагает просто написать AsExpandable(), и все заработает.

Деревья выражений в enterprise-разработке - 28

А еще вы можете подставлять в подзапросы Expression, где нам нужен делегат. Чтобы они совпали, мы пишем Compile(), но при этом, если написать AsExpandable(), как предлагает Албахари, этот Compile() на самом деле не произойдет, а все как-то магически будет сделано правильно.

Деревья выражений в enterprise-разработке - 29

Я на слово не поверил и полез в исходники. Что за метод AsExpandable()? В нем есть query и QueryOptimizer. Второй мы оставим за скобками, так как он неинтересный, а просто склеивает Expression: если есть 3 + 5, он поставит 8.

Деревья выражений в enterprise-разработке - 30

Интересно, что дальше вызывается метод Expand(), после него queryOptimizer, а затем все передается в query-провайдер как-то переделанное после метода Expand().

Деревья выражений в enterprise-разработке - 31

Открываем его, это Visitor, внутри мы видим неоригинальный Compile(), который компилирует что-то другое. Не буду рассказывать, что именно, хоть в этом и есть определенный смысл, но мы убираем одну компиляцию и заменяем ее на другую. Здорово, но попахивает маркетингом 80-го уровня, потому что performance impact никуда не денется.

В поисках альтернативы

Я подумал, что так дело не пойдет и стал искать другое решение. И нашел. Есть такой Пит Монтгомери, который тоже пишет об этой проблеме и утверждает, что Албахари схалтурил.
Деревья выражений в enterprise-разработке - 32
Пит поговорил с разработчиками EF, и они его научили все скомбинировать без Expression.Evoke(). Идея очень простая: засада была с параметрами. Дело в том, что при комбинации Expression есть параметр первого выражения и параметр второго. Они не совпадают. Тела склеили, а параметры остались висеть в воздухе. Их надо забиндить правильным образом.

Для этого надо составить словарь, посмотрев параметры выражений, если лямбда не от одного параметра. Составляем словарь, и все параметры второго перебиндиваем на параметры первого, чтобы изначальные параметры вошли в Expression, проехали по всему телу, которое мы склеили.
Деревья выражений в enterprise-разработке - 33
Такой простой метод позволяет избавиться от всех засад с Expression.Invoke(). Более того, в реализации Пита Монтгомери это сделано еще круче. У него есть метод Compose(), позволяющий комбинировать любые выражения.

Деревья выражений в enterprise-разработке - 34

Берем выражение и через AndAlso соединяем, работает без Expandable(). Именно такая реализация используется в булевых операциях.

Спецификации и агрегаты

Все было хорошо, пока не выяснилось, что в природе существуют агрегаты. Для тех, кто не знаком, поясню: если у вас есть доменная модель и вы представляете все сущности, которые связаны друг с другом, в виде деревьев, то висящее отдельно дерево — это агрегат. Заказ вместе с позициями заказа будет называться агрегат, а сущность заказа — корень агрегации.

Деревья выражений в enterprise-разработке - 35

Если кроме товаров, еще есть категории с объявленным для них бизнес-правилом в виде спецификации, что есть некий рейтинг, который должен быть больше 50, как сказали маркетологи и мы хотим его так использовать, то это хорошо.

Деревья выражений в enterprise-разработке - 36

Но если же мы хотим вытащить товары из хорошей категории, то опять плохо, потому что у нас не совпали типы. Спецификация для категории, а нужны продукты.

Деревья выражений в enterprise-разработке - 37

Опять надо как-то решать проблему. Первый вариант: заменить Select(), на SelectMany(). Здесь мне не нравятся две вещи. Во-первых, я плохо знаю, как реализована поддержка SelectMany() во всех популярных query-провайдерах. Во-вторых, если кто-то будет писать query-провайдер, то первое, что он будет делать, — это писать throw not implemented exception и SelectMany(). И третий момент: люди думают, что SelectMany() — это либо функциональщина, либо join’ы, обычно не ассоциируется с запросом SELECT.

Композиция

Хотелось бы использовать Select(), а не SelectMany().

Деревья выражений в enterprise-разработке - 38

Примерно в то же время я читал про теорию категорий, про функциональную композицию и подумал, что если есть спецификации из продукта в bool снизу, есть какая-то функция, которая от продукта может перейти к категории, есть спецификация относительно категории, то, подставив первую функцию в качестве аргумента второй, мы получим что надо, спецификацию относительно продукта. Абсолютно так же, как работает функциональная композиция, но для деревьев выражений.

Деревья выражений в enterprise-разработке - 39

Тогда можно было бы написать такой метод Where(), что от продуктов надо перейти к категориям и к этой связанной сущности применить спецификацию. Такой синтаксис на мой субъективный вкус выглядит довольно понятно.

public static IQueryable<T> Where<T, TParam>(this IQueryable<T> queryable,
 Expression<Func<T, TParam>> prop, Expression<Func<TParam, bool>> where)
{
 return queryable.Where(prop.Compose(where));
}

С методом Compose() это тоже можно просто сделать. Берем входной Expression от продуктов и комбинируем его вместе с спецификаций относительно продукта и всё.

Деревья выражений в enterprise-разработке - 40

Теперь можно писать такие Where(). Это будет работать, если у вас агрегат любой длины. У Category есть SuperCategory и сколько угодно дальше свойств, которые можно подставить.

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

Проекции

Где же мы можем применить мета-программирование, чтобы пришлось меньше кода писать.

Деревья выражений в enterprise-разработке - 41

Первый вариант — проекции. Вытаскивать целиком сущность зачастую слишком дорого. Чаще всего мы ее передаем на фронт, сериализуем JSON. А в нем не нужна вся сущность вместе с агрегатом. Максимально эффективно с помощью LINQ это можно сделать, написав такой Select() ручной. Не сложно, но нудно.

Деревья выражений в enterprise-разработке - 42

Вместо этого я всем предлагаю использовать ProjectToType(). По крайней мере, есть две библиотеки, которые это умеют: Automapper и Mapster. Почему-то очень многие знают, что AutoMapper умеет делать маппинг в памяти, но не все знают, что у него есть Queryable Extensions, там тоже Expression, и он может строить SQL-выражение. Если вы все еще пишете ручные запросы и вы используете LINQ, так как у вас нет супер-серьезных перформанс ограничений, то нет никакого смысла делать это руками, это работа машины, а не человека.

Фильтрация

Если мы умеем так делать с проекциями, почему бы так не делать для фильтрации.

Деревья выражений в enterprise-разработке - 43

Вот тоже код. Приходит какой-то фильтр. Очень много бизнес-приложений выглядят так: пришел фильтр, добавим Where(), пришел еще фильтр, добавим Where(). Сколько фильтров есть, столько и повторите. Ничего сложного, но очень много копипасты.

Деревья выражений в enterprise-разработке - 44

Если мы как AutoMapper сделаем, напишем AutoFilter, Project и Filter, чтобы он сам все сделал, было бы круто — меньше кода.

Деревья выражений в enterprise-разработке - 45

В этом нет ничего сложного. Берем Expression.Property, проходимся по DTO и по сущности. Находим общие свойства, которые называются одинаково. Если они называются одинаково — это похоже на фильтр.

Дальше надо проверить на null, использовать константу, чтобы вытащить из DTO значение, подставить его в выражение и добавить конвертацию на случай, если у вас Int и NullableInt или другие Nullable, чтобы типы совпали. И поставить, например, Equals(), фильтр, который проверяет на равенство.

Деревья выражений в enterprise-разработке - 46

После чего собрать лямбду и пробежаться для каждого свойства: если их много, собрать либо через «И» или «ИЛИ», в зависимости от того, как работает у вас фильтр.

Деревья выражений в enterprise-разработке - 47

Тоже самое можно сделать для сортировки, но это немного сложнее, так как в методе OrderBy() два дженерика, поэтому их придется заполнять руками, с помощью Reflections создавать метод OrderBy() от двух дженериков, вставлять тип сущности, которой мы берем, тип сортируемого Property. В общем, тоже можно сделать, это несложно.

Возникает вопрос: где поставить Where() — на уровне сущности, как были объявлены спецификации или после проекции, и там, и там будет работать.

Деревья выражений в enterprise-разработке - 48

Правильно и так, и так, потому что спецификации по определению — бизнес-правила, а их мы должны холить-лелеять и с ними не ошибаться. Это одномерный слой. А фильтры — это больше про UI, а значит они фильтруют по DTO. Поэтому можно поставить два Where(). Тут скорее вопросы, насколько query-провайдер хорошо с этим справится, но я считаю, что ORM-решения и так пишут плохой SQL, и он сильно хуже не будет. Если вам это сильно важно, то эта история вообще не про ваc.

Деревья выражений в enterprise-разработке - 49

Как говорится, лучше один раз увидеть, чем сто раз услышать.
Сейчас в магазине есть три товара: «Сникерс», Subaru Impreza и «Марс». Странный магазин. Давайте попробуем найти «Сникерс. Есть. Посмотрим, что за сто рублей. Тоже «Сникерс». А за 500? Приблизим, ничего нет. А за 100500 Subaru Impreza. Отлично, то же самое касается сортировки.

Посортируем по алфавиту и по цене. Кода там написано ровно столько, сколько было. Эти фильтры работают для любых классов, как угодно. Если попробовать поискать по названию, то Subaru тоже найдется. А у меня в презентации было Equals(). Как так-то? Дело в том, что код здесь и в презентации немного разный. Строчку про Equals() я закомментировал и добавил особой уличной магии. Если у нас тип String, то надо не Equals(), а вызовем StartWith(), который я тоже получил. Поэтому для строк строится другой фильтр.

Деревья выражений в enterprise-разработке - 50

Это значит, что здесь вы можете нажать Ctrl+Shift+R, выделить метод и написать не if, а switch, а может даже реализовать паттерн «Стратегия» и далее go insane. Любые желания о работе фильтров вы можете реализовать. Все зависит от типов, с которыми вы работаете. Что самое важное, фильтры будут работать одинаково.

Вы можете согласовать, что фильтры во всех элементах UI должны работать так: строки ищутся одним способом, деньги — другим. Все это согласовать, один раз написать, все будет в разных интерфейсах правильно сделать, и никакие другие разработчики это не сломают, потому что этот код не на уровне прикладного, а где-то либо во внешней библиотеке, либо в вашем ядре.

Валидация

Кроме фильтрации и проекции, можно заняться валидацией. На эту идею меня натолкнула JS-библиотека TComb.validation. TComb — это сокращение от Type Combinators и она основана на системе типов и т.н. refinement’ов, улучшений.

// null and undefined
validate('a', t.Nil).isValid(); // => false
validate(null, t.Nil).isValid(); // => true
validate(undefined, t.Nil).isValid(); // => true
// strings
validate(1, t.String).isValid(); // => false
validate('a', t.String).isValid(); // => true
// numbers
validate('a', t.Number).isValid(); // => false
validate(1, t.Number).isValid(); // => true

Сначала объявлены примитивы, соответствующие всем типам JS, и дополнительный тип nill, соответствующий либо undefined, либо нулю.

// a predicate is a function with signature: (x) -> boolean
var predicate = function (x) { return x >= 0; };
// a positive number
var Positive = t.refinement(t.Number, predicate);
validate(-1, Positive).isValid(); // => false
validate(1, Positive).isValid(); // => true

Дальше начинается интересное. Каждый тип можно усилить с помощью предиката. Если мы хотим числа больше нуля, то объявляем предикат x >= 0 и делаем валидацию, относительно типа Positive. Так из строительных блоков можно собирать любые свои валидации. Заметили, наверное, там тоже лямбда-выражения.

Деревья выражений в enterprise-разработке - 51

Вызов принят. Берем такой же refinement, пишем его на C#, пишем метод IsValid(), так же Expression компилируем, выполняем. Теперь у нас есть возможность валидацию проводить.

public class RefinementAttribute: ValidationAttribute
{
 public IValidator<object> Refinement { get; }
 public RefinementAttribute(Type refinmentType)
 {
 Refinement = (IValidator<object>)
 Activator.CreateInstance(refinmentType);
 }
 public override bool IsValid(object value)
 => Refinement.Validate(value).IsValid();
}

Интегрируемся со стандартной системой DataAnnotations в ASP.NET MVC, чтобы это все работало из коробки. Объявляем RefinementAttribute(), передаем в конструктор тип. Дело в том, что RefinementAttribute дженериковый, поэтому здесь приходится так использовать тип, потому что нельзя объявить атрибут дженерик в .NET, к сожалению.

Деревья выражений в enterprise-разработке - 52

Так помечаем класс юзера рефайнментом. AdultRefinement, что возраст больше 18.

Деревья выражений в enterprise-разработке - 53

Чтобы совсем было хорошо, давайте сделаем валидацию на клиенте и сервере одинаковой. Сторонники NoJS предлагают на JS написать и бэк, и фронт. Хорошо, я на C# напишу и бэк и фронт, ничего страшного и просто транспилирую это в JS. Джаваскриптистам же можно писать на своих JSX, ES6 и транспилировать это в JavaScript. Почему нам нельзя? Пишем Visitor, проходимся, какие операторы нужны и пишем JavaScript.

Деревья выражений в enterprise-разработке - 54

Отдельно частый кейс валидации — это регулярные выражения, их тоже надо разобрать. Если у вас regexp, берем StringBuilder, собираем regexp. Здесь я использовал два восклицательных знака, так как JS — это динамически типизированный язык, это выражение будет приведено к bool всегда, чтобы с типом все было хорошо. Давайте посмотрим, как это выглядит.

{
predicate: “x=> (x >= 18)”,
	errorMessage: “For adults only»
}

Вот наш рефайнмент, который приходит с бэкенда, предикат в виде строчки, так как в JS нет лямбд и errorMessage «For adults only». Попробуем заполнить форму. Не проходит. Смотрим, как это сделано.

Это React, мы запрашиваем с бэкенда из метода UserRefinment() Expression и errorMessage, конструируем refinment относительно number, используем eval, чтобы получить лямбду. Если я это переделаю и сниму ограничения типа, заменю на обычный number, отвалится валидация на JS. Вводим единицу, отправляем. Не знаю, видно или нет, здесь false вывелось.

Деревья выражений в enterprise-разработке - 55

В коде стоит alert. Когда отправляем onSubmit, alert того, что пришло с бэкенда. А на бэкенде такой простой код.

Деревья выражений в enterprise-разработке - 56

Мы просто возвращаем Ok(ModelState.IsValid), класс User, который мы получаем из формы на JavaScript. Вот этот атрибут Refinement.

using …

namespace DemoApp.Core
{
public class User: HasNameBase
{
[Refinement(typeof(AdultRefinement))]
public int Age { get; set; }
}
}

То есть валидация работает и на бэкенде, которая объявлена в этой лямбде. И мы ее транспилируем в JavaScript. Получается, пишем лямбда-выражения на C#, а код выполняется и там, и там. Наш ответ NoJS, мы тоже так можем.

Тестирование

Деревья выражений в enterprise-разработке - 57

Обычно именно тимлидов больше беспокоит количество ошибок в коде.Те, кто пишут юнит-тесты, знают библиотеку Moq. Не хочешь писать mock или какой-то класс объявлять — есть moq, у него есть fluent-синтаксис. Можно расписать, как ты хочешь чтобы он себя вел и подсунуть свое приложение для тестирования.

Эти лямбды в moq — это тоже Expression, не делегаты. Он пробегается по деревьям выражений, применяет свою логику и дальше кормит в Castle.DynamicProxy. А он создает в рантайме необходимые классы. Но мы же тоже так можем.

Деревья выражений в enterprise-разработке - 58

Один мой знакомый недавно спросил, есть ли в нашем Core что-то вроде WCF. Я ответил, что есть WebAPI. Он же хотел в WebAPI, как в WCF по WSDL построить прокси. В WebAPI есть только swagger. Но swagger — это просто текст, а знакомому не хотелось каждый раз следить, когда API поменяется и что сломается. Когда был WCF, он подключал WSDL, если спека поменялась у API, ломалась компиляция.

В этом есть определенный смысл, так как искать неохота, а компилятор может помочь. По аналогии с moq можно объявить метод GetResponse<>() дженериковы с вашим ProductController, и лямбда, переходящая в этот метод, параметризована контроллером. То есть вы, начиная писать лямбду, нажимаете Ctrl+Space и видите все методы, которые есть у этого контроллера, при условии, что есть библиотека, dll с кодом. Есть Intellisense, все это пишите, будто вы вызываете контроллер.

Дальше, как Moq, мы не будем это вызывать, а просто построим дерево выражений, пройдемся по нему, вытащим из конфига API всю информацию по роутингу. И вместо того, чтобы что-то делать с контроллером, который мы не можем выполнять, так как должны выполнить на сервере, сделаем просто POST- или GET-запрос, который нам нужен, и в обратную сторону десериализуем полученное в ответ, потому что из Intellisense и expression tree мы знаем о всех возвращаемых типах. Получается, пишем код про контроллеры, а на самом деле делаем Web-запросы.

Оптимизация Reflection

Все что касается мета-программирования, сильно перекликается с Reflection.

Деревья выражений в enterprise-разработке - 59

Мы знаем, что Reflection медленный, хотелось бы этого избежать. Здесь тоже есть хорошие кейсы работы с Expression. Первое — это активатор CreateInstance. Не надо его использовать вообще никогда, потому что есть Expression.New(), который просто можно загнать в лямбду, скомпилировать и после этого получить конструкторы.

Деревья выражений в enterprise-разработке - 60

Этот слайд я позаимствовал у замечательного спикера и музыканта Вагифа. Он в блоге делал какой-то бенчмарк. Вот Activator, это Пик Коммунизма вы видите, сколько он пытается все сделать. Constructor_Invoke, он примерно как половина. А слева — New и compiled-лямбда. Есть небольшое увеличение производительности за счет того, что это делегат, а не конструктор, но выбор очевиден, понятно что это сильно лучше.

Деревья выражений в enterprise-разработке - 61

То же самое можно делать с геттерами или сеттерами.

Деревья выражений в enterprise-разработке - 62

Делается очень просто. Если вас по каким-то причинам не устраивает Fast Memember Марка Гравелли или Fast Reflect, не хотите тащить эту зависимость, можно сделать так же. Единственная сложность, что за всеми этими компиляциями надо следить, где-то хранить и прогревать кэш. То есть, если этого много, то на старте надо скомпилировать один раз.

Деревья выражений в enterprise-разработке - 63

Раз есть конструктор, геттеры и сеттеры, осталось только поведение, методы. Но их тоже можно компилировать в делегаты, и вы получите просто большой зоопарк делегатов, которым нужно будет уметь управлять. Зная все то, о чем я рассказал, кому-то в голову может прийти идея, что если там много делегатов, много выражений, то может быть есть место для того, что называют DSL, Little Languages или паттерн-интерпретатор, свободная монада.

Это все одни и те же вещи, когда для какой-то задачи мы придумываем набор команд и для него пишем свой интерпретатор, который это выполняет. То есть, внутри приложения пишем еще компилятор или интерпретатор, который знает, как эти команды использовать. Именно так это и сделано в DLR, в той части, которая работает с языками IronPython, IronRuby. Expression tree там используется для исполнения динамического кода в CLR. Это же можно делать в бизнес-приложениях, но мы пока такой необходимости не заметили и это остается за скобками.

Итоги

В заключение хочу рассказать о том, к каким выводам мы после внедрения и проб. Как я говорил, это происходило на разных проектах. Все что я написал, мы не используем повсеместно, но где-то если требуется, некоторые вещи использовались.

Первый плюс — это возможность автоматизировать рутину. Если у вас 100 тысяч формочек с фильтрациями, пагинациями и всем таким. У Моцарта была шутка, что с помощью игральных костей, достаточного количества времени и бокала красного вина, можно писать вальсы в любом количестве. Тут с помощью Expression Trees, немного мета-программирования можно писать формочки в любом количестве.

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

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

С другой стороны, мы так сильно повышаем требования к квалификации проектировщика, потому что вылезают вопросы знания о работе с Expression, Reflection, их оптимизации, о местах, где можно выстрелить себе в ногу. Здесь много таких нюансов, поэтому человеку, незнакомому с этим API, будет не сразу понятно, почему Expression просто так не комбинируется. Проектировщик должен быть круче.

В некоторых случаях за счет Expression.Compile() можно поймать деградацию производительности. В примере с кэшированием у меня было ограничение, что Expression’ы статические, потому что используется Dictionary для кэширования. Если кто-то не знает, как это устроено внутри, начнет бездумно это делать, объявит спецификации нестатическими внутри, метод кэша не сработает, и мы получим вызовы Compile() в случайных местах. Именно то, чего хотелось избежать.

Самый неприятный минус — код перестает выглядеть как C#-код, он становится менее идиоматическим, появляются статические вызовы, странные дополнительные методы Where(), какие-то implicit-операторы перегружены. Этого нет в документации MSDN, в примерах. Если к вам приходит, допустим, человек с небольшим опытом, не привыкший в случае чего лезть в исходники, он скорее всего будет находиться в небольшой прострации первое время, потому что это не вписывается в картину мира, на StackOverflow таких примеров нет, но с этим придется как-то работать.

В общем, это все, о чем я хотел сегодня рассказать. Многое из того, о чем я рассказал, более подробно, с деталями написано на Хабре. Код библиотеки выложен на гитхабе, но у нее есть один фатальный недостаток — полное отсутствие документации.

22-23 ноября в Москве пройдет DotNext 2018 Moscow. Предварительная сетка докладов уже выложена на сайте, билеты можно приобрести там же (с первого октября стоимость билетов увеличится).

Автор: Максим Аршинов

Источник

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