Для большинства разработчиков использование expression tree ограничивается лямбда-выражениями в LINQ. Зачастую мы вообще не придаем значения тому, как технология работает «под капотом».
В этой статье я продемонстрирую вам продвинутые техники работы с деревьями выражений: устранение дублирования кода в LINQ, кодогенерация, метапрограммирование, транспиляция, автоматизация тестирования.
Вы узнаете, как пользоваться expression tree напрямую, какие подводные камни приготовила технология и как их обойти.
![](https://habrastorage.org/r/w1560/webt/e5/qh/tt/e5qhttxqyi5a27s-4dtlt2jq1mo.png)
Под катом — видео и текстовая расшифровка моего доклада с DotNext 2018 Piter.
Меня зовут Максим Аршинов, я соучредитель аутсорс-компании «Хайтек Груп». Мы занимаемся разработкой ПО для бизнеса, и сегодня я расскажу о том, какое применение нашлось технологии expression tree в повседневной работе и как она стала нам помогать.
Я никогда специально не хотел изучать внутреннее устройство деревьев выражений, казалось, что это какая-то внутренняя технология для .NET Team, чтобы LINQ работал, а его API прикладным программистам знать не надо. Получалось так, что появлялись какие-то прикладные задачи, требующие решения. Чтобы решение мне нравилось, приходилось лезть «в кишочки».
Вся эта история растянута во времени, были разные проекты, разные кейсы. Что-то вылезало, и я дописывал, но я позволю себе пожертвовать исторической правдивостью в пользу большей художественности изложения, поэтому все примеры будут на одной предметной модели — интернет-магазине.
![](https://habrastorage.org/r/w1560/webt/g5/yf/cr/g5yfcrf13ptoovmcheym1x15fdk.png)
Представьте, что мы все пишем интернет-магазин. В нем есть товары и галочка «Для продажи» в админке. На публичную часть выводить мы будем только те товары, у которых эта галочка отмечена.
![](https://habrastorage.org/r/w1560/webt/cg/_e/jt/cg_ejtgmxn1u0hq12ezlonenue8.png)
Берем какой-нибудь DbContext или NHibernate, пишем Where(), IsForSale выводим.
Все хорошо, но бизнес-правила не бывают одинаковыми, чтобы мы их написали раз и навсегда. Они со временем эволюционируют. Приходит менеджер и говорит, что надо еще следить за остатком и выводить на публичную часть только товары, у которых есть остатки, не забывая про галочку.
![](https://habrastorage.org/r/w1560/webt/8e/b1/mz/8eb1mzde7casnjrrilroqxpp8ku.png)
Легко добавляем такое свойство. Теперь наши бизнес-правила инкапсулированы, можем их повторно использовать.
Попробуем отредактировать LINQ. Все ли здесь хорошо?
Нет, это не будет работать, потому что IsAvailable не мапится никак на базу данных, это наш код, и query-провайдер не знает, как его разобрать.
Мы можем подсказать ему, что в нашем свойстве такая история. Но теперь эта лямбда продублирована и в linq-выражении, и в свойстве.
Значит, когда в следующий раз эта лямбда изменится, нам придется делать Ctrl+Shift+F по проекту. Естественно, все мы не найдем — баги и время. Хочется такого избежать.
![](https://habrastorage.org/r/w1560/webt/8b/8b/j8/8b8bj8voopaagucck7rcl1j9tcg.png)
Можем зайти с такой стороны и поставить перед Where() еще один ToList(). Это плохое решение, потому что если в базе миллион товаров, все поднимаются в оперативную память и фильтруются там.
![](https://habrastorage.org/r/w1560/webt/ca/2t/2g/ca2t2gbdetsmcijcefubnokpfeu.png)
Если у вас три товара в магазине, решение хорошее, но в E-commerce их обычно больше. Сработало это лишь потому, что, несмотря на схожесть лямбд между собой, тип у них абсолютно разный. В первом случае это делегат Func, а во втором — дерево выражений. Выглядит одинаково, типы разные, байт-код абсолютно разный.
Чтобы перейти от expression к делегату, надо просто вызвать метод Compile(). Это API предоставляет .NET: есть expression — скомпилировали, получили делегат.
А вот как перейти обратно? Есть ли в .NET что-то для перехода от делегата к деревьям выражений? Если вы знакомы с LISP, например, то там есть механизм цитирования, который позволяет код интерпретировать как структуру данных, но в .NET такого нет.
Учитывая, что у нас есть два типа лямбд, можно пофилософствовать, что же первично: expression tree или делегаты.
На первый взгляд ответ очевиден: раз есть прекрасный метод Compile(), expression tree первичен. А делегат мы должны получать, компилируя выражение. Но компиляция — процесс медленный, и если мы начнем повсеместно это делать, то получим деградацию производительности. Кроме того, мы ее получим в случайных местах, там где пришлось скомпилировать expression в делегат, будет проседание по производительности. Отыскивать эти места можно, но они будут влиять на время ответа сервера, причем случайным образом.
Поэтому их надо как-то кэшировать. Если вы слушали доклад про concurrent-структуры данных, то вы знаете про ConcurrentDictionary (или просто про него знаете). Я опущу детали про способы кэширования (с блокировками, не блокировками). Просто у ConcurrentDictionary есть простой метод GetOrAdd(), и самая простая реализация: засунуть в ConcurrentDictionary и закэшировать. В первый раз мы получим компиляцию, но потом все будет быстро, потому что делегат уже скомпилирован.
![](https://habrastorage.org/r/w1560/webt/h1/wp/gb/h1wpgbo_5pi3xhnfaivvvjsz4ks.png)
Дальше можно использовать такой метод расширения можно использовать и отрефакторить наш код с IsAvailable(), описать expression, свойства IsAvailable() скомпилировать и вызвать относительно текущего объекта this.
Есть, по крайней мере, два пакета, которые это реализуют: Microsoft.Linq.Translations и Signum Framework (опенсорсный фреймворк, написанный коммерческой компанией). И там, и там примерно одна и та же история с компиляцией делегатов. Немного разное API, но все как я показал на предыдущем слайде.
Тем не менее, это не единственный подход, и можно идти от делегатов к выражениям. Достаточно давно на хабре есть статья про Delegate Decompiler, где автор утверждает, что все компиляции это плохо, потому что долго.
Вообще делегаты были раньше выражений, и можно от делегатов переходить к ним. Для этого автор использует метод methodBody.GetILAsByteArray(); из Reflection, который действительно возвращает в качестве массива байтов весь IL-код метода. Если это засунуть дальше в Reflection, то можно получить объектное представление этого дела, пройтись по нему циклом и построить expression tree. Таким образом, обратный переход тоже возможен, но его приходится делать руками.
![](https://habrastorage.org/r/w1560/webt/_r/pp/d5/_rppd5sfv3tmy9mfacmg6wydi3k.png)
Для того чтобы не бегать по всем свойствам, автор предлагает повесить атрибут Computed, чтобы пометить, что это свойство надо инлайнить. Перед запросом залезаем в IsAvailable(), вытаскиваем его IL-код, преобразуем к expression tree и заменяем вызов IsAvailable() на то, что написано в этом геттере. Получается такой ручной инлайнинг.
![](https://habrastorage.org/r/w1560/webt/nt/jx/g1/ntjxg17v2olokmu7ebckzs7v_uq.png)
Чтобы это сработало, прежде чем передавать все в ToList(), вызываем специальный метод Decompile(). Он предоставляет декоратор для оригинального queryable и осуществляет инлайнинг. Только после этого мы передаем все в query-провайдер, и все у нас хорошо.
![](https://habrastorage.org/r/w1560/webt/ze/cz/qw/zeczqw8mzr-k72ivpyfzqrpuhrm.png)
Единственная проблема с этим подходом заключается в том, что Delegate Decompiler 0.23.0 не собирается двигаться вперед, поддержки Core нет, и сам автор говорит, что это глубокая alpha, там много недописанного, поэтому в продакшне использовать нельзя. Хотя к этой теме мы еще вернемся.
Получается, что проблему дублирования конкретных условий мы решили.
![](https://habrastorage.org/r/w1560/webt/gc/-u/5n/gc-u5nh7vip9kn5cq1d5gn3ivxm.png)
Но условия часто необходимо комбинировать с помощью булевой логики. У нас был IsForSale(), InStock() > 0, а между ними условие «И». Если есть еще какое-то условие, или потребуется «ИЛИ»-условие.
В случае с «И» можно схитрить и свалить всю работу на query-провайдер, то есть написать много Where() подряд, это он делать умеет.
![](https://habrastorage.org/r/w1560/webt/dd/hp/c9/ddhpc9tfdvmeiadkmyfmswnr4h8.png)
Если же потребуется «ИЛИ», это не пройдет, потому что WhereOr() в LINQ нет, а у выражений не перегружен оператор «||».
Если вы знакомы с книгой Эванса «DDD» или просто знаете что-то про паттерн Спецификация, то есть шаблон проектирования, предназначенный ровно для этого. Есть несколько бизнес-правил и вы хотите комбинировать операции в булевой логике — реализуйте Спецификацию.
![](https://habrastorage.org/r/w1560/webt/nj/cb/4n/njcb4n2xt0g8dk3joumhmsgmuyg.png)
Спецификация — это такой термин, старый паттерн из Java. А в Java, тем более в старом, никакого LINQ не было, поэтому он реализован там только в виде метода isSatisfiedBy(), то есть только делегаты, а про выражения там речи нет. В интернете есть реализация, которая называется LinqSpecs, на слайде вы ее увидите. Я ее немного подпилил напильником под себя, но идея принадлежит библиотеке.
Здесь перегружены все булевые операторы, перегружены операторы true и false, чтобы работали два оператора «&&» и «||», без них будет работать только одинарный амперсанд.
![](https://habrastorage.org/r/w1560/webt/nh/8b/16/nh8b16hfmjec4t6yotlcmu5nbzi.png)
Дальше дописываем implicit-операторы, которые заставят компилятор считать, что спецификация — это и выражения, и делегаты. В любом месте, где в функцию должен прийти тип Expression<> или Func<>, вы можете передавать спецификацию. Так как перегружен оператор implicit, компилятор разберется и подставит либо свойства Expression, либо IsSatisfiedBy.
![](https://habrastorage.org/r/w1560/webt/pd/-t/jk/pd-tjkx5uxieorm3go74act-2gc.png)
IsSatisfiedBy() можно реализовать с помощью кэширования выражения, которое пришло. В любом случае получается, что мы идем от Expression, делегат ему соответствует, мы добавили поддержку булевых операторов. Теперь это все можно компоновать. Бизнес-правила можно вынести в статические спецификации, их объявить и комбинировать.
![](https://habrastorage.org/r/w1560/webt/ru/ju/ya/rujuyaoj1ogssqq_resyqjmkutm.png)
Каждое бизнес-правило написано только один раз, оно никуда не потеряется, не продублируется, их можно комбинировать. Люди, приходя на проект, могут посмотреть, что у вас есть, какие условия, разобраться в предметной модели.
Есть небольшая проблема: методов And(), Or() и Not() у Expression нет. Это extension-методы, их надо реализовать самостоятельно.
Первая попытка реализации была такой. Про expression tree довольно мало документации в интернете, и она вся не подробная. Поэтому я попробовал просто взять Expression, нажал Ctrl+Space, увидел OrElse(), прочитал про него. Передал два Expression, чтобы скомпилировать и дальше получить лямбду. Так не будет работать.
![](https://habrastorage.org/r/w1560/webt/v4/p3/8u/v4p38uvzgxubb2y8olt38ivbipq.png)
Дело в том, что данный Expression состоит из двух частей: параметра и тела. Второй также состоит из параметра и тела. В OrElse() надо передавать тела выражений, то есть бесполезно сравнивать по «И» и «ИЛИ» лямбды, так не будет работать. Исправляем, но так снова не будет работать.
Но если в прошлый раз было NotSupportedException, что лямбда не поддерживается, то теперь странная история про параметр 1, параметр 2, «что-то неправильно, работать не буду».
Тут я подумал, что метод научного тыка не пройдет, надо разобраться. Начал гуглить и нашел сайт книжки Албахари «С# 7.0 in a Nutshell».
![](https://habrastorage.org/r/w1560/webt/m6/0b/ag/m60bagpqsrrcakt5wz-p05lcfyq.png)
Джозеф Албахари, он же разработчик популярной библиотеки LINQKit и LINQPad, как раз описывает эту проблему. что нельзя просто взять и скомбинировать Expression, а если взять волшебный Expression.Invoke(), работать будет.
Вопрос: что такое Expression.Invoke()? Опять идем в Google. Он создает InvocationExpression, который применяет делегат или лямбда-выражение к списку аргументов.
![](https://habrastorage.org/r/w1560/webt/br/du/mh/brdumhygufxqwgu5hfly1nlac0a.png)
Если я вам сейчас этот код зачитаю, что мы берем Expression.Invoke(), параметры передаем, то там написано тоже самое по-английски. Понятнее не становится. Есть какой-то волшебный Expression.Invoke(), который почему-то решает эту проблему с параметрами. Надо поверить, понимать не надо.
При этом, если попробовать скормить EF такие скомбинированные Expression, он опять упадет и скажет, что Expression.Invoke() не поддерживается. Кстати, EF Core начал поддерживать, а EF 6 не держит. Но Албахари предлагает просто написать AsExpandable(), и все заработает.
![](https://habrastorage.org/r/w1560/webt/z5/4s/lq/z54slqaaitigetkidixpw-0a408.png)
А еще вы можете подставлять в подзапросы Expression, где нам нужен делегат. Чтобы они совпали, мы пишем Compile(), но при этом, если написать AsExpandable(), как предлагает Албахари, этот Compile() на самом деле не произойдет, а все как-то магически будет сделано правильно.
Я на слово не поверил и полез в исходники. Что за метод AsExpandable()? В нем есть query и QueryOptimizer. Второй мы оставим за скобками, так как он неинтересный, а просто склеивает Expression: если есть 3 + 5, он поставит 8.
![](https://habrastorage.org/r/w1560/webt/l6/8j/tw/l68jtwqse6njinyczxh7xs3ilmw.png)
Интересно, что дальше вызывается метод Expand(), после него queryOptimizer, а затем все передается в query-провайдер как-то переделанное после метода Expand().
![](https://habrastorage.org/r/w1560/webt/pr/dv/oc/prdvockxs8plz7f2lyciiyu0s5y.png)
Открываем его, это Visitor, внутри мы видим неоригинальный Compile(), который компилирует что-то другое. Не буду рассказывать, что именно, хоть в этом и есть определенный смысл, но мы убираем одну компиляцию и заменяем ее на другую. Здорово, но попахивает маркетингом 80-го уровня, потому что performance impact никуда не денется.
Я подумал, что так дело не пойдет и стал искать другое решение. И нашел. Есть такой Пит Монтгомери, который тоже пишет об этой проблеме и утверждает, что Албахари схалтурил.
![](https://habrastorage.org/r/w1560/webt/k9/pu/hy/k9puhyrru6xqi3hyozftlav_lvk.png)
Пит поговорил с разработчиками EF, и они его научили все скомбинировать без Expression.Evoke(). Идея очень простая: засада была с параметрами. Дело в том, что при комбинации Expression есть параметр первого выражения и параметр второго. Они не совпадают. Тела склеили, а параметры остались висеть в воздухе. Их надо забиндить правильным образом.
Для этого надо составить словарь, посмотрев параметры выражений, если лямбда не от одного параметра. Составляем словарь, и все параметры второго перебиндиваем на параметры первого, чтобы изначальные параметры вошли в Expression, проехали по всему телу, которое мы склеили.
![](https://habrastorage.org/r/w1560/webt/yn/ct/37/ynct37iynt07424au-grgvhhz1o.png)
Такой простой метод позволяет избавиться от всех засад с Expression.Invoke(). Более того, в реализации Пита Монтгомери это сделано еще круче. У него есть метод Compose(), позволяющий комбинировать любые выражения.
![](https://habrastorage.org/r/w1560/webt/wc/of/fv/wcoffvtu4s62gggy_qqykowjtns.png)
Берем выражение и через AndAlso соединяем, работает без Expandable(). Именно такая реализация используется в булевых операциях.
Все было хорошо, пока не выяснилось, что в природе существуют агрегаты. Для тех, кто не знаком, поясню: если у вас есть доменная модель и вы представляете все сущности, которые связаны друг с другом, в виде деревьев, то висящее отдельно дерево — это агрегат. Заказ вместе с позициями заказа будет называться агрегат, а сущность заказа — корень агрегации.
![](https://habrastorage.org/r/w1560/webt/zr/vr/6a/zrvr6af-865n3tluhcoivfjn68g.png)
Если кроме товаров, еще есть категории с объявленным для них бизнес-правилом в виде спецификации, что есть некий рейтинг, который должен быть больше 50, как сказали маркетологи и мы хотим его так использовать, то это хорошо.
![](https://habrastorage.org/r/w1560/webt/tp/vh/u-/tpvhu-12wisjnbsdhnmddkqpkw4.png)
Но если же мы хотим вытащить товары из хорошей категории, то опять плохо, потому что у нас не совпали типы. Спецификация для категории, а нужны продукты.
![](https://habrastorage.org/r/w1560/webt/yz/cj/wp/yzcjwpjtf0jvuwff1dae8jef7ha.png)
Опять надо как-то решать проблему. Первый вариант: заменить Select(), на SelectMany(). Здесь мне не нравятся две вещи. Во-первых, я плохо знаю, как реализована поддержка SelectMany() во всех популярных query-провайдерах. Во-вторых, если кто-то будет писать query-провайдер, то первое, что он будет делать, — это писать throw not implemented exception и SelectMany(). И третий момент: люди думают, что SelectMany() — это либо функциональщина, либо join’ы, обычно не ассоциируется с запросом SELECT.
Хотелось бы использовать Select(), а не SelectMany().
![](https://habrastorage.org/r/w1560/webt/-_/bv/qm/-_bvqmi8jssf1sva8boyiuh1vsg.png)
Примерно в то же время я читал про теорию категорий, про функциональную композицию и подумал, что если есть спецификации из продукта в bool снизу, есть какая-то функция, которая от продукта может перейти к категории, есть спецификация относительно категории, то, подставив первую функцию в качестве аргумента второй, мы получим что надо, спецификацию относительно продукта. Абсолютно так же, как работает функциональная композиция, но для деревьев выражений.
![](https://habrastorage.org/r/w1560/webt/hg/wp/7h/hgwp7h8a0nrpv2xsfphtltel160.png)
Тогда можно было бы написать такой метод Where(), что от продуктов надо перейти к категориям и к этой связанной сущности применить спецификацию. Такой синтаксис на мой субъективный вкус выглядит довольно понятно.
С методом Compose() это тоже можно просто сделать. Берем входной Expression от продуктов и комбинируем его вместе с спецификаций относительно продукта и всё.
Теперь можно писать такие Where(). Это будет работать, если у вас агрегат любой длины. У Category есть SuperCategory и сколько угодно дальше свойств, которые можно подставить.
«Раз у нас есть инструмент функциональной композиции, и раз мы можем это компилировать, и раз мы можем собирать это динамически, значит есть запахло мета-программированием!», — подумал я.
Где же мы можем применить мета-программирование, чтобы пришлось меньше кода писать.
![](https://habrastorage.org/r/w1560/webt/z6/dw/gi/z6dwgivuday4edqii5me2z0yuw0.png)
Первый вариант — проекции. Вытаскивать целиком сущность зачастую слишком дорого. Чаще всего мы ее передаем на фронт, сериализуем JSON. А в нем не нужна вся сущность вместе с агрегатом. Максимально эффективно с помощью LINQ это можно сделать, написав такой Select() ручной. Не сложно, но нудно.
![](https://habrastorage.org/r/w1560/webt/1r/co/xd/1rcoxdmbzmuplg1vyuvkz5hrrqg.png)
Вместо этого я всем предлагаю использовать ProjectToType(). По крайней мере, есть две библиотеки, которые это умеют: Automapper и Mapster. Почему-то очень многие знают, что AutoMapper умеет делать маппинг в памяти, но не все знают, что у него есть Queryable Extensions, там тоже Expression, и он может строить SQL-выражение. Если вы все еще пишете ручные запросы и вы используете LINQ, так как у вас нет супер-серьезных перформанс ограничений, то нет никакого смысла делать это руками, это работа машины, а не человека.
Если мы умеем так делать с проекциями, почему бы так не делать для фильтрации.
![](https://habrastorage.org/r/w1560/webt/wg/ei/pd/wgeipdnvwwusix--gk7_zch9nhs.png)
Вот тоже код. Приходит какой-то фильтр. Очень много бизнес-приложений выглядят так: пришел фильтр, добавим Where(), пришел еще фильтр, добавим Where(). Сколько фильтров есть, столько и повторите. Ничего сложного, но очень много копипасты.
![](https://habrastorage.org/r/w1560/webt/fd/7r/wl/fd7rwlnpkuiluiadq0zppn7_t-y.png)
Если мы как AutoMapper сделаем, напишем AutoFilter, Project и Filter, чтобы он сам все сделал, было бы круто — меньше кода.
![](https://habrastorage.org/r/w1560/webt/9g/es/tg/9gestgfo-ouzbolexklhu78gv8q.png)
В этом нет ничего сложного. Берем Expression.Property, проходимся по DTO и по сущности. Находим общие свойства, которые называются одинаково. Если они называются одинаково — это похоже на фильтр.
Дальше надо проверить на null, использовать константу, чтобы вытащить из DTO значение, подставить его в выражение и добавить конвертацию на случай, если у вас Int и NullableInt или другие Nullable, чтобы типы совпали. И поставить, например, Equals(), фильтр, который проверяет на равенство.
![](https://habrastorage.org/r/w1560/webt/09/x6/me/09x6mep3p-rrvwo0vodcnke_8eo.png)
После чего собрать лямбду и пробежаться для каждого свойства: если их много, собрать либо через «И» или «ИЛИ», в зависимости от того, как работает у вас фильтр.
![](https://habrastorage.org/r/w1560/webt/ha/vw/en/havwen3xvrbraul6plgdadjjusy.png)
Тоже самое можно сделать для сортировки, но это немного сложнее, так как в методе OrderBy() два дженерика, поэтому их придется заполнять руками, с помощью Reflections создавать метод OrderBy() от двух дженериков, вставлять тип сущности, которой мы берем, тип сортируемого Property. В общем, тоже можно сделать, это несложно.
Возникает вопрос: где поставить Where() — на уровне сущности, как были объявлены спецификации или после проекции, и там, и там будет работать.
![](https://habrastorage.org/r/w1560/webt/e1/4g/4g/e14g4gelsidqhaeq7avs0wgp51i.png)
Правильно и так, и так, потому что спецификации по определению — бизнес-правила, а их мы должны холить-лелеять и с ними не ошибаться. Это одномерный слой. А фильтры — это больше про UI, а значит они фильтруют по DTO. Поэтому можно поставить два Where(). Тут скорее вопросы, насколько query-провайдер хорошо с этим справится, но я считаю, что ORM-решения и так пишут плохой SQL, и он сильно хуже не будет. Если вам это сильно важно, то эта история вообще не про ваc.
![](https://habrastorage.org/r/w1560/webt/46/wn/kc/46wnkcjkjayv9boma0pot5of0rs.png)
Как говорится, лучше один раз увидеть, чем сто раз услышать.
Сейчас в магазине есть три товара: «Сникерс», Subaru Impreza и «Марс». Странный магазин. Давайте попробуем найти «Сникерс. Есть. Посмотрим, что за сто рублей. Тоже «Сникерс». А за 500? Приблизим, ничего нет. А за 100500 Subaru Impreza. Отлично, то же самое касается сортировки.
Посортируем по алфавиту и по цене. Кода там написано ровно столько, сколько было. Эти фильтры работают для любых классов, как угодно. Если попробовать поискать по названию, то Subaru тоже найдется. А у меня в презентации было Equals(). Как так-то? Дело в том, что код здесь и в презентации немного разный. Строчку про Equals() я закомментировал и добавил особой уличной магии. Если у нас тип String, то надо не Equals(), а вызовем StartWith(), который я тоже получил. Поэтому для строк строится другой фильтр.
![](https://habrastorage.org/r/w1560/webt/t7/fa/kp/t7fakpf7peg_v2swpq2xwksiecu.png)
Это значит, что здесь вы можете нажать Ctrl+Shift+R, выделить метод и написать не if, а switch, а может даже реализовать паттерн «Стратегия» и далее go insane. Любые желания о работе фильтров вы можете реализовать. Все зависит от типов, с которыми вы работаете. Что самое важное, фильтры будут работать одинаково.
Вы можете согласовать, что фильтры во всех элементах UI должны работать так: строки ищутся одним способом, деньги — другим. Все это согласовать, один раз написать, все будет в разных интерфейсах правильно сделать, и никакие другие разработчики это не сломают, потому что этот код не на уровне прикладного, а где-то либо во внешней библиотеке, либо в вашем ядре.
Кроме фильтрации и проекции, можно заняться валидацией. На эту идею меня натолкнула JS-библиотека TComb.validation. TComb — это сокращение от Type Combinators и она основана на системе типов и т.н. refinement’ов, улучшений.
Сначала объявлены примитивы, соответствующие всем типам JS, и дополнительный тип nill, соответствующий либо undefined, либо нулю.
Дальше начинается интересное. Каждый тип можно усилить с помощью предиката. Если мы хотим числа больше нуля, то объявляем предикат x >= 0 и делаем валидацию, относительно типа Positive. Так из строительных блоков можно собирать любые свои валидации. Заметили, наверное, там тоже лямбда-выражения.
Вызов принят. Берем такой же refinement, пишем его на C#, пишем метод IsValid(), так же Expression компилируем, выполняем. Теперь у нас есть возможность валидацию проводить.
Интегрируемся со стандартной системой DataAnnotations в ASP.NET MVC, чтобы это все работало из коробки. Объявляем RefinementAttribute(), передаем в конструктор тип. Дело в том, что RefinementAttribute дженериковый, поэтому здесь приходится так использовать тип, потому что нельзя объявить атрибут дженерик в .NET, к сожалению.
![](https://habrastorage.org/r/w1560/webt/ml/e7/j9/mle7j92pbchiswi1ni7zfvizgou.png)
Так помечаем класс юзера рефайнментом. AdultRefinement, что возраст больше 18.
![](https://habrastorage.org/r/w1560/webt/cp/2o/yt/cp2oytz1rrhql042hddkb3va8ug.png)
Чтобы совсем было хорошо, давайте сделаем валидацию на клиенте и сервере одинаковой. Сторонники NoJS предлагают на JS написать и бэк, и фронт. Хорошо, я на C# напишу и бэк и фронт, ничего страшного и просто транспилирую это в JS. Джаваскриптистам же можно писать на своих JSX, ES6 и транспилировать это в JavaScript. Почему нам нельзя? Пишем Visitor, проходимся, какие операторы нужны и пишем JavaScript.
![](https://habrastorage.org/r/w1560/webt/lg/qe/9y/lgqe9yl63oz8p8wmlkrmyfmkkky.png)
Отдельно частый кейс валидации — это регулярные выражения, их тоже надо разобрать. Если у вас regexp, берем StringBuilder, собираем regexp. Здесь я использовал два восклицательных знака, так как JS — это динамически типизированный язык, это выражение будет приведено к bool всегда, чтобы с типом все было хорошо. Давайте посмотрим, как это выглядит.
Вот наш рефайнмент, который приходит с бэкенда, предикат в виде строчки, так как в JS нет лямбд и errorMessage «For adults only». Попробуем заполнить форму. Не проходит. Смотрим, как это сделано.
Это React, мы запрашиваем с бэкенда из метода UserRefinment() Expression и errorMessage, конструируем refinment относительно number, используем eval, чтобы получить лямбду. Если я это переделаю и сниму ограничения типа, заменю на обычный number, отвалится валидация на JS. Вводим единицу, отправляем. Не знаю, видно или нет, здесь false вывелось.
![](https://habrastorage.org/r/w1560/webt/km/vw/ls/kmvwlsxxw5csttfvkh_rxrpbwpe.png)
В коде стоит alert. Когда отправляем onSubmit, alert того, что пришло с бэкенда. А на бэкенде такой простой код.
Мы просто возвращаем Ok(ModelState.IsValid), класс User, который мы получаем из формы на JavaScript. Вот этот атрибут Refinement.
То есть валидация работает и на бэкенде, которая объявлена в этой лямбде. И мы ее транспилируем в JavaScript. Получается, пишем лямбда-выражения на C#, а код выполняется и там, и там. Наш ответ NoJS, мы тоже так можем.
![](https://habrastorage.org/r/w1560/webt/jz/fm/y0/jzfmy0ngjejazfvdrf5ld9y8cc4.png)
Обычно именно тимлидов больше беспокоит количество ошибок в коде.Те, кто пишут юнит-тесты, знают библиотеку Moq. Не хочешь писать mock или какой-то класс объявлять — есть moq, у него есть fluent-синтаксис. Можно расписать, как ты хочешь чтобы он себя вел и подсунуть свое приложение для тестирования.
Эти лямбды в moq — это тоже Expression, не делегаты. Он пробегается по деревьям выражений, применяет свою логику и дальше кормит в Castle.DynamicProxy. А он создает в рантайме необходимые классы. Но мы же тоже так можем.
Один мой знакомый недавно спросил, есть ли в нашем 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.
Мы знаем, что Reflection медленный, хотелось бы этого избежать. Здесь тоже есть хорошие кейсы работы с Expression. Первое — это активатор CreateInstance. Не надо его использовать вообще никогда, потому что есть Expression.New(), который просто можно загнать в лямбду, скомпилировать и после этого получить конструкторы.
![](https://habrastorage.org/r/w1560/webt/8u/me/lv/8umelvrhhu0ctn0gaunjp1ldmxg.png)
Этот слайд я позаимствовал у замечательного спикера и музыканта Вагифа. Он в блоге делал какой-то бенчмарк. Вот Activator, это Пик Коммунизма вы видите, сколько он пытается все сделать. Constructor_Invoke, он примерно как половина. А слева — New и compiled-лямбда. Есть небольшое увеличение производительности за счет того, что это делегат, а не конструктор, но выбор очевиден, понятно что это сильно лучше.
То же самое можно делать с геттерами или сеттерами.
![](https://habrastorage.org/r/w1560/webt/gf/2d/4e/gf2d4ebwaltmazxfjicoupxwrbk.png)
Делается очень просто. Если вас по каким-то причинам не устраивает Fast Memember Марка Гравелли или Fast Reflect, не хотите тащить эту зависимость, можно сделать так же. Единственная сложность, что за всеми этими компиляциями надо следить, где-то хранить и прогревать кэш. То есть, если этого много, то на старте надо скомпилировать один раз.
Раз есть конструктор, геттеры и сеттеры, осталось только поведение, методы. Но их тоже можно компилировать в делегаты, и вы получите просто большой зоопарк делегатов, которым нужно будет уметь управлять. Зная все то, о чем я рассказал, кому-то в голову может прийти идея, что если там много делегатов, много выражений, то может быть есть место для того, что называют 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 таких примеров нет, но с этим придется как-то работать.
В общем, это все, о чем я хотел сегодня рассказать. Многое из того, о чем я рассказал, более подробно, с деталями написано на Хабре. Код библиотеки выложен на гитхабе, но у нее есть один фатальный недостаток — полное отсутствие документации.
В этой статье я продемонстрирую вам продвинутые техники работы с деревьями выражений: устранение дублирования кода в LINQ, кодогенерация, метапрограммирование, транспиляция, автоматизация тестирования.
Вы узнаете, как пользоваться expression tree напрямую, какие подводные камни приготовила технология и как их обойти.
![](https://habrastorage.org/webt/e5/qh/tt/e5qhttxqyi5a27s-4dtlt2jq1mo.png)
Под катом — видео и текстовая расшифровка моего доклада с DotNext 2018 Piter.
Меня зовут Максим Аршинов, я соучредитель аутсорс-компании «Хайтек Груп». Мы занимаемся разработкой ПО для бизнеса, и сегодня я расскажу о том, какое применение нашлось технологии expression tree в повседневной работе и как она стала нам помогать.
Я никогда специально не хотел изучать внутреннее устройство деревьев выражений, казалось, что это какая-то внутренняя технология для .NET Team, чтобы LINQ работал, а его API прикладным программистам знать не надо. Получалось так, что появлялись какие-то прикладные задачи, требующие решения. Чтобы решение мне нравилось, приходилось лезть «в кишочки».
Вся эта история растянута во времени, были разные проекты, разные кейсы. Что-то вылезало, и я дописывал, но я позволю себе пожертвовать исторической правдивостью в пользу большей художественности изложения, поэтому все примеры будут на одной предметной модели — интернет-магазине.
![](https://habrastorage.org/webt/g5/yf/cr/g5yfcrf13ptoovmcheym1x15fdk.png)
Представьте, что мы все пишем интернет-магазин. В нем есть товары и галочка «Для продажи» в админке. На публичную часть выводить мы будем только те товары, у которых эта галочка отмечена.
![](https://habrastorage.org/webt/cg/_e/jt/cg_ejtgmxn1u0hq12ezlonenue8.png)
Берем какой-нибудь DbContext или NHibernate, пишем Where(), IsForSale выводим.
Все хорошо, но бизнес-правила не бывают одинаковыми, чтобы мы их написали раз и навсегда. Они со временем эволюционируют. Приходит менеджер и говорит, что надо еще следить за остатком и выводить на публичную часть только товары, у которых есть остатки, не забывая про галочку.
![](https://habrastorage.org/webt/8e/b1/mz/8eb1mzde7casnjrrilroqxpp8ku.png)
Легко добавляем такое свойство. Теперь наши бизнес-правила инкапсулированы, можем их повторно использовать.
![](https://habrastorage.org/webt/6s/yj/09/6syj09lcwmeiktufuwl4a4wzvyq.png)
Попробуем отредактировать LINQ. Все ли здесь хорошо?
Нет, это не будет работать, потому что IsAvailable не мапится никак на базу данных, это наш код, и query-провайдер не знает, как его разобрать.
![](https://habrastorage.org/webt/qb/yt/dp/qbytdpgh5ybdiua4kjpc-rouxbs.png)
Мы можем подсказать ему, что в нашем свойстве такая история. Но теперь эта лямбда продублирована и в linq-выражении, и в свойстве.
Where(x => x.IsForSale && x.InStock > 0)
IsAvailable => IsForSale && InStock > 0;
Значит, когда в следующий раз эта лямбда изменится, нам придется делать Ctrl+Shift+F по проекту. Естественно, все мы не найдем — баги и время. Хочется такого избежать.
![](https://habrastorage.org/webt/8b/8b/j8/8b8bj8voopaagucck7rcl1j9tcg.png)
Можем зайти с такой стороны и поставить перед Where() еще один ToList(). Это плохое решение, потому что если в базе миллион товаров, все поднимаются в оперативную память и фильтруются там.
![](https://habrastorage.org/webt/ca/2t/2g/ca2t2gbdetsmcijcefubnokpfeu.png)
Если у вас три товара в магазине, решение хорошее, но в E-commerce их обычно больше. Сработало это лишь потому, что, несмотря на схожесть лямбд между собой, тип у них абсолютно разный. В первом случае это делегат Func, а во втором — дерево выражений. Выглядит одинаково, типы разные, байт-код абсолютно разный.
![](https://habrastorage.org/webt/2l/qi/ya/2lqiya8v9axdrfchqrdqtrxbe4o.png)
Чтобы перейти от 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 в делегат, будет проседание по производительности. Отыскивать эти места можно, но они будут влиять на время ответа сервера, причем случайным образом.
![](https://habrastorage.org/webt/ur/jw/y_/urjwy_veoqpl7udanxvc3-ddsl8.png)
Поэтому их надо как-то кэшировать. Если вы слушали доклад про concurrent-структуры данных, то вы знаете про ConcurrentDictionary (или просто про него знаете). Я опущу детали про способы кэширования (с блокировками, не блокировками). Просто у ConcurrentDictionary есть простой метод GetOrAdd(), и самая простая реализация: засунуть в ConcurrentDictionary и закэшировать. В первый раз мы получим компиляцию, но потом все будет быстро, потому что делегат уже скомпилирован.
![](https://habrastorage.org/webt/h1/wp/gb/h1wpgbo_5pi3xhnfaivvvjsz4ks.png)
Дальше можно использовать такой метод расширения можно использовать и отрефакторить наш код с IsAvailable(), описать expression, свойства IsAvailable() скомпилировать и вызвать относительно текущего объекта this.
Есть, по крайней мере, два пакета, которые это реализуют: Microsoft.Linq.Translations и Signum Framework (опенсорсный фреймворк, написанный коммерческой компанией). И там, и там примерно одна и та же история с компиляцией делегатов. Немного разное API, но все как я показал на предыдущем слайде.
Тем не менее, это не единственный подход, и можно идти от делегатов к выражениям. Достаточно давно на хабре есть статья про Delegate Decompiler, где автор утверждает, что все компиляции это плохо, потому что долго.
Вообще делегаты были раньше выражений, и можно от делегатов переходить к ним. Для этого автор использует метод methodBody.GetILAsByteArray(); из Reflection, который действительно возвращает в качестве массива байтов весь IL-код метода. Если это засунуть дальше в Reflection, то можно получить объектное представление этого дела, пройтись по нему циклом и построить expression tree. Таким образом, обратный переход тоже возможен, но его приходится делать руками.
![](https://habrastorage.org/webt/_r/pp/d5/_rppd5sfv3tmy9mfacmg6wydi3k.png)
Для того чтобы не бегать по всем свойствам, автор предлагает повесить атрибут Computed, чтобы пометить, что это свойство надо инлайнить. Перед запросом залезаем в IsAvailable(), вытаскиваем его IL-код, преобразуем к expression tree и заменяем вызов IsAvailable() на то, что написано в этом геттере. Получается такой ручной инлайнинг.
![](https://habrastorage.org/webt/nt/jx/g1/ntjxg17v2olokmu7ebckzs7v_uq.png)
Чтобы это сработало, прежде чем передавать все в ToList(), вызываем специальный метод Decompile(). Он предоставляет декоратор для оригинального queryable и осуществляет инлайнинг. Только после этого мы передаем все в query-провайдер, и все у нас хорошо.
![](https://habrastorage.org/webt/ze/cz/qw/zeczqw8mzr-k72ivpyfzqrpuhrm.png)
Единственная проблема с этим подходом заключается в том, что Delegate Decompiler 0.23.0 не собирается двигаться вперед, поддержки Core нет, и сам автор говорит, что это глубокая alpha, там много недописанного, поэтому в продакшне использовать нельзя. Хотя к этой теме мы еще вернемся.
Булевы операции
Получается, что проблему дублирования конкретных условий мы решили.
![](https://habrastorage.org/webt/gc/-u/5n/gc-u5nh7vip9kn5cq1d5gn3ivxm.png)
Но условия часто необходимо комбинировать с помощью булевой логики. У нас был IsForSale(), InStock() > 0, а между ними условие «И». Если есть еще какое-то условие, или потребуется «ИЛИ»-условие.
![](https://habrastorage.org/webt/9f/xu/hy/9fxuhyfzinahcrlertade-zam2s.png)
В случае с «И» можно схитрить и свалить всю работу на query-провайдер, то есть написать много Where() подряд, это он делать умеет.
![](https://habrastorage.org/webt/dd/hp/c9/ddhpc9tfdvmeiadkmyfmswnr4h8.png)
Если же потребуется «ИЛИ», это не пройдет, потому что WhereOr() в LINQ нет, а у выражений не перегружен оператор «||».
Спецификации
Если вы знакомы с книгой Эванса «DDD» или просто знаете что-то про паттерн Спецификация, то есть шаблон проектирования, предназначенный ровно для этого. Есть несколько бизнес-правил и вы хотите комбинировать операции в булевой логике — реализуйте Спецификацию.
![](https://habrastorage.org/webt/nj/cb/4n/njcb4n2xt0g8dk3joumhmsgmuyg.png)
Спецификация — это такой термин, старый паттерн из Java. А в Java, тем более в старом, никакого LINQ не было, поэтому он реализован там только в виде метода isSatisfiedBy(), то есть только делегаты, а про выражения там речи нет. В интернете есть реализация, которая называется LinqSpecs, на слайде вы ее увидите. Я ее немного подпилил напильником под себя, но идея принадлежит библиотеке.
Здесь перегружены все булевые операторы, перегружены операторы true и false, чтобы работали два оператора «&&» и «||», без них будет работать только одинарный амперсанд.
![](https://habrastorage.org/webt/nh/8b/16/nh8b16hfmjec4t6yotlcmu5nbzi.png)
Дальше дописываем implicit-операторы, которые заставят компилятор считать, что спецификация — это и выражения, и делегаты. В любом месте, где в функцию должен прийти тип Expression<> или Func<>, вы можете передавать спецификацию. Так как перегружен оператор implicit, компилятор разберется и подставит либо свойства Expression, либо IsSatisfiedBy.
![](https://habrastorage.org/webt/pd/-t/jk/pd-tjkx5uxieorm3go74act-2gc.png)
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);
![](https://habrastorage.org/webt/ru/ju/ya/rujuyaoj1ogssqq_resyqjmkutm.png)
Каждое бизнес-правило написано только один раз, оно никуда не потеряется, не продублируется, их можно комбинировать. Люди, приходя на проект, могут посмотреть, что у вас есть, какие условия, разобраться в предметной модели.
![](https://habrastorage.org/webt/td/5l/zi/td5lzi5sx9bcq522numfdrylehg.png)
Есть небольшая проблема: методов And(), Or() и Not() у Expression нет. Это extension-методы, их надо реализовать самостоятельно.
![](https://habrastorage.org/webt/l9/go/tj/l9gotjytui3drpptkcdtbyeymcc.png)
Первая попытка реализации была такой. Про expression tree довольно мало документации в интернете, и она вся не подробная. Поэтому я попробовал просто взять Expression, нажал Ctrl+Space, увидел OrElse(), прочитал про него. Передал два Expression, чтобы скомпилировать и дальше получить лямбду. Так не будет работать.
![](https://habrastorage.org/webt/v4/p3/8u/v4p38uvzgxubb2y8olt38ivbipq.png)
Дело в том, что данный Expression состоит из двух частей: параметра и тела. Второй также состоит из параметра и тела. В OrElse() надо передавать тела выражений, то есть бесполезно сравнивать по «И» и «ИЛИ» лямбды, так не будет работать. Исправляем, но так снова не будет работать.
Но если в прошлый раз было NotSupportedException, что лямбда не поддерживается, то теперь странная история про параметр 1, параметр 2, «что-то неправильно, работать не буду».
С# 7.0 in a Nutshell
Тут я подумал, что метод научного тыка не пройдет, надо разобраться. Начал гуглить и нашел сайт книжки Албахари «С# 7.0 in a Nutshell».
![](https://habrastorage.org/webt/m6/0b/ag/m60bagpqsrrcakt5wz-p05lcfyq.png)
Джозеф Албахари, он же разработчик популярной библиотеки LINQKit и LINQPad, как раз описывает эту проблему. что нельзя просто взять и скомбинировать Expression, а если взять волшебный Expression.Invoke(), работать будет.
Вопрос: что такое Expression.Invoke()? Опять идем в Google. Он создает InvocationExpression, который применяет делегат или лямбда-выражение к списку аргументов.
![](https://habrastorage.org/webt/br/du/mh/brdumhygufxqwgu5hfly1nlac0a.png)
Если я вам сейчас этот код зачитаю, что мы берем Expression.Invoke(), параметры передаем, то там написано тоже самое по-английски. Понятнее не становится. Есть какой-то волшебный Expression.Invoke(), который почему-то решает эту проблему с параметрами. Надо поверить, понимать не надо.
![](https://habrastorage.org/webt/ja/88/po/ja88po-nxrjqtzdya6f8xxnfwre.png)
При этом, если попробовать скормить EF такие скомбинированные Expression, он опять упадет и скажет, что Expression.Invoke() не поддерживается. Кстати, EF Core начал поддерживать, а EF 6 не держит. Но Албахари предлагает просто написать AsExpandable(), и все заработает.
![](https://habrastorage.org/webt/z5/4s/lq/z54slqaaitigetkidixpw-0a408.png)
А еще вы можете подставлять в подзапросы Expression, где нам нужен делегат. Чтобы они совпали, мы пишем Compile(), но при этом, если написать AsExpandable(), как предлагает Албахари, этот Compile() на самом деле не произойдет, а все как-то магически будет сделано правильно.
![](https://habrastorage.org/webt/u_/bd/wo/u_bdwop-dd-1gf_xvq3e_d9hbj8.png)
Я на слово не поверил и полез в исходники. Что за метод AsExpandable()? В нем есть query и QueryOptimizer. Второй мы оставим за скобками, так как он неинтересный, а просто склеивает Expression: если есть 3 + 5, он поставит 8.
![](https://habrastorage.org/webt/l6/8j/tw/l68jtwqse6njinyczxh7xs3ilmw.png)
Интересно, что дальше вызывается метод Expand(), после него queryOptimizer, а затем все передается в query-провайдер как-то переделанное после метода Expand().
![](https://habrastorage.org/webt/pr/dv/oc/prdvockxs8plz7f2lyciiyu0s5y.png)
Открываем его, это Visitor, внутри мы видим неоригинальный Compile(), который компилирует что-то другое. Не буду рассказывать, что именно, хоть в этом и есть определенный смысл, но мы убираем одну компиляцию и заменяем ее на другую. Здорово, но попахивает маркетингом 80-го уровня, потому что performance impact никуда не денется.
В поисках альтернативы
Я подумал, что так дело не пойдет и стал искать другое решение. И нашел. Есть такой Пит Монтгомери, который тоже пишет об этой проблеме и утверждает, что Албахари схалтурил.
![](https://habrastorage.org/webt/k9/pu/hy/k9puhyrru6xqi3hyozftlav_lvk.png)
Пит поговорил с разработчиками EF, и они его научили все скомбинировать без Expression.Evoke(). Идея очень простая: засада была с параметрами. Дело в том, что при комбинации Expression есть параметр первого выражения и параметр второго. Они не совпадают. Тела склеили, а параметры остались висеть в воздухе. Их надо забиндить правильным образом.
Для этого надо составить словарь, посмотрев параметры выражений, если лямбда не от одного параметра. Составляем словарь, и все параметры второго перебиндиваем на параметры первого, чтобы изначальные параметры вошли в Expression, проехали по всему телу, которое мы склеили.
![](https://habrastorage.org/webt/yn/ct/37/ynct37iynt07424au-grgvhhz1o.png)
Такой простой метод позволяет избавиться от всех засад с Expression.Invoke(). Более того, в реализации Пита Монтгомери это сделано еще круче. У него есть метод Compose(), позволяющий комбинировать любые выражения.
![](https://habrastorage.org/webt/wc/of/fv/wcoffvtu4s62gggy_qqykowjtns.png)
Берем выражение и через AndAlso соединяем, работает без Expandable(). Именно такая реализация используется в булевых операциях.
Спецификации и агрегаты
Все было хорошо, пока не выяснилось, что в природе существуют агрегаты. Для тех, кто не знаком, поясню: если у вас есть доменная модель и вы представляете все сущности, которые связаны друг с другом, в виде деревьев, то висящее отдельно дерево — это агрегат. Заказ вместе с позициями заказа будет называться агрегат, а сущность заказа — корень агрегации.
![](https://habrastorage.org/webt/zr/vr/6a/zrvr6af-865n3tluhcoivfjn68g.png)
Если кроме товаров, еще есть категории с объявленным для них бизнес-правилом в виде спецификации, что есть некий рейтинг, который должен быть больше 50, как сказали маркетологи и мы хотим его так использовать, то это хорошо.
![](https://habrastorage.org/webt/tp/vh/u-/tpvhu-12wisjnbsdhnmddkqpkw4.png)
Но если же мы хотим вытащить товары из хорошей категории, то опять плохо, потому что у нас не совпали типы. Спецификация для категории, а нужны продукты.
![](https://habrastorage.org/webt/yz/cj/wp/yzcjwpjtf0jvuwff1dae8jef7ha.png)
Опять надо как-то решать проблему. Первый вариант: заменить Select(), на SelectMany(). Здесь мне не нравятся две вещи. Во-первых, я плохо знаю, как реализована поддержка SelectMany() во всех популярных query-провайдерах. Во-вторых, если кто-то будет писать query-провайдер, то первое, что он будет делать, — это писать throw not implemented exception и SelectMany(). И третий момент: люди думают, что SelectMany() — это либо функциональщина, либо join’ы, обычно не ассоциируется с запросом SELECT.
Композиция
Хотелось бы использовать Select(), а не SelectMany().
![](https://habrastorage.org/webt/-_/bv/qm/-_bvqmi8jssf1sva8boyiuh1vsg.png)
Примерно в то же время я читал про теорию категорий, про функциональную композицию и подумал, что если есть спецификации из продукта в bool снизу, есть какая-то функция, которая от продукта может перейти к категории, есть спецификация относительно категории, то, подставив первую функцию в качестве аргумента второй, мы получим что надо, спецификацию относительно продукта. Абсолютно так же, как работает функциональная композиция, но для деревьев выражений.
![](https://habrastorage.org/webt/hg/wp/7h/hgwp7h8a0nrpv2xsfphtltel160.png)
Тогда можно было бы написать такой метод 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 от продуктов и комбинируем его вместе с спецификаций относительно продукта и всё.
![](https://habrastorage.org/webt/5p/5k/re/5p5krejfyvexk6c-hf50hymykss.png)
Теперь можно писать такие Where(). Это будет работать, если у вас агрегат любой длины. У Category есть SuperCategory и сколько угодно дальше свойств, которые можно подставить.
«Раз у нас есть инструмент функциональной композиции, и раз мы можем это компилировать, и раз мы можем собирать это динамически, значит есть запахло мета-программированием!», — подумал я.
Проекции
Где же мы можем применить мета-программирование, чтобы пришлось меньше кода писать.
![](https://habrastorage.org/webt/z6/dw/gi/z6dwgivuday4edqii5me2z0yuw0.png)
Первый вариант — проекции. Вытаскивать целиком сущность зачастую слишком дорого. Чаще всего мы ее передаем на фронт, сериализуем JSON. А в нем не нужна вся сущность вместе с агрегатом. Максимально эффективно с помощью LINQ это можно сделать, написав такой Select() ручной. Не сложно, но нудно.
![](https://habrastorage.org/webt/1r/co/xd/1rcoxdmbzmuplg1vyuvkz5hrrqg.png)
Вместо этого я всем предлагаю использовать ProjectToType(). По крайней мере, есть две библиотеки, которые это умеют: Automapper и Mapster. Почему-то очень многие знают, что AutoMapper умеет делать маппинг в памяти, но не все знают, что у него есть Queryable Extensions, там тоже Expression, и он может строить SQL-выражение. Если вы все еще пишете ручные запросы и вы используете LINQ, так как у вас нет супер-серьезных перформанс ограничений, то нет никакого смысла делать это руками, это работа машины, а не человека.
Фильтрация
Если мы умеем так делать с проекциями, почему бы так не делать для фильтрации.
![](https://habrastorage.org/webt/wg/ei/pd/wgeipdnvwwusix--gk7_zch9nhs.png)
Вот тоже код. Приходит какой-то фильтр. Очень много бизнес-приложений выглядят так: пришел фильтр, добавим Where(), пришел еще фильтр, добавим Where(). Сколько фильтров есть, столько и повторите. Ничего сложного, но очень много копипасты.
![](https://habrastorage.org/webt/fd/7r/wl/fd7rwlnpkuiluiadq0zppn7_t-y.png)
Если мы как AutoMapper сделаем, напишем AutoFilter, Project и Filter, чтобы он сам все сделал, было бы круто — меньше кода.
![](https://habrastorage.org/webt/9g/es/tg/9gestgfo-ouzbolexklhu78gv8q.png)
В этом нет ничего сложного. Берем Expression.Property, проходимся по DTO и по сущности. Находим общие свойства, которые называются одинаково. Если они называются одинаково — это похоже на фильтр.
Дальше надо проверить на null, использовать константу, чтобы вытащить из DTO значение, подставить его в выражение и добавить конвертацию на случай, если у вас Int и NullableInt или другие Nullable, чтобы типы совпали. И поставить, например, Equals(), фильтр, который проверяет на равенство.
![](https://habrastorage.org/webt/09/x6/me/09x6mep3p-rrvwo0vodcnke_8eo.png)
После чего собрать лямбду и пробежаться для каждого свойства: если их много, собрать либо через «И» или «ИЛИ», в зависимости от того, как работает у вас фильтр.
![](https://habrastorage.org/webt/ha/vw/en/havwen3xvrbraul6plgdadjjusy.png)
Тоже самое можно сделать для сортировки, но это немного сложнее, так как в методе OrderBy() два дженерика, поэтому их придется заполнять руками, с помощью Reflections создавать метод OrderBy() от двух дженериков, вставлять тип сущности, которой мы берем, тип сортируемого Property. В общем, тоже можно сделать, это несложно.
Возникает вопрос: где поставить Where() — на уровне сущности, как были объявлены спецификации или после проекции, и там, и там будет работать.
![](https://habrastorage.org/webt/e1/4g/4g/e14g4gelsidqhaeq7avs0wgp51i.png)
Правильно и так, и так, потому что спецификации по определению — бизнес-правила, а их мы должны холить-лелеять и с ними не ошибаться. Это одномерный слой. А фильтры — это больше про UI, а значит они фильтруют по DTO. Поэтому можно поставить два Where(). Тут скорее вопросы, насколько query-провайдер хорошо с этим справится, но я считаю, что ORM-решения и так пишут плохой SQL, и он сильно хуже не будет. Если вам это сильно важно, то эта история вообще не про ваc.
![](https://habrastorage.org/webt/46/wn/kc/46wnkcjkjayv9boma0pot5of0rs.png)
Как говорится, лучше один раз увидеть, чем сто раз услышать.
Сейчас в магазине есть три товара: «Сникерс», Subaru Impreza и «Марс». Странный магазин. Давайте попробуем найти «Сникерс. Есть. Посмотрим, что за сто рублей. Тоже «Сникерс». А за 500? Приблизим, ничего нет. А за 100500 Subaru Impreza. Отлично, то же самое касается сортировки.
Посортируем по алфавиту и по цене. Кода там написано ровно столько, сколько было. Эти фильтры работают для любых классов, как угодно. Если попробовать поискать по названию, то Subaru тоже найдется. А у меня в презентации было Equals(). Как так-то? Дело в том, что код здесь и в презентации немного разный. Строчку про Equals() я закомментировал и добавил особой уличной магии. Если у нас тип String, то надо не Equals(), а вызовем StartWith(), который я тоже получил. Поэтому для строк строится другой фильтр.
![](https://habrastorage.org/webt/t7/fa/kp/t7fakpf7peg_v2swpq2xwksiecu.png)
Это значит, что здесь вы можете нажать 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. Так из строительных блоков можно собирать любые свои валидации. Заметили, наверное, там тоже лямбда-выражения.
![](https://habrastorage.org/webt/ha/wx/lj/hawxljvmttn1bc0rhh5k4fvtywe.png)
Вызов принят. Берем такой же 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, к сожалению.
![](https://habrastorage.org/webt/ml/e7/j9/mle7j92pbchiswi1ni7zfvizgou.png)
Так помечаем класс юзера рефайнментом. AdultRefinement, что возраст больше 18.
![](https://habrastorage.org/webt/cp/2o/yt/cp2oytz1rrhql042hddkb3va8ug.png)
Чтобы совсем было хорошо, давайте сделаем валидацию на клиенте и сервере одинаковой. Сторонники NoJS предлагают на JS написать и бэк, и фронт. Хорошо, я на C# напишу и бэк и фронт, ничего страшного и просто транспилирую это в JS. Джаваскриптистам же можно писать на своих JSX, ES6 и транспилировать это в JavaScript. Почему нам нельзя? Пишем Visitor, проходимся, какие операторы нужны и пишем JavaScript.
![](https://habrastorage.org/webt/lg/qe/9y/lgqe9yl63oz8p8wmlkrmyfmkkky.png)
Отдельно частый кейс валидации — это регулярные выражения, их тоже надо разобрать. Если у вас 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 вывелось.
![](https://habrastorage.org/webt/km/vw/ls/kmvwlsxxw5csttfvkh_rxrpbwpe.png)
В коде стоит alert. Когда отправляем onSubmit, alert того, что пришло с бэкенда. А на бэкенде такой простой код.
![](https://habrastorage.org/webt/xj/hm/mz/xjhmmznmn7xgzlcxedk_tgvdzcu.png)
Мы просто возвращаем 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, мы тоже так можем.
Тестирование
![](https://habrastorage.org/webt/jz/fm/y0/jzfmy0ngjejazfvdrf5ld9y8cc4.png)
Обычно именно тимлидов больше беспокоит количество ошибок в коде.Те, кто пишут юнит-тесты, знают библиотеку Moq. Не хочешь писать mock или какой-то класс объявлять — есть moq, у него есть fluent-синтаксис. Можно расписать, как ты хочешь чтобы он себя вел и подсунуть свое приложение для тестирования.
Эти лямбды в moq — это тоже Expression, не делегаты. Он пробегается по деревьям выражений, применяет свою логику и дальше кормит в Castle.DynamicProxy. А он создает в рантайме необходимые классы. Но мы же тоже так можем.
![](https://habrastorage.org/webt/tc/xo/fk/tcxofk6igzdettbtprnfewltn_q.png)
Один мой знакомый недавно спросил, есть ли в нашем 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.
![](https://habrastorage.org/webt/gr/bq/r_/grbqr_q2oqe0rulsblvpejylomk.png)
Мы знаем, что Reflection медленный, хотелось бы этого избежать. Здесь тоже есть хорошие кейсы работы с Expression. Первое — это активатор CreateInstance. Не надо его использовать вообще никогда, потому что есть Expression.New(), который просто можно загнать в лямбду, скомпилировать и после этого получить конструкторы.
![](https://habrastorage.org/webt/8u/me/lv/8umelvrhhu0ctn0gaunjp1ldmxg.png)
Этот слайд я позаимствовал у замечательного спикера и музыканта Вагифа. Он в блоге делал какой-то бенчмарк. Вот Activator, это Пик Коммунизма вы видите, сколько он пытается все сделать. Constructor_Invoke, он примерно как половина. А слева — New и compiled-лямбда. Есть небольшое увеличение производительности за счет того, что это делегат, а не конструктор, но выбор очевиден, понятно что это сильно лучше.
![](https://habrastorage.org/webt/yr/sv/7k/yrsv7kspmbbmnfjxc1slzkemvx4.png)
То же самое можно делать с геттерами или сеттерами.
![](https://habrastorage.org/webt/gf/2d/4e/gf2d4ebwaltmazxfjicoupxwrbk.png)
Делается очень просто. Если вас по каким-то причинам не устраивает Fast Memember Марка Гравелли или Fast Reflect, не хотите тащить эту зависимость, можно сделать так же. Единственная сложность, что за всеми этими компиляциями надо следить, где-то хранить и прогревать кэш. То есть, если этого много, то на старте надо скомпилировать один раз.
![](https://habrastorage.org/webt/hi/x1/e7/hix1e7peelljkkm6kewh4blyhgq.png)
Раз есть конструктор, геттеры и сеттеры, осталось только поведение, методы. Но их тоже можно компилировать в делегаты, и вы получите просто большой зоопарк делегатов, которым нужно будет уметь управлять. Зная все то, о чем я рассказал, кому-то в голову может прийти идея, что если там много делегатов, много выражений, то может быть есть место для того, что называют 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. Предварительная сетка докладов уже выложена на сайте, билеты можно приобрести там же (с первого октября стоимость билетов увеличится).