Для большинства разработчиков использование expression tree ограничивается лямбда-выражениями в LINQ. Зачастую мы вообще не придаем значения тому, как технология работает «под капотом».
В этой статье я продемонстрирую вам продвинутые техники работы с деревьями выражений: устранение дублирования кода в LINQ, кодогенерация, метапрограммирование, транспиляция, автоматизация тестирования.
Вы узнаете, как пользоваться expression tree напрямую, какие подводные камни приготовила технология и как их обойти.
Под катом — видео и текстовая расшифровка моего доклада с DotNext 2018 Piter.
Меня зовут Максим Аршинов, я соучредитель аутсорс-компании «Хайтек Груп». Мы занимаемся разработкой ПО для бизнеса, и сегодня я расскажу о том, какое применение нашлось технологии expression tree в повседневной работе и как она стала нам помогать.
Я никогда специально не хотел изучать внутреннее устройство деревьев выражений, казалось, что это какая-то внутренняя технология для .NET Team, чтобы LINQ работал, а его API прикладным программистам знать не надо. Получалось так, что появлялись какие-то прикладные задачи, требующие решения. Чтобы решение мне нравилось, приходилось лезть «в кишочки».
Вся эта история растянута во времени, были разные проекты, разные кейсы. Что-то вылезало, и я дописывал, но я позволю себе пожертвовать исторической правдивостью в пользу большей художественности изложения, поэтому все примеры будут на одной предметной модели — интернет-магазине.
Представьте, что мы все пишем интернет-магазин. В нем есть товары и галочка «Для продажи» в админке. На публичную часть выводить мы будем только те товары, у которых эта галочка отмечена.
Берем какой-нибудь DbContext или NHibernate, пишем Where(), IsForSale выводим.
Все хорошо, но бизнес-правила не бывают одинаковыми, чтобы мы их написали раз и навсегда. Они со временем эволюционируют. Приходит менеджер и говорит, что надо еще следить за остатком и выводить на публичную часть только товары, у которых есть остатки, не забывая про галочку.
Легко добавляем такое свойство. Теперь наши бизнес-правила инкапсулированы, можем их повторно использовать.
Попробуем отредактировать LINQ. Все ли здесь хорошо?
Нет, это не будет работать, потому что IsAvailable не мапится никак на базу данных, это наш код, и query-провайдер не знает, как его разобрать.
Мы можем подсказать ему, что в нашем свойстве такая история. Но теперь эта лямбда продублирована и в linq-выражении, и в свойстве.
Значит, когда в следующий раз эта лямбда изменится, нам придется делать Ctrl+Shift+F по проекту. Естественно, все мы не найдем — баги и время. Хочется такого избежать.
Можем зайти с такой стороны и поставить перед Where() еще один ToList(). Это плохое решение, потому что если в базе миллион товаров, все поднимаются в оперативную память и фильтруются там.
Если у вас три товара в магазине, решение хорошее, но в E-commerce их обычно больше. Сработало это лишь потому, что, несмотря на схожесть лямбд между собой, тип у них абсолютно разный. В первом случае это делегат Func, а во втором — дерево выражений. Выглядит одинаково, типы разные, байт-код абсолютно разный.
Чтобы перейти от expression к делегату, надо просто вызвать метод Compile(). Это API предоставляет .NET: есть expression — скомпилировали, получили делегат.
А вот как перейти обратно? Есть ли в .NET что-то для перехода от делегата к деревьям выражений? Если вы знакомы с LISP, например, то там есть механизм цитирования, который позволяет код интерпретировать как структуру данных, но в .NET такого нет.
Учитывая, что у нас есть два типа лямбд, можно пофилософствовать, что же первично: expression tree или делегаты.
На первый взгляд ответ очевиден: раз есть прекрасный метод Compile(), expression tree первичен. А делегат мы должны получать, компилируя выражение. Но компиляция — процесс медленный, и если мы начнем повсеместно это делать, то получим деградацию производительности. Кроме того, мы ее получим в случайных местах, там где пришлось скомпилировать expression в делегат, будет проседание по производительности. Отыскивать эти места можно, но они будут влиять на время ответа сервера, причем случайным образом.
Поэтому их надо как-то кэшировать. Если вы слушали доклад про concurrent-структуры данных, то вы знаете про ConcurrentDictionary (или просто про него знаете). Я опущу детали про способы кэширования (с блокировками, не блокировками). Просто у ConcurrentDictionary есть простой метод GetOrAdd(), и самая простая реализация: засунуть в ConcurrentDictionary и закэшировать. В первый раз мы получим компиляцию, но потом все будет быстро, потому что делегат уже скомпилирован.
Дальше можно использовать такой метод расширения можно использовать и отрефакторить наш код с IsAvailable(), описать expression, свойства IsAvailable() скомпилировать и вызвать относительно текущего объекта this.
Есть, по крайней мере, два пакета, которые это реализуют: Microsoft.Linq.Translations и Signum Framework (опенсорсный фреймворк, написанный коммерческой компанией). И там, и там примерно одна и та же история с компиляцией делегатов. Немного разное API, но все как я показал на предыдущем слайде.
Тем не менее, это не единственный подход, и можно идти от делегатов к выражениям. Достаточно давно на хабре есть статья про Delegate Decompiler, где автор утверждает, что все компиляции это плохо, потому что долго.
Вообще делегаты были раньше выражений, и можно от делегатов переходить к ним. Для этого автор использует метод methodBody.GetILAsByteArray(); из Reflection, который действительно возвращает в качестве массива байтов весь IL-код метода. Если это засунуть дальше в Reflection, то можно получить объектное представление этого дела, пройтись по нему циклом и построить expression tree. Таким образом, обратный переход тоже возможен, но его приходится делать руками.
Для того чтобы не бегать по всем свойствам, автор предлагает повесить атрибут Computed, чтобы пометить, что это свойство надо инлайнить. Перед запросом залезаем в IsAvailable(), вытаскиваем его IL-код, преобразуем к expression tree и заменяем вызов IsAvailable() на то, что написано в этом геттере. Получается такой ручной инлайнинг.
Чтобы это сработало, прежде чем передавать все в ToList(), вызываем специальный метод Decompile(). Он предоставляет декоратор для оригинального queryable и осуществляет инлайнинг. Только после этого мы передаем все в query-провайдер, и все у нас хорошо.
Единственная проблема с этим подходом заключается в том, что Delegate Decompiler 0.23.0 не собирается двигаться вперед, поддержки Core нет, и сам автор говорит, что это глубокая alpha, там много недописанного, поэтому в продакшне использовать нельзя. Хотя к этой теме мы еще вернемся.
Получается, что проблему дублирования конкретных условий мы решили.
Но условия часто необходимо комбинировать с помощью булевой логики. У нас был IsForSale(), InStock() > 0, а между ними условие «И». Если есть еще какое-то условие, или потребуется «ИЛИ»-условие.
В случае с «И» можно схитрить и свалить всю работу на query-провайдер, то есть написать много Where() подряд, это он делать умеет.
Если же потребуется «ИЛИ», это не пройдет, потому что WhereOr() в LINQ нет, а у выражений не перегружен оператор «||».
Если вы знакомы с книгой Эванса «DDD» или просто знаете что-то про паттерн Спецификация, то есть шаблон проектирования, предназначенный ровно для этого. Есть несколько бизнес-правил и вы хотите комбинировать операции в булевой логике — реализуйте Спецификацию.
Спецификация — это такой термин, старый паттерн из Java. А в Java, тем более в старом, никакого LINQ не было, поэтому он реализован там только в виде метода isSatisfiedBy(), то есть только делегаты, а про выражения там речи нет. В интернете есть реализация, которая называется LinqSpecs, на слайде вы ее увидите. Я ее немного подпилил напильником под себя, но идея принадлежит библиотеке.
Здесь перегружены все булевые операторы, перегружены операторы true и false, чтобы работали два оператора «&&» и «||», без них будет работать только одинарный амперсанд.
Дальше дописываем implicit-операторы, которые заставят компилятор считать, что спецификация — это и выражения, и делегаты. В любом месте, где в функцию должен прийти тип Expression<> или Func<>, вы можете передавать спецификацию. Так как перегружен оператор implicit, компилятор разберется и подставит либо свойства Expression, либо IsSatisfiedBy.
IsSatisfiedBy() можно реализовать с помощью кэширования выражения, которое пришло. В любом случае получается, что мы идем от Expression, делегат ему соответствует, мы добавили поддержку булевых операторов. Теперь это все можно компоновать. Бизнес-правила можно вынести в статические спецификации, их объявить и комбинировать.
Каждое бизнес-правило написано только один раз, оно никуда не потеряется, не продублируется, их можно комбинировать. Люди, приходя на проект, могут посмотреть, что у вас есть, какие условия, разобраться в предметной модели.
Есть небольшая проблема: методов And(), Or() и Not() у Expression нет. Это extension-методы, их надо реализовать самостоятельно.
Первая попытка реализации была такой. Про expression tree довольно мало документации в интернете, и она вся не подробная. Поэтому я попробовал просто взять Expression, нажал Ctrl+Space, увидел OrElse(), прочитал про него. Передал два Expression, чтобы скомпилировать и дальше получить лямбду. Так не будет работать.
Дело в том, что данный Expression состоит из двух частей: параметра и тела. Второй также состоит из параметра и тела. В OrElse() надо передавать тела выражений, то есть бесполезно сравнивать по «И» и «ИЛИ» лямбды, так не будет работать. Исправляем, но так снова не будет работать.
Но если в прошлый раз было NotSupportedException, что лямбда не поддерживается, то теперь странная история про параметр 1, параметр 2, «что-то неправильно, работать не буду».
Тут я подумал, что метод научного тыка не пройдет, надо разобраться. Начал гуглить и нашел сайт книжки Албахари «С# 7.0 in a Nutshell».
Джозеф Албахари, он же разработчик популярной библиотеки LINQKit и LINQPad, как раз описывает эту проблему. что нельзя просто взять и скомбинировать Expression, а если взять волшебный Expression.Invoke(), работать будет.
Вопрос: что такое Expression.Invoke()? Опять идем в Google. Он создает InvocationExpression, который применяет делегат или лямбда-выражение к списку аргументов.
Если я вам сейчас этот код зачитаю, что мы берем Expression.Invoke(), параметры передаем, то там написано тоже самое по-английски. Понятнее не становится. Есть какой-то волшебный Expression.Invoke(), который почему-то решает эту проблему с параметрами. Надо поверить, понимать не надо.
При этом, если попробовать скормить EF такие скомбинированные Expression, он опять упадет и скажет, что Expression.Invoke() не поддерживается. Кстати, EF Core начал поддерживать, а EF 6 не держит. Но Албахари предлагает просто написать AsExpandable(), и все заработает.
А еще вы можете подставлять в подзапросы Expression, где нам нужен делегат. Чтобы они совпали, мы пишем Compile(), но при этом, если написать AsExpandable(), как предлагает Албахари, этот Compile() на самом деле не произойдет, а все как-то магически будет сделано правильно.
Я на слово не поверил и полез в исходники. Что за метод AsExpandable()? В нем есть query и QueryOptimizer. Второй мы оставим за скобками, так как он неинтересный, а просто склеивает Expression: если есть 3 + 5, он поставит 8.
Интересно, что дальше вызывается метод Expand(), после него queryOptimizer, а затем все передается в query-провайдер как-то переделанное после метода Expand().
Открываем его, это Visitor, внутри мы видим неоригинальный Compile(), который компилирует что-то другое. Не буду рассказывать, что именно, хоть в этом и есть определенный смысл, но мы убираем одну компиляцию и заменяем ее на другую. Здорово, но попахивает маркетингом 80-го уровня, потому что performance impact никуда не денется.
Я подумал, что так дело не пойдет и стал искать другое решение. И нашел. Есть такой Пит Монтгомери, который тоже пишет об этой проблеме и утверждает, что Албахари схалтурил.
Пит поговорил с разработчиками EF, и они его научили все скомбинировать без Expression.Evoke(). Идея очень простая: засада была с параметрами. Дело в том, что при комбинации Expression есть параметр первого выражения и параметр второго. Они не совпадают. Тела склеили, а параметры остались висеть в воздухе. Их надо забиндить правильным образом.
Для этого надо составить словарь, посмотрев параметры выражений, если лямбда не от одного параметра. Составляем словарь, и все параметры второго перебиндиваем на параметры первого, чтобы изначальные параметры вошли в Expression, проехали по всему телу, которое мы склеили.
Такой простой метод позволяет избавиться от всех засад с Expression.Invoke(). Более того, в реализации Пита Монтгомери это сделано еще круче. У него есть метод Compose(), позволяющий комбинировать любые выражения.
Берем выражение и через AndAlso соединяем, работает без Expandable(). Именно такая реализация используется в булевых операциях.
Все было хорошо, пока не выяснилось, что в природе существуют агрегаты. Для тех, кто не знаком, поясню: если у вас есть доменная модель и вы представляете все сущности, которые связаны друг с другом, в виде деревьев, то висящее отдельно дерево — это агрегат. Заказ вместе с позициями заказа будет называться агрегат, а сущность заказа — корень агрегации.
Если кроме товаров, еще есть категории с объявленным для них бизнес-правилом в виде спецификации, что есть некий рейтинг, который должен быть больше 50, как сказали маркетологи и мы хотим его так использовать, то это хорошо.
Но если же мы хотим вытащить товары из хорошей категории, то опять плохо, потому что у нас не совпали типы. Спецификация для категории, а нужны продукты.
Опять надо как-то решать проблему. Первый вариант: заменить Select(), на SelectMany(). Здесь мне не нравятся две вещи. Во-первых, я плохо знаю, как реализована поддержка SelectMany() во всех популярных query-провайдерах. Во-вторых, если кто-то будет писать query-провайдер, то первое, что он будет делать, — это писать throw not implemented exception и SelectMany(). И третий момент: люди думают, что SelectMany() — это либо функциональщина, либо join’ы, обычно не ассоциируется с запросом SELECT.
Хотелось бы использовать Select(), а не SelectMany().
Примерно в то же время я читал про теорию категорий, про функциональную композицию и подумал, что если есть спецификации из продукта в bool снизу, есть какая-то функция, которая от продукта может перейти к категории, есть спецификация относительно категории, то, подставив первую функцию в качестве аргумента второй, мы получим что надо, спецификацию относительно продукта. Абсолютно так же, как работает функциональная композиция, но для деревьев выражений.
Тогда можно было бы написать такой метод Where(), что от продуктов надо перейти к категориям и к этой связанной сущности применить спецификацию. Такой синтаксис на мой субъективный вкус выглядит довольно понятно.
С методом Compose() это тоже можно просто сделать. Берем входной Expression от продуктов и комбинируем его вместе с спецификаций относительно продукта и всё.
Теперь можно писать такие Where(). Это будет работать, если у вас агрегат любой длины. У Category есть SuperCategory и сколько угодно дальше свойств, которые можно подставить.
«Раз у нас есть инструмент функциональной композиции, и раз мы можем это компилировать, и раз мы можем собирать это динамически, значит есть запахло мета-программированием!», — подумал я.
Где же мы можем применить мета-программирование, чтобы пришлось меньше кода писать.
Первый вариант — проекции. Вытаскивать целиком сущность зачастую слишком дорого. Чаще всего мы ее передаем на фронт, сериализуем JSON. А в нем не нужна вся сущность вместе с агрегатом. Максимально эффективно с помощью LINQ это можно сделать, написав такой Select() ручной. Не сложно, но нудно.
Вместо этого я всем предлагаю использовать ProjectToType(). По крайней мере, есть две библиотеки, которые это умеют: Automapper и Mapster. Почему-то очень многие знают, что AutoMapper умеет делать маппинг в памяти, но не все знают, что у него есть Queryable Extensions, там тоже Expression, и он может строить SQL-выражение. Если вы все еще пишете ручные запросы и вы используете LINQ, так как у вас нет супер-серьезных перформанс ограничений, то нет никакого смысла делать это руками, это работа машины, а не человека.
Если мы умеем так делать с проекциями, почему бы так не делать для фильтрации.
Вот тоже код. Приходит какой-то фильтр. Очень много бизнес-приложений выглядят так: пришел фильтр, добавим Where(), пришел еще фильтр, добавим Where(). Сколько фильтров есть, столько и повторите. Ничего сложного, но очень много копипасты.
Если мы как AutoMapper сделаем, напишем AutoFilter, Project и Filter, чтобы он сам все сделал, было бы круто — меньше кода.
В этом нет ничего сложного. Берем Expression.Property, проходимся по DTO и по сущности. Находим общие свойства, которые называются одинаково. Если они называются одинаково — это похоже на фильтр.
Дальше надо проверить на null, использовать константу, чтобы вытащить из DTO значение, подставить его в выражение и добавить конвертацию на случай, если у вас Int и NullableInt или другие Nullable, чтобы типы совпали. И поставить, например, Equals(), фильтр, который проверяет на равенство.
После чего собрать лямбду и пробежаться для каждого свойства: если их много, собрать либо через «И» или «ИЛИ», в зависимости от того, как работает у вас фильтр.
Тоже самое можно сделать для сортировки, но это немного сложнее, так как в методе OrderBy() два дженерика, поэтому их придется заполнять руками, с помощью Reflections создавать метод OrderBy() от двух дженериков, вставлять тип сущности, которой мы берем, тип сортируемого Property. В общем, тоже можно сделать, это несложно.
Возникает вопрос: где поставить Where() — на уровне сущности, как были объявлены спецификации или после проекции, и там, и там будет работать.
Правильно и так, и так, потому что спецификации по определению — бизнес-правила, а их мы должны холить-лелеять и с ними не ошибаться. Это одномерный слой. А фильтры — это больше про UI, а значит они фильтруют по DTO. Поэтому можно поставить два Where(). Тут скорее вопросы, насколько query-провайдер хорошо с этим справится, но я считаю, что ORM-решения и так пишут плохой SQL, и он сильно хуже не будет. Если вам это сильно важно, то эта история вообще не про ваc.
Как говорится, лучше один раз увидеть, чем сто раз услышать.
Сейчас в магазине есть три товара: «Сникерс», 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’ов, улучшений.
Сначала объявлены примитивы, соответствующие всем типам JS, и дополнительный тип nill, соответствующий либо undefined, либо нулю.
Дальше начинается интересное. Каждый тип можно усилить с помощью предиката. Если мы хотим числа больше нуля, то объявляем предикат x >= 0 и делаем валидацию, относительно типа Positive. Так из строительных блоков можно собирать любые свои валидации. Заметили, наверное, там тоже лямбда-выражения.
Вызов принят. Берем такой же refinement, пишем его на C#, пишем метод IsValid(), так же Expression компилируем, выполняем. Теперь у нас есть возможность валидацию проводить.
Интегрируемся со стандартной системой DataAnnotations в ASP.NET MVC, чтобы это все работало из коробки. Объявляем RefinementAttribute(), передаем в конструктор тип. Дело в том, что RefinementAttribute дженериковый, поэтому здесь приходится так использовать тип, потому что нельзя объявить атрибут дженерик в .NET, к сожалению.
Так помечаем класс юзера рефайнментом. AdultRefinement, что возраст больше 18.
Чтобы совсем было хорошо, давайте сделаем валидацию на клиенте и сервере одинаковой. Сторонники NoJS предлагают на JS написать и бэк, и фронт. Хорошо, я на C# напишу и бэк и фронт, ничего страшного и просто транспилирую это в JS. Джаваскриптистам же можно писать на своих JSX, ES6 и транспилировать это в JavaScript. Почему нам нельзя? Пишем Visitor, проходимся, какие операторы нужны и пишем JavaScript.
Отдельно частый кейс валидации — это регулярные выражения, их тоже надо разобрать. Если у вас regexp, берем StringBuilder, собираем regexp. Здесь я использовал два восклицательных знака, так как JS — это динамически типизированный язык, это выражение будет приведено к bool всегда, чтобы с типом все было хорошо. Давайте посмотрим, как это выглядит.
Вот наш рефайнмент, который приходит с бэкенда, предикат в виде строчки, так как в JS нет лямбд и errorMessage «For adults only». Попробуем заполнить форму. Не проходит. Смотрим, как это сделано.
Это React, мы запрашиваем с бэкенда из метода UserRefinment() Expression и errorMessage, конструируем refinment относительно number, используем eval, чтобы получить лямбду. Если я это переделаю и сниму ограничения типа, заменю на обычный number, отвалится валидация на JS. Вводим единицу, отправляем. Не знаю, видно или нет, здесь false вывелось.
В коде стоит alert. Когда отправляем onSubmit, alert того, что пришло с бэкенда. А на бэкенде такой простой код.
Мы просто возвращаем Ok(ModelState.IsValid), класс User, который мы получаем из формы на JavaScript. Вот этот атрибут Refinement.
То есть валидация работает и на бэкенде, которая объявлена в этой лямбде. И мы ее транспилируем в JavaScript. Получается, пишем лямбда-выражения на C#, а код выполняется и там, и там. Наш ответ NoJS, мы тоже так можем.
Обычно именно тимлидов больше беспокоит количество ошибок в коде.Те, кто пишут юнит-тесты, знают библиотеку 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(), который просто можно загнать в лямбду, скомпилировать и после этого получить конструкторы.
Этот слайд я позаимствовал у замечательного спикера и музыканта Вагифа. Он в блоге делал какой-то бенчмарк. Вот Activator, это Пик Коммунизма вы видите, сколько он пытается все сделать. Constructor_Invoke, он примерно как половина. А слева — New и compiled-лямбда. Есть небольшое увеличение производительности за счет того, что это делегат, а не конструктор, но выбор очевиден, понятно что это сильно лучше.
То же самое можно делать с геттерами или сеттерами.
Делается очень просто. Если вас по каким-то причинам не устраивает 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 напрямую, какие подводные камни приготовила технология и как их обойти.
Под катом — видео и текстовая расшифровка моего доклада с DotNext 2018 Piter.
Меня зовут Максим Аршинов, я соучредитель аутсорс-компании «Хайтек Груп». Мы занимаемся разработкой ПО для бизнеса, и сегодня я расскажу о том, какое применение нашлось технологии expression tree в повседневной работе и как она стала нам помогать.
Я никогда специально не хотел изучать внутреннее устройство деревьев выражений, казалось, что это какая-то внутренняя технология для .NET Team, чтобы LINQ работал, а его API прикладным программистам знать не надо. Получалось так, что появлялись какие-то прикладные задачи, требующие решения. Чтобы решение мне нравилось, приходилось лезть «в кишочки».
Вся эта история растянута во времени, были разные проекты, разные кейсы. Что-то вылезало, и я дописывал, но я позволю себе пожертвовать исторической правдивостью в пользу большей художественности изложения, поэтому все примеры будут на одной предметной модели — интернет-магазине.
Представьте, что мы все пишем интернет-магазин. В нем есть товары и галочка «Для продажи» в админке. На публичную часть выводить мы будем только те товары, у которых эта галочка отмечена.
Берем какой-нибудь DbContext или NHibernate, пишем Where(), IsForSale выводим.
Все хорошо, но бизнес-правила не бывают одинаковыми, чтобы мы их написали раз и навсегда. Они со временем эволюционируют. Приходит менеджер и говорит, что надо еще следить за остатком и выводить на публичную часть только товары, у которых есть остатки, не забывая про галочку.
Легко добавляем такое свойство. Теперь наши бизнес-правила инкапсулированы, можем их повторно использовать.
Попробуем отредактировать LINQ. Все ли здесь хорошо?
Нет, это не будет работать, потому что IsAvailable не мапится никак на базу данных, это наш код, и query-провайдер не знает, как его разобрать.
Мы можем подсказать ему, что в нашем свойстве такая история. Но теперь эта лямбда продублирована и в linq-выражении, и в свойстве.
Where(x => x.IsForSale && x.InStock > 0)
IsAvailable => IsForSale && InStock > 0;
Значит, когда в следующий раз эта лямбда изменится, нам придется делать Ctrl+Shift+F по проекту. Естественно, все мы не найдем — баги и время. Хочется такого избежать.
Можем зайти с такой стороны и поставить перед Where() еще один ToList(). Это плохое решение, потому что если в базе миллион товаров, все поднимаются в оперативную память и фильтруются там.
Если у вас три товара в магазине, решение хорошее, но в E-commerce их обычно больше. Сработало это лишь потому, что, несмотря на схожесть лямбд между собой, тип у них абсолютно разный. В первом случае это делегат Func, а во втором — дерево выражений. Выглядит одинаково, типы разные, байт-код абсолютно разный.
Чтобы перейти от 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 в делегат, будет проседание по производительности. Отыскивать эти места можно, но они будут влиять на время ответа сервера, причем случайным образом.
Поэтому их надо как-то кэшировать. Если вы слушали доклад про concurrent-структуры данных, то вы знаете про ConcurrentDictionary (или просто про него знаете). Я опущу детали про способы кэширования (с блокировками, не блокировками). Просто у ConcurrentDictionary есть простой метод GetOrAdd(), и самая простая реализация: засунуть в ConcurrentDictionary и закэшировать. В первый раз мы получим компиляцию, но потом все будет быстро, потому что делегат уже скомпилирован.
Дальше можно использовать такой метод расширения можно использовать и отрефакторить наш код с IsAvailable(), описать expression, свойства IsAvailable() скомпилировать и вызвать относительно текущего объекта this.
Есть, по крайней мере, два пакета, которые это реализуют: Microsoft.Linq.Translations и Signum Framework (опенсорсный фреймворк, написанный коммерческой компанией). И там, и там примерно одна и та же история с компиляцией делегатов. Немного разное API, но все как я показал на предыдущем слайде.
Тем не менее, это не единственный подход, и можно идти от делегатов к выражениям. Достаточно давно на хабре есть статья про Delegate Decompiler, где автор утверждает, что все компиляции это плохо, потому что долго.
Вообще делегаты были раньше выражений, и можно от делегатов переходить к ним. Для этого автор использует метод methodBody.GetILAsByteArray(); из Reflection, который действительно возвращает в качестве массива байтов весь IL-код метода. Если это засунуть дальше в Reflection, то можно получить объектное представление этого дела, пройтись по нему циклом и построить expression tree. Таким образом, обратный переход тоже возможен, но его приходится делать руками.
Для того чтобы не бегать по всем свойствам, автор предлагает повесить атрибут Computed, чтобы пометить, что это свойство надо инлайнить. Перед запросом залезаем в IsAvailable(), вытаскиваем его IL-код, преобразуем к expression tree и заменяем вызов IsAvailable() на то, что написано в этом геттере. Получается такой ручной инлайнинг.
Чтобы это сработало, прежде чем передавать все в ToList(), вызываем специальный метод Decompile(). Он предоставляет декоратор для оригинального queryable и осуществляет инлайнинг. Только после этого мы передаем все в query-провайдер, и все у нас хорошо.
Единственная проблема с этим подходом заключается в том, что Delegate Decompiler 0.23.0 не собирается двигаться вперед, поддержки Core нет, и сам автор говорит, что это глубокая alpha, там много недописанного, поэтому в продакшне использовать нельзя. Хотя к этой теме мы еще вернемся.
Булевы операции
Получается, что проблему дублирования конкретных условий мы решили.
Но условия часто необходимо комбинировать с помощью булевой логики. У нас был IsForSale(), InStock() > 0, а между ними условие «И». Если есть еще какое-то условие, или потребуется «ИЛИ»-условие.
В случае с «И» можно схитрить и свалить всю работу на query-провайдер, то есть написать много Where() подряд, это он делать умеет.
Если же потребуется «ИЛИ», это не пройдет, потому что WhereOr() в LINQ нет, а у выражений не перегружен оператор «||».
Спецификации
Если вы знакомы с книгой Эванса «DDD» или просто знаете что-то про паттерн Спецификация, то есть шаблон проектирования, предназначенный ровно для этого. Есть несколько бизнес-правил и вы хотите комбинировать операции в булевой логике — реализуйте Спецификацию.
Спецификация — это такой термин, старый паттерн из Java. А в Java, тем более в старом, никакого LINQ не было, поэтому он реализован там только в виде метода isSatisfiedBy(), то есть только делегаты, а про выражения там речи нет. В интернете есть реализация, которая называется LinqSpecs, на слайде вы ее увидите. Я ее немного подпилил напильником под себя, но идея принадлежит библиотеке.
Здесь перегружены все булевые операторы, перегружены операторы true и false, чтобы работали два оператора «&&» и «||», без них будет работать только одинарный амперсанд.
Дальше дописываем implicit-операторы, которые заставят компилятор считать, что спецификация — это и выражения, и делегаты. В любом месте, где в функцию должен прийти тип Expression<> или Func<>, вы можете передавать спецификацию. Так как перегружен оператор implicit, компилятор разберется и подставит либо свойства Expression, либо IsSatisfiedBy.
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);
Каждое бизнес-правило написано только один раз, оно никуда не потеряется, не продублируется, их можно комбинировать. Люди, приходя на проект, могут посмотреть, что у вас есть, какие условия, разобраться в предметной модели.
Есть небольшая проблема: методов And(), Or() и Not() у Expression нет. Это extension-методы, их надо реализовать самостоятельно.
Первая попытка реализации была такой. Про expression tree довольно мало документации в интернете, и она вся не подробная. Поэтому я попробовал просто взять Expression, нажал Ctrl+Space, увидел OrElse(), прочитал про него. Передал два Expression, чтобы скомпилировать и дальше получить лямбду. Так не будет работать.
Дело в том, что данный Expression состоит из двух частей: параметра и тела. Второй также состоит из параметра и тела. В OrElse() надо передавать тела выражений, то есть бесполезно сравнивать по «И» и «ИЛИ» лямбды, так не будет работать. Исправляем, но так снова не будет работать.
Но если в прошлый раз было NotSupportedException, что лямбда не поддерживается, то теперь странная история про параметр 1, параметр 2, «что-то неправильно, работать не буду».
С# 7.0 in a Nutshell
Тут я подумал, что метод научного тыка не пройдет, надо разобраться. Начал гуглить и нашел сайт книжки Албахари «С# 7.0 in a Nutshell».
Джозеф Албахари, он же разработчик популярной библиотеки LINQKit и LINQPad, как раз описывает эту проблему. что нельзя просто взять и скомбинировать Expression, а если взять волшебный Expression.Invoke(), работать будет.
Вопрос: что такое Expression.Invoke()? Опять идем в Google. Он создает InvocationExpression, который применяет делегат или лямбда-выражение к списку аргументов.
Если я вам сейчас этот код зачитаю, что мы берем Expression.Invoke(), параметры передаем, то там написано тоже самое по-английски. Понятнее не становится. Есть какой-то волшебный Expression.Invoke(), который почему-то решает эту проблему с параметрами. Надо поверить, понимать не надо.
При этом, если попробовать скормить EF такие скомбинированные Expression, он опять упадет и скажет, что Expression.Invoke() не поддерживается. Кстати, EF Core начал поддерживать, а EF 6 не держит. Но Албахари предлагает просто написать AsExpandable(), и все заработает.
А еще вы можете подставлять в подзапросы Expression, где нам нужен делегат. Чтобы они совпали, мы пишем Compile(), но при этом, если написать AsExpandable(), как предлагает Албахари, этот Compile() на самом деле не произойдет, а все как-то магически будет сделано правильно.
Я на слово не поверил и полез в исходники. Что за метод AsExpandable()? В нем есть query и QueryOptimizer. Второй мы оставим за скобками, так как он неинтересный, а просто склеивает Expression: если есть 3 + 5, он поставит 8.
Интересно, что дальше вызывается метод Expand(), после него queryOptimizer, а затем все передается в query-провайдер как-то переделанное после метода Expand().
Открываем его, это Visitor, внутри мы видим неоригинальный Compile(), который компилирует что-то другое. Не буду рассказывать, что именно, хоть в этом и есть определенный смысл, но мы убираем одну компиляцию и заменяем ее на другую. Здорово, но попахивает маркетингом 80-го уровня, потому что performance impact никуда не денется.
В поисках альтернативы
Я подумал, что так дело не пойдет и стал искать другое решение. И нашел. Есть такой Пит Монтгомери, который тоже пишет об этой проблеме и утверждает, что Албахари схалтурил.
Пит поговорил с разработчиками EF, и они его научили все скомбинировать без Expression.Evoke(). Идея очень простая: засада была с параметрами. Дело в том, что при комбинации Expression есть параметр первого выражения и параметр второго. Они не совпадают. Тела склеили, а параметры остались висеть в воздухе. Их надо забиндить правильным образом.
Для этого надо составить словарь, посмотрев параметры выражений, если лямбда не от одного параметра. Составляем словарь, и все параметры второго перебиндиваем на параметры первого, чтобы изначальные параметры вошли в Expression, проехали по всему телу, которое мы склеили.
Такой простой метод позволяет избавиться от всех засад с Expression.Invoke(). Более того, в реализации Пита Монтгомери это сделано еще круче. У него есть метод Compose(), позволяющий комбинировать любые выражения.
Берем выражение и через AndAlso соединяем, работает без Expandable(). Именно такая реализация используется в булевых операциях.
Спецификации и агрегаты
Все было хорошо, пока не выяснилось, что в природе существуют агрегаты. Для тех, кто не знаком, поясню: если у вас есть доменная модель и вы представляете все сущности, которые связаны друг с другом, в виде деревьев, то висящее отдельно дерево — это агрегат. Заказ вместе с позициями заказа будет называться агрегат, а сущность заказа — корень агрегации.
Если кроме товаров, еще есть категории с объявленным для них бизнес-правилом в виде спецификации, что есть некий рейтинг, который должен быть больше 50, как сказали маркетологи и мы хотим его так использовать, то это хорошо.
Но если же мы хотим вытащить товары из хорошей категории, то опять плохо, потому что у нас не совпали типы. Спецификация для категории, а нужны продукты.
Опять надо как-то решать проблему. Первый вариант: заменить Select(), на SelectMany(). Здесь мне не нравятся две вещи. Во-первых, я плохо знаю, как реализована поддержка SelectMany() во всех популярных query-провайдерах. Во-вторых, если кто-то будет писать query-провайдер, то первое, что он будет делать, — это писать throw not implemented exception и SelectMany(). И третий момент: люди думают, что SelectMany() — это либо функциональщина, либо join’ы, обычно не ассоциируется с запросом SELECT.
Композиция
Хотелось бы использовать 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() ручной. Не сложно, но нудно.
Вместо этого я всем предлагаю использовать ProjectToType(). По крайней мере, есть две библиотеки, которые это умеют: Automapper и Mapster. Почему-то очень многие знают, что AutoMapper умеет делать маппинг в памяти, но не все знают, что у него есть Queryable Extensions, там тоже Expression, и он может строить SQL-выражение. Если вы все еще пишете ручные запросы и вы используете LINQ, так как у вас нет супер-серьезных перформанс ограничений, то нет никакого смысла делать это руками, это работа машины, а не человека.
Фильтрация
Если мы умеем так делать с проекциями, почему бы так не делать для фильтрации.
Вот тоже код. Приходит какой-то фильтр. Очень много бизнес-приложений выглядят так: пришел фильтр, добавим Where(), пришел еще фильтр, добавим Where(). Сколько фильтров есть, столько и повторите. Ничего сложного, но очень много копипасты.
Если мы как AutoMapper сделаем, напишем AutoFilter, Project и Filter, чтобы он сам все сделал, было бы круто — меньше кода.
В этом нет ничего сложного. Берем Expression.Property, проходимся по DTO и по сущности. Находим общие свойства, которые называются одинаково. Если они называются одинаково — это похоже на фильтр.
Дальше надо проверить на null, использовать константу, чтобы вытащить из DTO значение, подставить его в выражение и добавить конвертацию на случай, если у вас Int и NullableInt или другие Nullable, чтобы типы совпали. И поставить, например, Equals(), фильтр, который проверяет на равенство.
После чего собрать лямбду и пробежаться для каждого свойства: если их много, собрать либо через «И» или «ИЛИ», в зависимости от того, как работает у вас фильтр.
Тоже самое можно сделать для сортировки, но это немного сложнее, так как в методе OrderBy() два дженерика, поэтому их придется заполнять руками, с помощью Reflections создавать метод OrderBy() от двух дженериков, вставлять тип сущности, которой мы берем, тип сортируемого Property. В общем, тоже можно сделать, это несложно.
Возникает вопрос: где поставить Where() — на уровне сущности, как были объявлены спецификации или после проекции, и там, и там будет работать.
Правильно и так, и так, потому что спецификации по определению — бизнес-правила, а их мы должны холить-лелеять и с ними не ошибаться. Это одномерный слой. А фильтры — это больше про UI, а значит они фильтруют по DTO. Поэтому можно поставить два Where(). Тут скорее вопросы, насколько query-провайдер хорошо с этим справится, но я считаю, что ORM-решения и так пишут плохой SQL, и он сильно хуже не будет. Если вам это сильно важно, то эта история вообще не про ваc.
Как говорится, лучше один раз увидеть, чем сто раз услышать.
Сейчас в магазине есть три товара: «Сникерс», 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) { 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. Так из строительных блоков можно собирать любые свои валидации. Заметили, наверное, там тоже лямбда-выражения.
Вызов принят. Берем такой же 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 написать и бэк, и фронт. Хорошо, я на C# напишу и бэк и фронт, ничего страшного и просто транспилирую это в JS. Джаваскриптистам же можно писать на своих JSX, ES6 и транспилировать это в JavaScript. Почему нам нельзя? Пишем Visitor, проходимся, какие операторы нужны и пишем JavaScript.
Отдельно частый кейс валидации — это регулярные выражения, их тоже надо разобрать. Если у вас 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 вывелось.
В коде стоит alert. Когда отправляем onSubmit, alert того, что пришло с бэкенда. А на бэкенде такой простой код.
Мы просто возвращаем 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, мы тоже так можем.
Тестирование
Обычно именно тимлидов больше беспокоит количество ошибок в коде.Те, кто пишут юнит-тесты, знают библиотеку 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(), который просто можно загнать в лямбду, скомпилировать и после этого получить конструкторы.
Этот слайд я позаимствовал у замечательного спикера и музыканта Вагифа. Он в блоге делал какой-то бенчмарк. Вот Activator, это Пик Коммунизма вы видите, сколько он пытается все сделать. Constructor_Invoke, он примерно как половина. А слева — New и compiled-лямбда. Есть небольшое увеличение производительности за счет того, что это делегат, а не конструктор, но выбор очевиден, понятно что это сильно лучше.
То же самое можно делать с геттерами или сеттерами.
Делается очень просто. Если вас по каким-то причинам не устраивает 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 таких примеров нет, но с этим придется как-то работать.
В общем, это все, о чем я хотел сегодня рассказать. Многое из того, о чем я рассказал, более подробно, с деталями написано на Хабре. Код библиотеки выложен на гитхабе, но у нее есть один фатальный недостаток — полное отсутствие документации.
22-23 ноября в Москве пройдет DotNext 2018 Moscow. Предварительная сетка докладов уже выложена на сайте, билеты можно приобрести там же (с первого октября стоимость билетов увеличится).