Как стать автором
Обновить

Принципы работы IQueryable и LINQ-провайдеров данных

Время на прочтение8 мин
Количество просмотров74K
Средства LINQ позволяют .Net-разработчикам единообразно работать как с коллекциями объектов в памяти, так и с объектами, хранящимися в базе данных или ином удаленном источнике. Например, для запроса десяти красных яблок из списка в памяти и из БД средствами Entity Framework мы можем использовать абсолютно идентичный код:

List<Apple> appleList; 
DbSet<Apple> appleDbSet;
var applesFromList = appleList.Where(apple => apple.Color == “red”).Take(10);
var applesFromDb = appleDbSet.Where(apple => apple.Color == “red”).Take(10);

Однако, выполняются эти запросы по-разному. В первом случае при перечислении результата с помощью foreach яблоки будут отфильтрованы с помощью заданного предиката, после чего будут взяты первые 10 из них. Во втором случае синтаксическое дерево с выражением запроса будет передано специальному LINQ-провайдеру, который транслирует его в SQL-запрос к базе данных и выполнит, после чего сформирует для 10 найденных записей объекты С# и вернет их. Обеспечить такое поведение позволяет интерфейс IQueryable<T>, предназначенный для создания LINQ-провайдеров к внешним источникам данных. Ниже мы попробуем разобраться с принципами организации и использования этого интерфейса.

Интерфейсы IEnumerable<T> и IQueryable<T>


На первый взгляд может показаться, что в основе LINQ лежит набор методов-расширений вроде Where(), Select(), First(), Count() и т.д. к интерфейсу IEnumerable<T>, что в итоге дает разработчику возможность единообразно писать запросы как к объектам в памяти (LINQ to Objects), так и к базам данных (например, LINQ to SQL, LINQ to Entities) и удаленным сервисам (например, LINQ to OData Services). Но это не так. Дело в том, что внутри методов-расширений к IEnumerable<T> уже реализованы соответствующие операции с последовательностями. Так, например, метод First<TSource>(Func<TSource, bool> predicate) реализован в .Net Framework 4.5.2, исходники которого нам доступны здесь, следующим образом:

public static TSource First<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate) {
    if (source == null) throw Error.ArgumentNull("source");
    if (predicate == null) throw Error.ArgumentNull("predicate");
    foreach (TSource element in source) {
          if (predicate(element)) return element;
    }
    throw Error.NoMatch();
}

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

Для реализации LINQ-провайдеров к внешним по отношению к приложению данным используется интерфейс IQueryable<T> (наследник от IEnumerable<T>) вместе с набором методов-расширений, почти полностью идентичных тем, что написаны для IEnumerable<T>. Именно потому, что List<T> реализует IEnumerable<T>, а DbSet<T> из Entity Framework – IQueryable<T>, приведенные в начале статьи запросы с яблоками выполняются по-разному.

Особенность методов-расширений к IQueryable<T> состоит в том, что они не содержат логики обработки данных. Вместо этого они просто формируют синтаксическую структуру с описанием запроса, «наращивая» ее при каждом новом вызове метода в цепочке. При вызове же агрегатных методов (Count() и т.п.) или при перечислении с помощью foreach описание запроса передается на выполнение провайдеру, инкапсулированному внутри конкретной реализации IQueryable<T>, а тот уже преобразует запрос в язык источника данных, с которым работает, и выполняет его. В случае с Entity Framework таким языком является SQL, в случае с .Net-драйвером для MongoDb – это поисковый json-объект и т.д.

Кстати говоря, из этой особенности вытекают некоторые «интересные» характеристики LINQ-провайдеров:
  • запрос, который успешно выполняется одним провайдером, может не поддерживаться другим; более того, узнаем мы об этом даже не на этапе конструирования запроса, а только на этапе его выполнения провайдером;
  • перед выполнением запроса провайдер может предварительно его модифицировать; например, ко всем запросам может добавляться ограничение на количество возвращаемых объектов, дополнительные фильтры и т.д.

Делаем LINQ своими руками: ISimpleQueryable<T>


Прежде чем описывать устройство интерфейса IQueryable<T>, попробуем самостоятельно написать его простой аналог – интерфейс ISimpleQueryable<T>, а также пару методов-расширений к нему в стиле LINQ. Это позволит наглядно продемонстрировать основные принципы работы с IQueryable<T>, не вдаваясь пока в нюансы его реализации.
public interface ISimpleQueryable<TSource> : IEnumerable<TSource> {
    string QueryDescription { get; }
    ISimpleQueryable<TSource> CreateNewQueryable(string queryDescription);
    TResult Execute<TResult>();
}

