Время от времени я встречаю людей, пытающихся выразить 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.


Торт — это ложь.