Хабрахабр

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Я подумал, что так дело не пойдет и стал искать другое решение. И нашел. Есть такой Пит Монтгомери, который тоже пишет об этой проблеме и утверждает, что Албахари схалтурил.

Пит поговорил с разработчиками EF, и они его научили все скомбинировать без Expression.Evoke(). Идея очень простая: засада была с параметрами. Дело в том, что при комбинации Expression есть параметр первого выражения и параметр второго. Они не совпадают. Тела склеили, а параметры остались висеть в воздухе. Их надо забиндить правильным образом.

Составляем словарь, и все параметры второго перебиндиваем на параметры первого, чтобы изначальные параметры вошли в Expression, проехали по всему телу, которое мы склеили.

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

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

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

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

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

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

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

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

Такой синтаксис на мой субъективный вкус выглядит довольно понятно. Тогда можно было бы написать такой метод 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 от продуктов и комбинируем его вместе с спецификаций относительно продукта и всё.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Любые желания о работе фильтров вы можете реализовать. Это значит, что здесь вы можете нажать 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) ;
// a positive number
var Positive = t.refinement(t.Number, predicate);
validate(-1, Positive).isValid(); // => false
validate(1, Positive).isValid(); // => true

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

Берем такой же 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, к сожалению.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть