[.NET, C#] Транслируй меня полностью
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Вы когда-нибудь работали с Entity Framework или другим ORM и получали NotSupportedException? Многие люди получали:
InvalidOperationException: Error generated for warning 'Microsoft.EntityFrameworkCore.Query.QueryClientEvaluationWarning: The LINQ expression could not be translated and will be evaluated locally.'
Марк Симан твердо убежден, что, за одним исключением, все существующие реализации нарушают LSP. Он даже готов отправить бесплатную копию своей книги первому читателю, который укажет ему на реальную, общедоступную реализацию IQueryable<T>, которая может принять любое выражение и не выбросить исключение. За девять лет книга так и не нашла своего обладателя:)
- Hi Mark,
I am writing a blog post that refers to your artticle. I am wondering if you have ever sent a free copy of your book to someone. Presumably not:)
- Hi Maxim
That’s right: I haven’t.
Regards
Mark Seemann
В поддержку этой точки зрения можно привести и другие аргументы. Например, ToListAsync вообще отсутствует в наборе методов расширения из коробки. Вместо этого он определен в пакетах конкретных ORM. Значит ли это, что не стоит раскрывать IQueryable<T> в публичных API? Я думаю, что ответ на этот вопрос — «зависит»
ToListAsync
Для начала разберемся с ToListAsync. Здесь все однозначно. Метод построен на попытке привести IQueryable<TSource> к IAsyncEnumerable<TSource> с помощью метода AsAsyncEnumerable:
public static async Task<List<TSource>> ToListAsync<TSource>(
[NotNull] this IQueryable<TSource> source,
CancellationToken cancellationToken = default)
{
var list = new List<TSource>();
await foreach (var element in source.AsAsyncEnumerable().WithCancellation(cancellationToken))
{
list.Add(element);
}
return list;
}
Который, в свою очередь, выбрасывает исключение, если аргумент не реализует соответствующий интерфейс:
public static IAsyncEnumerable<TSource> AsAsyncEnumerable<TSource>(
[NotNull] this IQueryable<TSource> source)
{
Check.NotNull(source, nameof(source));
if (source is IAsyncEnumerable<TSource> asyncEnumerable)
{
return asyncEnumerable;
}
throw new InvalidOperationException(CoreStrings.IQueryableNotAsync(typeof(TSource)));
}
Не очень-то заменимы реализации этого интерфейса. Да, можно обложить этот метод еще одной оберткой и в случае отсутствия интерфейса завернуть синхронный метод в Task.FromResult, но это позиция обезьянок на картинке для привлечения внимания.
Раскрывать ли IQueryable в public API?
Пишем IQueryable держим Entity Framework / NHibernate / Linq2Db в уме. Почти никогда в рамках одного проекта не используется больше одной ORM. Замена одной ORM на другую — крайне редкое и невероятно затратное мероприятие.
Связка Linq2Db и Entity Framework кажется многообещающей, но я ее не пробовал в реальных проектах, поэтому рекомендовать не могу.
Фасад из протекающей абстракции, к сожалению, никак не меняет этого факта. Поэтому вопрос в заголовке можно переформулировать как «раскрывать ли зависимости от фреймворка в API»?
Out Of Process
Я думаю, что светить IQueryable за пределами вашего приложения — скорее неудачная идея. Поэтому я настороженно отношусь к GraphQl или OData. Безусловно, существуют сценарии, когда использование этих технологий может быть оправдано. Однако, обычные rest-like API гораздо проще в разработке, поддержке и использовании. Даже если методов для фильтрации достаточно много Query Objects все еще могут неплохо справляться со своей задачей с минимальным дублированием кода.
In process
Что касается передачи IQueryable во внутренних слоях приложения, я думаю, что этот сценарий допустим, при условии, что такой объект появился в результате рефакторинга. Бывают случаи, когда условия получения данных настолько запутаны и сложны, что одними спецификациями выразить их не получается. Приходится создавать промежуточные слои à la DataQueryHandler в терминологии CQRS/Vertical Slices. В этом случае передача IQueryable — допустимо, просто потому что остальные варианты еще хуже.
Что учесть при работе с IQueryable
Мы условились, что под IQueryable всегда понимается реализация конкретного поставщика запросов. Поэтому, придется учитывать его ограничения. Будем рассматривать варианты запросов на примере вот такого простого класса пользователя:
public enum UserType: byte
{
Regular,
Vip
}
public class User : IdentityUser
{
[NotMapped]
public int Age { get; set; }
public Organization Organization { get; set; }
public UserType UserType { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string FullName => $"{FirstName} {LastName}";
}
Вызов методов
Первое от чего нужно отказаться — использование методов, написанных вами внутри выражений:
public static class Demo
{
public static bool Filter(User user) =>
user.FirstName.StartsWith("М");
public static object Works(IEnumerable<User> users) =>
users
.Where(x => Filter(x))
.ToList();
public static object ThrowsException(IQueryable<User> users) =>
users
.Where(x => Filter(x))
.ToList();
}
Если вы не понимаете, почему «одинаковые (на самом деле нет)» LINQ-выражения в одном случае работают, а в другом — нет, посмотрите или почитайте мой доклад о деревьях выражений и попробуйте написать Visitor, чтобы посмотреть разницу в выражениях. Должно стать понятно.
К сожалению, Мадс Торгерсен подтвердил мне в ходе Q&A сессии DotNext Moscow 2020, что у них нет планов по реализации декомпилятора делегатов в выражения в BCL. Кроме того, существуют объективные технические сложности для реализации такого метода. Поэтому, у ORM нет простых способов «заинлайнить» методы в выражении. В какой SQL должен транслироваться вызов функции Filter? В общем случае ответ — «да хрен его знает». Ровно поэтому поставщики запросов и выбрасывают исключения.
Вы можете подсказать Entity Framework, что такая функция есть у вас в БД. В этому случае есть смысл объявить об этом явно:
public static class DbFunctions
{
public static bool Filter(User user) =>
user.FirstName.StartsWith("М");
}
//...
users
.Where(DbFunctions.Filter)
.ToList();
Исключения, подтверждающее правило
public static object Exception (IQueryable<User> users) =>
users
.Where(x => x.FirstName.StartsWith("М"))
.ToList();
Метод StartsWith или Contains будут транслироваться, потому что трансляция этих методов в SQL достаточно проста: LIKE “М%” и LIKE “%М%”, соответственно. Важное различие заключается в том, что эти методы входят в BCL и поставщики типов знают об их существовании на этапе компиляции. Кроме этого, вы никогда не застрахованы от неожиданностей вроде:
// падает
public static object EnumException (IQueryable<User> users) =>
users
.Select(x => x.UserType.ToString())
.Distinct()
.ToList();
// а так работает
public static object EnumWorks (IQueryable<User> users) =>
users
.Select(x => ((byte)x.UserType).ToString())
.Distinct()
.ToList();
Видимо, приведение к Underlying Type позволяет проигнорировать создание Enum как такового и интерпретировать ToString как обычный Convert.
Вообще, что именно будет транслировано в плане MethodCallExpression, а что нет — это та еще кроличья нора. К тому же, нет никакой гарантии, что в новых версиях ORM правила трансляции не будут изменены, как это уже случалось много раз.
[NotMapped]
Здесь все очевидно. Если поле не имеет отражения на БД, то и транслировать такое выражение не во что:
public static object NotMappedException (IQueryable<User> users) =>
users
.Where(x => x.Age > 18)
.ToList();
Однако, при отладке не всегда удается быстро понять в чем именно дело, особенно, если IQueryable, обвешанный выражениями, как новогодняя ёлка игрушками, пришел к вам в качестве возвращаемого значения метода или свойства.
Конструктор
И на закуску еще сценарий, часто вводящих в ступор. Вот такое выражение транслируется последними версиями EF Core (может транслируется и в EF 6, напишите в комментариях, если знаете точно):
public static object ConstructorWorks (IQueryable<User> users) =>
users
.Select(x => x.FullName)
.ToList();
А вот такой уже нет:
public static object ConstructorException (IQueryable<User> users) =>
users
.Where(x => x.FullName.StartsWith("М"))
.Select(x => x.FullName)
.ToList();
Это связано с «оптимизацией» EF Core. Если Select не получается транслировать, то он может вытащить всю сущность и выполнить вызов x => x.FullName уже в памяти. «Оптимизация» может приводить к более изощенным казусам, вроде:
public static object OrganizationWorksOrNot (IQueryable<User> users) =>
users
.Select(x => new { x.Organization.FullName })
//.Where(x => x.FullName.StartsWith("М"))
.ToList();
Код выше может работать или падать с ошибкой, в зависимости от того, что еще входит в выражение. Еще хуже все становится, если где-то в Select попадают сущности целиком. В этом случае успех операции может зависеть от того включен ли Lazy Loading или указан ли Include.
Вообще Include в сочетание с Select — это либо тот еще запашок, либо полное непонимание того, что проекции не попадают в change tracker (если вы не инициализируете свойства проекции классами сущностей). Вообще, по поводу Include и Lazy Loading очень рекомендую статью «In Defense of Lazy Loading», особенно если вы хотите структурировать бизнес-логику в DDD-стиле.
Интерполяция строк
Это еще несколько палок в колеса. Я не буду подробно останавливаться на деталях, потому что они уже прекрасно описаны в статье «Помогаем Queryable Provider разобраться с интерполированными строками». Замечу лишь, что:
FullName => FirstName + " " + LastName
гораздо безопаснее, чем
FullName => $"{FirstName} {LastName}"
Выводы
Использование IQueryable во внутренних API допустимо, но сопряжено с неожиданностями. Чего больше: плюсов или минусов — каждый решает сам для себя. Делитесь в комментариях вашим опытом увлекательной отладки нетранслируемых запросов. Добавим интересные истории в статью, чтобы жить C#-программистам стало чуточку легче.
===========
Источник:
habr.com
===========
Похожие новости:
- [Программирование, Java, Совершенный код, C#, Kotlin] Лучший язык программирования
- [Совершенный код, .NET, API, C#, Микросервисы] Паттерн CQRS: теория и практика в рамках ASP.Net Core 5
- [.NET, C#] Реализация Minecraft Query протокола в .Net Core
- [.NET, C#] IQueryable порождает сильную связанность (перевод)
- [.NET] First touch of Kafka
- [Python, .NET, История IT] Языку программирования Python исполнилось 30 лет
- [Программирование, .NET, ASP, C#] Реализуем глобальную обработку исключений в ASP.NET Core приложении (перевод)
- [.NET] Как изменить формат данных JSON на Snake Case в ASP.NET Core Web API
- [.NET, PowerShell, Visual Studio, C#, F#] Работаем с notebook в VS Code с помощью расширения «dotnet interactive»
- [.NET, IT-инфраструктура, C#, DevOps] ProcInsp — веб-диспетчер задач для Windows
Теги для поиска: #_.net, #_c#, #_iqueryable, #_iqueryprovider, #_expression_trees, #_.net, #_c#
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 19:04
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Вы когда-нибудь работали с Entity Framework или другим ORM и получали NotSupportedException? Многие люди получали: InvalidOperationException: Error generated for warning 'Microsoft.EntityFrameworkCore.Query.QueryClientEvaluationWarning: The LINQ expression could not be translated and will be evaluated locally.'
Марк Симан твердо убежден, что, за одним исключением, все существующие реализации нарушают LSP. Он даже готов отправить бесплатную копию своей книги первому читателю, который укажет ему на реальную, общедоступную реализацию IQueryable<T>, которая может принять любое выражение и не выбросить исключение. За девять лет книга так и не нашла своего обладателя:)
ToListAsync Для начала разберемся с ToListAsync. Здесь все однозначно. Метод построен на попытке привести IQueryable<TSource> к IAsyncEnumerable<TSource> с помощью метода AsAsyncEnumerable: public static async Task<List<TSource>> ToListAsync<TSource>(
[NotNull] this IQueryable<TSource> source, CancellationToken cancellationToken = default) { var list = new List<TSource>(); await foreach (var element in source.AsAsyncEnumerable().WithCancellation(cancellationToken)) { list.Add(element); } return list; } Который, в свою очередь, выбрасывает исключение, если аргумент не реализует соответствующий интерфейс: public static IAsyncEnumerable<TSource> AsAsyncEnumerable<TSource>(
[NotNull] this IQueryable<TSource> source) { Check.NotNull(source, nameof(source)); if (source is IAsyncEnumerable<TSource> asyncEnumerable) { return asyncEnumerable; } throw new InvalidOperationException(CoreStrings.IQueryableNotAsync(typeof(TSource))); } Не очень-то заменимы реализации этого интерфейса. Да, можно обложить этот метод еще одной оберткой и в случае отсутствия интерфейса завернуть синхронный метод в Task.FromResult, но это позиция обезьянок на картинке для привлечения внимания. Раскрывать ли IQueryable в public API? Пишем IQueryable держим Entity Framework / NHibernate / Linq2Db в уме. Почти никогда в рамках одного проекта не используется больше одной ORM. Замена одной ORM на другую — крайне редкое и невероятно затратное мероприятие. Связка Linq2Db и Entity Framework кажется многообещающей, но я ее не пробовал в реальных проектах, поэтому рекомендовать не могу.
Out Of Process Я думаю, что светить IQueryable за пределами вашего приложения — скорее неудачная идея. Поэтому я настороженно отношусь к GraphQl или OData. Безусловно, существуют сценарии, когда использование этих технологий может быть оправдано. Однако, обычные rest-like API гораздо проще в разработке, поддержке и использовании. Даже если методов для фильтрации достаточно много Query Objects все еще могут неплохо справляться со своей задачей с минимальным дублированием кода. In process Что касается передачи IQueryable во внутренних слоях приложения, я думаю, что этот сценарий допустим, при условии, что такой объект появился в результате рефакторинга. Бывают случаи, когда условия получения данных настолько запутаны и сложны, что одними спецификациями выразить их не получается. Приходится создавать промежуточные слои à la DataQueryHandler в терминологии CQRS/Vertical Slices. В этом случае передача IQueryable — допустимо, просто потому что остальные варианты еще хуже. Что учесть при работе с IQueryable Мы условились, что под IQueryable всегда понимается реализация конкретного поставщика запросов. Поэтому, придется учитывать его ограничения. Будем рассматривать варианты запросов на примере вот такого простого класса пользователя: public enum UserType: byte
{ Regular, Vip } public class User : IdentityUser { [NotMapped] public int Age { get; set; } public Organization Organization { get; set; } public UserType UserType { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string FullName => $"{FirstName} {LastName}"; } Вызов методов Первое от чего нужно отказаться — использование методов, написанных вами внутри выражений: public static class Demo
{ public static bool Filter(User user) => user.FirstName.StartsWith("М"); public static object Works(IEnumerable<User> users) => users .Where(x => Filter(x)) .ToList(); public static object ThrowsException(IQueryable<User> users) => users .Where(x => Filter(x)) .ToList(); } Если вы не понимаете, почему «одинаковые (на самом деле нет)» LINQ-выражения в одном случае работают, а в другом — нет, посмотрите или почитайте мой доклад о деревьях выражений и попробуйте написать Visitor, чтобы посмотреть разницу в выражениях. Должно стать понятно.
Вы можете подсказать Entity Framework, что такая функция есть у вас в БД. В этому случае есть смысл объявить об этом явно: public static class DbFunctions
{ public static bool Filter(User user) => user.FirstName.StartsWith("М"); } //... users .Where(DbFunctions.Filter) .ToList(); Исключения, подтверждающее правило public static object Exception (IQueryable<User> users) =>
users .Where(x => x.FirstName.StartsWith("М")) .ToList(); Метод StartsWith или Contains будут транслироваться, потому что трансляция этих методов в SQL достаточно проста: LIKE “М%” и LIKE “%М%”, соответственно. Важное различие заключается в том, что эти методы входят в BCL и поставщики типов знают об их существовании на этапе компиляции. Кроме этого, вы никогда не застрахованы от неожиданностей вроде: // падает
public static object EnumException (IQueryable<User> users) => users .Select(x => x.UserType.ToString()) .Distinct() .ToList(); // а так работает public static object EnumWorks (IQueryable<User> users) => users .Select(x => ((byte)x.UserType).ToString()) .Distinct() .ToList(); Видимо, приведение к Underlying Type позволяет проигнорировать создание Enum как такового и интерпретировать ToString как обычный Convert. Вообще, что именно будет транслировано в плане MethodCallExpression, а что нет — это та еще кроличья нора. К тому же, нет никакой гарантии, что в новых версиях ORM правила трансляции не будут изменены, как это уже случалось много раз.
[NotMapped] Здесь все очевидно. Если поле не имеет отражения на БД, то и транслировать такое выражение не во что: public static object NotMappedException (IQueryable<User> users) =>
users .Where(x => x.Age > 18) .ToList(); Однако, при отладке не всегда удается быстро понять в чем именно дело, особенно, если IQueryable, обвешанный выражениями, как новогодняя ёлка игрушками, пришел к вам в качестве возвращаемого значения метода или свойства. Конструктор И на закуску еще сценарий, часто вводящих в ступор. Вот такое выражение транслируется последними версиями EF Core (может транслируется и в EF 6, напишите в комментариях, если знаете точно): public static object ConstructorWorks (IQueryable<User> users) =>
users .Select(x => x.FullName) .ToList(); А вот такой уже нет: public static object ConstructorException (IQueryable<User> users) =>
users .Where(x => x.FullName.StartsWith("М")) .Select(x => x.FullName) .ToList(); Это связано с «оптимизацией» EF Core. Если Select не получается транслировать, то он может вытащить всю сущность и выполнить вызов x => x.FullName уже в памяти. «Оптимизация» может приводить к более изощенным казусам, вроде: public static object OrganizationWorksOrNot (IQueryable<User> users) =>
users .Select(x => new { x.Organization.FullName }) //.Where(x => x.FullName.StartsWith("М")) .ToList(); Код выше может работать или падать с ошибкой, в зависимости от того, что еще входит в выражение. Еще хуже все становится, если где-то в Select попадают сущности целиком. В этом случае успех операции может зависеть от того включен ли Lazy Loading или указан ли Include. Вообще Include в сочетание с Select — это либо тот еще запашок, либо полное непонимание того, что проекции не попадают в change tracker (если вы не инициализируете свойства проекции классами сущностей). Вообще, по поводу Include и Lazy Loading очень рекомендую статью «In Defense of Lazy Loading», особенно если вы хотите структурировать бизнес-логику в DDD-стиле.
Интерполяция строк Это еще несколько палок в колеса. Я не буду подробно останавливаться на деталях, потому что они уже прекрасно описаны в статье «Помогаем Queryable Provider разобраться с интерполированными строками». Замечу лишь, что: FullName => FirstName + " " + LastName
гораздо безопаснее, чем FullName => $"{FirstName} {LastName}"
Выводы Использование IQueryable во внутренних API допустимо, но сопряжено с неожиданностями. Чего больше: плюсов или минусов — каждый решает сам для себя. Делитесь в комментариях вашим опытом увлекательной отладки нетранслируемых запросов. Добавим интересные истории в статью, чтобы жить C#-программистам стало чуточку легче. =========== Источник: habr.com =========== Похожие новости:
|
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 19:04
Часовой пояс: UTC + 5