Время от времени я встречаю людей, пытающихся выразить API в терминах IQueryable<T>
. Почти всегда это плохая идея. В этой статье я объясню почему. Вкратце, IQueryable<T>
— это один из лучших примеров заголовочного интерфейса (Header Interface), предлагаемых платформой .NET. Его почти невозможно реализовать полностью.
Эта статья о проблемах реализации API на основе интерфейсаIQueryable<T>
. Это не претензия к интерфейсу как таковому. Кроме этого, это не претензия к замечательным методам LINQ, доступным для интерфейсаIEnumerable<T>
.
Можно сказать, что IQueryable<T>
— это одно сплошное нарушение принципа подстановки Лисков. Я буду использовать закон Постела, чтобы объяснить почему это так.
Принцип устойчивости, также известен как закон Постела в честь Джона Постела: «Будь либерален к тому, что принимаешь, и консервативен к тому, что отсылаешь (Be liberal in what you accept, and conservative in what you send)».
Использование IQueryable<T>
Первая часть закона Постела применительно к проектированию API гласит о том, что API должен быть либеральным по отношению к тому, что принимает. Иными словами, мы говорим о входных параметрах. Таким образом, API, принимающий IQueryable<T>
может выглядеть следующим образом:
IFoo SomeMethod(IQueryable<Bar> q);
Достаточно ли либеральные требования этого API? Определенно, — нет. Такой интерфейс требует от вызывающего кода передать реализацию IQueryable<Bar>
. Согласно принципу подстановки Лисков программа должна оставаться корректной для всех реализаций интерфейса. Это относится как к реализации IQueryable<Bar>
, так и к реализации SomeMethod
.
Важно понимать основное назначение IQueryable<T>
: интерфейс спроектирован для использования в связке с поставщиками запросов (Query Provider). Иными словами, это не просто последовательность экземпляров класса Bar
, которую можно отфильтровать или преобразовать к последовательности другого типа. Нет, это выражение, предназначенное для трансляции в запрос к какому-то источнику данных, чаще всего, — к SQL. Это довольно серьезное требование для вызывающего кода.
Конечно, это мощный интерфейс (или так могло бы показаться), но действительно ли он необходим? Действительно ли SomeMethod
должен иметь возможность выполнять произвольно сложные запросы к источнику данных? В одной из недавних дискуссий выяснилось, что, на самом деле, разработчик просто хотел сделать выборку на основе нескольких простых критериев. В другой раз, разработчик хотел добавить постраничный вывод.
Такие требования могли бы быть представлены гораздо проще, без того, чтобы предъявлять значительные требования к клиенту. В обоих случаях, мы могли бы использовать специализированные Query Objects или, даже проще, просто создать набор специализированных методов.
Для фильтрации
IFoo FindById(int fooId);
IFoo FindByCorrelationId(int correlationId);
Для постраничного вывода
IEnumerable<IFoo> GetFoos(int page);
Однозначно, гораздо более либерально требовать от клиент передать только действительно необходимую информацию для реализации интерфейса. API, спроектированное в терминах ролевых интерфейсов (Role Interfaces) а не заголовочных интерфейсов, получается гораздо более гибким.
IQueryable<T>
в качестве результата
Вторая часть закона Постела гласит о том, что API должен быть консервативен в том, что он отправляет. Другими словами, метод должен гарантировать, что возвращаемые им данные строго соответствуют контракту между вызывающей стороной и реализацией. Метод, возвращающий IQueryable<T>
может выглядеть следующим образом:
IQueryable<Bar> GetBars();
При разработке API большая часть контракта определяется интерфейсом (или базовым классом). Таким образом, тип возвращаемого значения метода определяет консервативную гарантию в плане возвращаемых данных. Таким образом, в случае возврата IQueryable <Bar>
метод гарантирует, что он вернет полную реализацию IQueryable <Bar>
.
Это консервативно? Вызывая LSP, потребитель должен иметь возможность делать все, что разрешено IQueryable<Bar>
, не влияя на корректность программы. Это весьма серьезное обещание. Кто сможет его выполнить?
Существующие реализации
Реализация IQueryable<T>
— огромная задача. Если вы мне не верите, просто взгляните на серию публикаций Создание поставщика IQueryable на сайте Microsoft. Интерфейс настолько гибкий и выразительный, что, за одним исключением, всегда можно написать запрос, который данный провайдер не может перевести.
Вы когда-нибудь работали с Entity Framework или другим ORM и получалиNotSupportedException
? Многие люди получали. Фактически, за одним исключением, я твердо убежден, что все существующие реализации нарушают LSP. Я даже готов отправить бесплатную копию своей книги первому читателю, который укажет мне на реальную, общедоступную реализациюIQueryable<T>
, которая может принять любое выражение, которое я ей скормлю.
Кроме того, набор функций, поддерживаемых каждой реализацией, варьируется от поставщика запросов к поставщику запросов. Выражение, которое может быть переведено Entity Framework, может не работать с поставщиком запросов Microsoft OData. Единственная реализация, которая полностью реализует IQueryable<T>
, — это реализация в памяти (и ссылка на нее не принесет вам бесплатной книги). По иронии судьбы эту реализацию можно рассматривать как реализацию Null Object, и она идет вразрез с предназначением интерфейса IQueryable<T>
, именно потому, что он не переводит выражение на другой язык.
Почему это важно
Вы можете подумать, что это все теоретическое упражнение, но на самом деле оно имеет конкретный практический смысл. При написании чистого кода важно разработать API таким образом, чтобы было понятно, что он делает.
Такой интерфейс дает ложные гарантии:
public interface IRepository
{
IQueryable<T> Query<T>();
}
Согласно LSP и закону Постела, это, казалось бы, гарантирует, что вы можете написать любое выражение запроса (независимо от его сложности) для возвращаемого экземпляра, и оно всегда будет работать. На практике этого никогда не происходит. Программисты, определяющие такие интерфейсы, неизменно имеют в виду конкретную ORM, и они неявно стремятся оставаться в пределах, которые, как они знают, безопасны для этой конкретной ORM. Это дырявая абстракция.
Если вы имеете в виду конкретный ORM, скажите об этом прямо. Не прячьте его за интерфейс. Это создает иллюзию того, что вы можете заменить одну реализацию другой. На практике это невозможно. Представьте, что вы пытаетесь заменить источник данных с SQL на Event Store.
- Больше об устройстве
IQueryable
иIQueryProvider
в статье «Принципы работы IQueryable и LINQ-провайдеров данных».- О создании собственных поставщиков запросов в одноименном докладе Антона Третьякова с DotNext Moscow 2019.
byme
У меня тоже возникают подобные мысли, но я не вижу альтернативы. Да можно делать простые специализированые репозитории, но они плохо работают когда нужно сделать какой-то разухабистый фильрт с кучей условий. Не всегда понятно куда пихать всю эту логику. В DAL-е слишком низкоуревнево, в бизнес логике слишком не абстрактно.
Veikedo
Для фильтров хорошо подходит спецификация (но согласен, там тоже текучесть).
При должном усилии можно подружить с чистым sql.
fkthat
Паттерн "команда". Вместо сотни методов для фильтрации с дюжиной параметров в каждом — один единственный метод, который принимает параметер абстрактного типа, например, "Query" или "Filter". Каждый конкретный запрос/фильтр реализуется как производный от этого типа.
lair
И как вы сделаете, чтобы одновременно было удобно (т.е. пользователь мог сделать свой фильтр) и не текло, как
IQueryable
?fkthat
А зачем пользователю делать свой фильтр? Можно, конечно, придумать какой-нибудь продвинутый сценарий, но, это, скорее, экзотика. Много ли пользователей гугла используют что-либо кроме текстового поиска или вообще даже знают что в гугл-поиске есть целый язык поисковых запросов?
lair
Пользователь API — программист.
Но вообще, конечно, если для вас ситуации, когда пользователь (конечный) хочет фильтры в табличке "чтобы как в экселе" — экзотика, то у нас с вами радикально разные целевые аудитории.
fkthat
Да пожалуйста, если нужно "как в экселе".
Или кому-то может прийти в голову фильтровать записи по тангенсу или логарифму дня недели? Тогда да, тут уж ничего не поделаешь.
lair
У вас фильтрация только на равенство. Этого недостаточно. Нужны диапазоны, нужны строковые операции, нужны операции над частями дат (включая запрос "только по будням").
fkthat
Ну и что — все это так сложно сделать? Все равно для пользователя будет только определенный набор опций. А иначе проще ему вообще прямой доступ на чтение в БД дать.
lair
Не столько сложно, сколько просто много кода. Лайк, очень много кода.
Вот об этом, собственно и речь: когда этого набора будет не хватать, кто и в каких местах системы должен внести изменения?
fkthat
Разработчик. Или, иначе, изобретайте свой собственный аналог SQL чтобы пользователь мог писать вообще любые запросы. Или вообще, как я уже писал, пусть напрямую SQL вбивает в текстбоксе.
lair
Разработчик какого слоя системы? У вас как минимум есть DAL и UI.
fkthat
Очевидно, что и там и там. Какой смысл иметь опцию фильтрации в BL/DAL, если эта опция отсутствует в UI. В общем-то, мы вообще сейчас постепенно отказываемся от слоев в пользу CQRS с использованием GraphQL для "thin data layer" (т.е. query-вызовов).
lair
Очевидно, что когда вам в DAL надо делать изменения на каждое "пользователь захотел вот такую фильтрацию", это вызывает некоторую фрустрацию у разработчиков и особенно у заказчиков.
fkthat
Я предложил альтернативу — пускай пользоватеь сам пишет SQL. А еще лучше, пускай вообще сам все приложение пишет — тогда у разработчиков уж точно фрустрации не будет :))
lair
Ну, собственно, заказчик такого и хочет иногда. Только это как раз и есть пример протекшей абстракции.
0x1000000
Я для себя нашел решение в виде повторения синтаксического дерева SQL (даже статья на хабре про это есть "Дерево синтаксиса и альтернатива LINQ при взаимодействии с базами данных SQL"). “Разухабистый” фильтр передается в виде выражения по всем уровням абстракции (сериализация /десериализация в JSON/XML происходит без проблем) в репозиторий, где он легко превращается в SQL текст. Пользуюсь этим подходом уже несколько лет, забыв про все эти IQueryable как про страшный сон. Все наработки выложил в виде библиотеки на github.
0x1000000
Что бы не быть голословным вот скриншот реально работающего фильтра:
zetroot
Я тоже этого каждый раз опасаюсь, а потом оказывается, что в жизни реально нужно 5-10 специальных методов в 20% репозиториев в проекте.
Иногда надо просто принять что, набор данных на 100 записей из 5-7 полей лучше отфильтровать на слое бизнес-логики, а не пытаться сделать все в БД и получать дырявые абстракции. Но тут конечно, надо быть осторожным. Такие таблицы иногда могут вырасти)
В общем золотой пули нет, и каждый раз надо смотреть на баланс трудозатрат на разработку, тестирование и поддержку «красивого решения» и профита которое оно даст.
MonkAlex
Статья без выводов.
Всё плохо, но что делать, куда идти и прочее — отсутствует.
При этом, проблема действительно есть, есть давно, все уже привыкли =)
mayorovp
А вывод простой — не передавать IQueryable между слоями, его надо оставить его на уровне DAL.
MonkAlex
Никаких публичных апи с ним всмысле?
А если взять не IQueryable, а например Odata с его ограниченным набором операций — станет лучше?
byme
Но тогда может получиться DAL который принимает целую модель с 100500+ полями и что-то с ними делает.
jabrailzadeh
Идти в сторону GraphQL :)
MonkAlex
Так его кто-то должен на бэке реализовать же? и будет ровно то же, кто-то реализовал не всё, падает notsupported условный.
Или я что-то не знаю про GraphQL?
jabrailzadeh
ну да, реализовать то надо, но нету надобностей в IQueryable так как он из под коробки работает с фильтрациями.
Ну а если кто то что то не правильно реализовал, его вина, пусть исправляет. Как то я не знаю такого языка или фреймворка который бы помогал с такого рода проблемами ))
MonkAlex
Хм, я может чего не понимаю, то по моему GraphQL никак не решает проблему указанную в статье.
Может автор прокомментирует.
jabrailzadeh
проблема указанная в статье это успользование IQueryable в апи для предоставления доступа к разного рода фильтрациям(во всяком случае я это так понял) что то вроди анти паттерна. А GraphQL решает это тем что там вообще отпадает нужда в использовании IQueryable.
marshinov Автор
прокомментировал
andreyverbin
Каким образом интерфейс может нарушать LSP? Реализация — запросто, но интерфейс то как?
byme
Например, когда он отвечает за много всего сразу. Любой кто захочет его реализовать сделает это по другому и очень высока вероятность что это «по другому» может что-то сломать. Именно это и происходит с IQueryable.
andreyverbin
В общем случае плохо «отвечать за все сразу», но это никак к LSP не относится.
Вся дискуссия в итоге о том, стоит ли IQueryable светить в «публичном» API. Я соглашусь с тем, что не стоит, но LSP тут ни при чем. LSP ничего не говорит о сложности реализации или вероятности ошибиться во время реализации. Вот скажите — если вместо IQueryable в API будет Expression от этого что-то поменяется? Expression вообще реализовывать не нужно, он дан нам свыше :)
Я могу аргументировать против IQueryable в публичном API, без привлечения Лисков. Так как IQueryable завязан на провайдера, который часто владеет ценным ресурсом вроде подключения к БД, то нужно всегда знать время жизни этого объекта. А если мы его отдаём всем направо и налево, то жди беды.
marshinov Автор
Где в IQueryable сказано о базе и о времени жизни? Хм, может быть в IQueryProvider? Нет? Что же это — «детали реализации» получается? Может быть, даже существуют провайдеры, работающие со внешним API, а не БД в качестве источника данных? Так это, выходит, что разные реализации провайдеров не взаимозаменяемы? Как же назывался этот принцип?
andreyverbin
Span тоже нарушает LSP потому что внутри у него разные менеджеры памяти?
fkthat
LSP как раз о том, что производные реализации базового могут быть разными, но вести себя должны одинаково. Например:
Если для базового допустимо входное значение "42", то все производные тоже должны его допускать — неусиление предусловий.
Если от базового на выходе ожидают всегда что-то в диапазоне от 1 до 10, то все производные тоже должны возвращать значение в этом диапазоне — неослабление постусловий.
Если значение свойства Price у базового долно быть всегда неотрицательным, то и у всех производных оно всегда должно быть неотрицательным — неослабление инвариантов.
andreyverbin
Вот с чего обсуждение началось
LSP может нарушить конкретная реализация. Интерфейс не может нарушить пред. и пост. условились, он их устанавливает.
fkthat
Автор, может быть, для краткости выразился немного неточно, но, по крайней мере, лично я его мысль о нарушении LSP вполне понял.
lair
Если невозможно сделать две взаимозаменяемых (и полезных) реализации интерфейса, то выполнение LSP для этого интерфейса невозможно.