В интерфейсе мы видим свойство QueryDescription, которое содержит описание запроса, а также метод Execute<TResult>(), который должен этот запрос при необходимости выполнить. Это generic-метод, поскольку результат выполнения может быть как перечислением, так и значением агрегатной функции, такой как Count(). Кроме того, в интерфейсе есть метод CreateNewQueryable(), который позволяет при добавлении нового LINQ-метода сформировать новый экземпляр ISimpleQueryable<T>, но уже с новым описанием запроса. Заметим, что описание запроса здесь представлено в виде строки, а в LINQ для этого используются деревья выражений (Expression Trees), о которых можно почитать здесь или здесь.

Теперь перейдем к методам-расширениям:

public static class SimpleQueryableExtentions 
{
    public static ISimpleQueryable<TSource> Where<TSource>(this ISimpleQueryable<TSource> queryable,
                                                            Expression<Func<TSource, bool>> predicate) {
        string newQueryDescription = queryable.QueryDescription + ".Where(" + predicate.ToString() + ")";
        return queryable.CreateNewQueryable(newQueryDescription);
    }
  
    public static int Count<TSource>(this ISimpleQueryable<TSource> queryable) {
        string newQueryDescription = queryable.QueryDescription + ".Count()";
        ISimpleQueryable<TSource> newQueryable = queryable.CreateNewQueryable(newQueryDescription);
        return newQueryable.Execute<int>();
    }
}

Как мы видим, эти методы просто дописывают информацию о себе в описание запроса и создают новый экземпляр ISimpleQueryable<T>. Кроме того, метод Where(), в отличие от своего аналога для IEnumerable<T>, принимает не сам предикат Func<TSource, bool>, а упомянутое ранее дерево выражения (expression tree) с его описанием Expression<Func<TSource, bool>>. В данном примере это просто дает нам возможность получить строку с кодом предиката, а в случае с реальным LINQ – возможность сохранить все детали запроса в виде дерева выражений.

Наконец, создадим простую реализацию нашего ISimpleQueryable<T>, которая будет содержать все необходимое для написания LINQ-запросов, за исключением метода их выполнения. Для придания реалистичности добавим туда ссылку на источник данных (_dataSource), которая должна использоваться при выполнении запроса методом Execute().

public class FakeSimpleQueryable<TSource> : ISimpleQueryable<TSource>
{
    private readonly object _dataSource;
    public string QueryDescription { get; private set; }

    public FakeSimpleQueryable(string queryDescription, object dataSource) {
        _dataSource = dataSource;
        QueryDescription = queryDescription;
    }
    
    public ISimpleQueryable<TSource> CreateNewQueryable(string queryDescription) {
        return new FakeSimpleQueryable<TSource>(queryDescription, _dataSource);
    }

    public TResult Execute<TResult>() {
        //Здесь должна быть обработка QueryDescription и применение запроса к dataSource
        throw new NotImplementedException();
    }
    
    public IEnumerator<TSource> GetEnumerator() {
        return Execute<IEnumerator<TSource>>();
    }

    IEnumerator IEnumerable.GetEnumerator() {
        return GetEnumerator();
    }
}

Теперь рассмотрим простой запрос к FakeSimpleQueryable:

var provider = new FakeSimpleQueryable<string>("", null);
int result = provider.Where(s => s.Contains("substring")).Where(s => s != "some string").Count();

Попробуем разобраться, что будет происходить, при выполнении приведенного выше кода (см. также рисунок ниже):
  • сначала первый вызов метода Where() возьмет у созданного с помощью конструктора экземпляра FakeSimpleQueryable пустое описание запроса, добавит к нему ".Where(s => s.Contains(«substring»))" и сформирует второй экземпляр FakeSimpleQueryable с новым описанием;
  • затем второй вызов Where() возьмет у созданного ранее FakeSimpleQueryable описание запроса, добавит к нему ".Where(s => s != «some string»)", после чего опять сформирует новый, третий по счету, экземпляр FakeSimpleQueryable с описанием запроса ".Where(s => s.Contains(«substring»)).Where(s => s != «some string»)";
  • наконец, вызов Count() возьмет у созданного на предыдущем шаге экземпляра FakeSimpleQueryable описание запроса, добавит к нему " .Count()" и сформирует четвертый экземпляр FakeSimpleQueryable, после чего вызовет у него метод Execute<int>, поскольку дальше построение запроса невозможно;
  • в результате внутри метода Execute() мы будем иметь значение QueryDescription, равное ".Where(s => s.Contains(«substring»)).Where(s => s != «some string»).Count()", которое и нужно обрабатывать дальше.



Настоящий IQueryable<T>… и IQueryProvider<T>


Рассмотрим теперь, что собой представляет интерфейс IQueryable<T>, реализованный в .Net:
public interface IQueryable : IEnumerable {
    Expression Expression { get; }
    Type ElementType { get; }
    IQueryProvider Provider { get; }
}
 
public interface IQueryable<out T> : IEnumerable<T>, IQueryable {}
 
public interface IQueryProvider {
    IQueryable CreateQuery(Expression expression);
    IQueryable<TElement> CreateQuery<TElement>(Expression expression);
    object Execute(Expression expression);
    TResult Execute<TResult>(Expression expression);
}

Отметим, что:
  • в .Net есть generic- и обычная версия IQueryable;
  • для хранения дерева с описанием LINQ-запроса используется свойство Expression (в нашей реализации мы использовали строчное QueryDescription);
  • свойство ElementType содержит информацию о типе возвращаемых запросом элементов и используется в реализациях LINQ-провайдеров для проверки соответствия типов;
  • пара методов по созданию новых экземпляров IQueryable (CreateQuery() и CreateQuery<TElement>()), а также пара методов по выполнению запроса (Execute() и Execute<TResult>()) вынесены в отдельный интерфейс IQueryProvider<T>; можно предположить, что такое разделение понадобилось для того, чтобы отделить сам запрос, который пересоздается при каждом новом вызове метода-расширения, от того объекта, который реально имеет доступ к источнику данных, делает всю основную работу и может быть достаточно «тяжеловесным» для постоянного пересоздания;
  • свойство IQueryable.Provider указывает на связанный экземпляр IQueryProvider.

Теперь рассмотрим работу методов-расширений к IQueryable<T> на примере метода Where():
public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source, Expression<Func<TSource, int, bool>> predicate) {
    if (source == null) throw Error.ArgumentNull("source");
    if (predicate == null) throw Error.ArgumentNull("predicate");
    return source.Provider.CreateQuery<TSource>( 
        Expression.Call(
           null, 
           ((MethodInfo)MethodBase.GetCurrentMethod()).MakeGenericMethod(typeof(TSource)), 
           new Expression[] { source.Expression, Expression.Quote(predicate) }
           ));
}

Мы видим, что метод конструирует новый экземпляр IQueryable<TSource>, передавая в CreateQuery<TSource>() выражение, в котором к исходному выражению из source.Expression добавлен вызов собственно метода Where() с переданным предикатом в качестве аргумента.

Таким образом, несмотря на некоторые отличия интерфейсов IQueryable<T> и IQueryProvider<T> от созданного нами ранее ISimpleQueryable<T>, принципы их использования в LINQ те же: каждый метод-расширение, добавленный к запросу, дополняет дерево выражений информацией о себе, после чего создает новый экземпляр IQueryable<T> с помощью метода CreateQuery<T>(), а агрегатные методы, кроме того, инициируют выполнение запроса, вызывая метод Execute<T>().

Пара слов о разработке LINQ-провайдеров


Поскольку механизм конструирования LINQ-запроса уже реализован в .Net за нас, то разработка LINQ-провайдера в большинстве своем сводится к реализации методов Execute() и Execute<TResult>(). Именно здесь требуется разобрать пришедшее на выполнение expression tree, сконвертировать его в язык источника данных, выполнить запрос, обернуть результаты в C#-объекты и вернуть их. К сожалению, эта процедура включает в себя обработку немалого количества различных нюансов. Более того, доступной информации по разработке LINQ-провайдеров довольно мало. Ниже приведены наиболее информативные, по мнению автора, статьи на эту тему:

Надеюсь, что материал этой статьи будет полезен всем, кто хотел разобраться с организацией работы LINQ-провайдеров к удаленным источникам данных или подступиться к созданию такого провайдера, но пока не решался.
Теги:
Хабы:
+26
Комментарии3

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн