Деревья выражений на практике: динамическая фильтрация в C# с использованием Asp.NET Core

от автора

В нашем предыдущем уроке мы обсудили ключевые моменты деревьев выражений, их примеры использования и ограничения. Любая тема без практического примера, особенно если она связана с программированием, не имеет большого смысла. В этой статье мы рассмотрим вторую часть деревьев выражений в C# и покажем реальную мощь их использования на практике.

Что мы собираемся построить?

Наша основная цель — создать веб-API на Asp.NET Core с динамической функцией фильтрации, построенной с использованием минимального API, EF Core и, конечно же, деревьев выражений.

Мы планируем построить фильтрацию для базы данных продуктов и использовать деревья выражений, чтобы показать одну из реальных возможностей деревьев выражений при построении сложных и динамических запросов. Вот финальный пример с несколькими динамическими аргументами фильтрации:

expression trees in C#

expression trees in C#

Для более полных примеров обращайтесь к репозиторию на GitHub.

Начало работы

Сначала откройте Visual Studio и выберите шаблон веб-API Asp.NET Core с следующей конфигурацией:

asp.net core web api termplate

asp.net core web api termplate

Мы используем .NET 8.0, но сама тема не зависит от версии .NET. Вы даже можете использовать классический .NET Framework для работы с деревьями выражений. Название проекта — “ExpressionTreesInPractice”.

Вот сгенерированный шаблон из Visual Studio:

project initial state

project initial state

Чтобы иметь простое хранилище, мы будем использовать InMemory Ef Core. Вы можете использовать любое другое подхранилище EF Core.

Теперь перейдите в Tool->Nuget Package Manager->Package Manager Console и введите следующую команду:

install-package microsoft.entityframeworkcore.inmemory

Теперь создадим нашу реализацию DbContext. Создайте папку под названием ‘Database’ и добавьте в нее класс ProductDbContext со следующим содержимым:

using ExpressionTreesInPractice.Models; using Microsoft.EntityFrameworkCore;  namespace ExpressionTreesInPractice.Database {     public class ProductDbContext : DbContext     {         public DbSet<Product> Products { get; set; }         public ProductDbContext(DbContextOptions<ProductDbContext> options) : base(options) { }         protected override void OnModelCreating(ModelBuilder modelBuilder)         {             modelBuilder.Entity<Product>().HasData(new List<Product>             {                 new Product(){ Id = 1, Category = "TV", IsActive = true, Name = "LG", Price = 500},                 new Product(){ Id = 2, Category = "Mobile", IsActive = false, Name = "Iphone", Price = 4500},                 new Product(){ Id = 3, Category = "TV", IsActive = true, Name = "Samsung", Price = 2500}             });             base.OnModelCreating(modelBuilder);         }     } } 

Мы просто добавили базовые данные для инициализации при запуске приложения, и именно для этого нам нужно переопределить OnModelCreating из DbContext. Отличный пример использования паттерна «Шаблонный метод», не правда ли?

Нам нужна наша модель сущности под названием Product, вы можете создать папку ‘Models’ и добавить туда класс Product со следующим содержимым:

namespace ExpressionTreesInPractice.Models {     public class Product     {         public int Id { get; set; }         public string Category { get; set; }         public decimal Price { get; set; }         public bool IsActive { get; set; }         public string Name { get; set; }     } } 

Теперь пришло время зарегистрировать нашу реализацию DbContext в файле Program.cs:

builder.Services.AddDbContext<ProductDbContext>(x => x.UseInMemoryDatabase("ProductDb"));

Кстати, в Program.cs есть множество ненужных кодовых фрагментов, которые нужно удалить. После всей очистки наш код должен выглядеть так:

using ExpressionTreesInPractice.Database; using Microsoft.EntityFrameworkCore;  var builder = WebApplication.CreateBuilder(args);  // Добавляем сервисы в контейнер. builder.Services.AddDbContext<ProductDbContext>(x => x.UseInMemoryDatabase("ProductDb"));  var app = builder.Build();  // Настраиваем конвейер HTTP-запросов. app.UseHttpsRedirection();  app.Run(); 

Мы не хотим использовать контроллеры, так как они тяжеловесны и вызывают дополнительные проблемы. Поэтому мы выбираем минимальный API. Если вы не знакомы с минимальными API, пожалуйста, посмотрите наш видеоурок, чтобы узнать больше.

После того, как вы разберетесь, откройте Program.cs и добавьте следующий код:

app.MapGet("/products", async ([FromBody] ProductSearchCriteria productSearch, ProductDbContext dbContext) => { }

Приведенный выше код определяет маршрут в минимальном API ASP.NET Core и создает конечную точку для HTTP-запроса GET на путь /products. Метод использует асинхронное программирование для обработки потенциально долгих операций без блокировки основного потока приложения.

ProductSearchCriteria — это параметр, переданный в метод, который содержит критерии для фильтрации продуктов. Он помечен атрибутом [FromBody], что означает, что тело запроса будет привязано к этому параметру. Обычно GET-запросы не используют тело запроса, но в этом случае оно разрешено, если нужно передать сложный объект.

ProductDbContext — это контекст базы данных, который представляет сессию с базой данных. Он внедряется в метод, позволяя приложению выполнять такие операции, как запрос продуктов на основе критериев поиска.

Причина использования ProductSearchCriteria вместо Product заключается в том, что запрос должен быть динамическим. В этом случае пользователь может предоставить некоторые атрибуты продукта, но не все. Так как свойства Product не допускают значения null, пользователь был бы вынужден указывать все свойства, даже если не хочет фильтровать по всем.

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

Вот как выглядит наш класс ProductSearchCriteria в папке ‘Models’.

namespace ExpressionTreesInPractice.Models {     public record PriceRange(decimal? Min, decimal? Max);     public record Category(string Name);     public record ProductName(string Name);     public class ProductSearchCriteria     {         public bool? IsActive { get; set; }         public PriceRange? Price { get; set; }         public Category[]? Categories { get; set; }         public ProductName[]? Names { get; set; }     } }

Теперь давайте сосредоточимся на реализации минимального API. Обратите внимание, что целью данного урока не является показ лучших практик или написание чистого кода. Цель — продемонстрировать деревья выражений на практике, и после освоения материала вы легко сможете рефакторить код.

Вот первый фрагмент кода внутри функции MapGet:

await dbContext.Database.EnsureCreatedAsync();  ParameterExpression parameterExp = Expression.Parameter(typeof(Product), "x");  Expression predicate = Expression.Constant(true);//x=>True && x.IsActive=true/false   if (productSearch.IsActive.HasValue)  {      MemberExpression memberExp = Expression.Property(parameterExp, nameof(Product.IsActive));       ConstantExpression constantExp = Expression.Constant(productSearch.IsActive.Value);       BinaryExpression binaryExp = Expression.Equal(memberExp, constantExp);       predicate = Expression.AndAlso(predicate, binaryExp);  }  var lambdaExp = Expression.Lambda<Func<Product, bool>>(predicate, parameterExp); var data = await dbContext.Products.Where(lambdaExp).ToListAsync(); return Results.Ok(data);

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

await dbContext.Database.EnsureCreatedAsync();

Этот оператор асинхронно проверяет, создана ли база данных. Если она не существует, то будет создана. Это обычно используется в средах разработки или тестирования для обеспечения наличия схемы базы данных.

ParameterExpression parameterExp = Expression.Parameter(typeof(Product), "x");

Здесь создается параметр выражения, представляющий экземпляр класса Product. Это будет действовать как входной параметр (x) в дереве выражений, аналогично тому, как вы определяете лямбда-выражение вида x => ....

Expression predicate = Expression.Constant(true);

Изначально предикат создается как константное логическое выражение со значением true. Это полезно для поэтапного построения динамического предиката, так как вы можете использовать его как базу для добавления других условий (например, true AND другие условия). Это служит отправной точкой для объединения дополнительных выражений.

if (productSearch.IsActive.HasValue)

Этот блок if проверяет, что свойство IsActive в productSearch не равно null, что означает, что пользователь задал фильтр для активности продукта.

Внутри блока if:

MemberExpression memberExp = Expression.Property(parameterExp, nameof(Product.IsActive));

Это создает MemberExpression, который получает доступ к свойству IsActive экземпляра Product, представленному parameterExp (x.IsActive). По сути, это представляет выражение x => x.IsActive.

ConstantExpression constantExp = Expression.Constant(productSearch.IsActive.Value);

Создается ConstantExpression со значением productSearch.IsActive. Это значение, с которым будет производиться сравнение (true или false).

BinaryExpression binaryExp = Expression.Equal(memberExp, constantExp);

Создается BinaryExpression, который сравнивает свойство IsActive с заданным значением. Это представляет выражение x.IsActive == productSearch.IsActive.

predicate = Expression.AndAlso(predicate, binaryExp);

Текущий предикат (который начался с true) комбинируется с новым условием (x.IsActive == productSearch.IsActive) с помощью логической операции AND. Это приводит к выражению, которое можно использовать для фильтрации продуктов по их статусу активности.

В целом, приведенный выше код динамически строит дерево выражений, которое в конечном итоге будет использоваться для фильтрации продуктов в зависимости от того, активны они или нет. Изначальный предикат (true) позволяет легко добавлять дополнительные условия без специальной обработки для первого условия. Если productSearch.IsActive указано, добавляется условие, проверяющее, соответствует ли свойство IsActive продукта заданному значению (true или false).

Затем переменной lambdaExp присваивается лямбда-выражение, которое представляет функцию фильтрации для сущностей Product. Это лямбда-выражение создается из предиката, построенного ранее, который может содержать такие условия, как проверка активности продукта (IsActive). Вызов Expression.Lambda<Func<Product, bool>> генерирует Func<Product, bool>, то есть функцию, которая принимает продукт в качестве входного параметра и возвращает логическое значение, определяющее, соответствует ли продукт критериям фильтрации.

Далее это лямбда-выражение передается в метод Where DbSet Products в dbContext. Метод Where применяет этот фильтр к записям о продуктах в базе данных. Он создает запрос, который извлекает только те продукты, которые соответствуют условиям, определенным в лямбда-выражении.

Наконец, метод ToListAsync() асинхронно выполняет запрос и извлекает соответствующие продукты в виде списка. Этот список затем возвращается в виде HTTP-ответа 200 OK с помощью Results.Ok(data). Результатом является отфильтрованный список продуктов, отправленный обратно в качестве ответа API.

Для тестирования просто запустите приложение и отправьте следующий GET-запрос с телом через Postman:

expression tree with isActive in C#

expression tree with isActive in C#

Этот подход полезен при динамическом построении запросов, так как позволяет добавлять условия в зависимости от предоставленных фильтров.

Вот как будет выглядеть ваше выражение после компиляции дерева выражений:

{x => (True AndAlso (x.IsActive == True))}

Пока что мы реализовали самое простое свойство, которое имеет два значения: true или false. Но как насчет других свойств, таких как categories, names, price и т. д.? Пользователи могут выбирать продукт не только по признаку активности, но, например, по его категории. Мы позволяем пользователям указывать несколько категорий одновременно, поэтому реализовали это как массив в нашем классе ProductSearchCategory.

csharpCopy codeif (productSearch.Categories is not null && productSearch.Categories.Any()) {     //x.Category     MemberExpression memberExp = Expression.Property(parameterExp, nameof(Product.Category));     Expression orExpression = Expression.Constant(false);     foreach (var category in productSearch.Categories)     {         var constExp = Expression.Constant(category.Name);         BinaryExpression binaryExp = Expression.Equal(memberExp, constExp);         orExpression = Expression.OrElse(orExpression, binaryExp);     }     predicate = Expression.AndAlso(predicate, orExpression); } 

Код добавляет динамическую фильтрацию по категориям продуктов. Сначала проверяется, не равны ли Categories в объекте productSearch null и содержат ли они элементы. Если да, то выполняется построение динамического выражения для фильтрации продуктов по категориям.

Начинается с доступа к свойству Category класса Product через выражение. Это выражение представляет x => x.Category, где x — это экземпляр Product.

Изначальное выражение orExpression установлено в false. Это будет базой для динамического сравнения категорий. Используется цикл для перебора каждой категории в productSearch.Categories. Для каждой категории создается константное выражение с именем категории и бинарное выражение, которое проверяет, равна ли категория продукта указанной категории.

Затем бинарные выражения объединяются с помощью OrElse, что означает, что если продукт соответствует любой из предоставленных категорий, условие становится истинным. После обработки всех категорий, объединенное выражение orExpression добавляется к основному предикату с помощью AndAlso. Это означает, что основной предикат теперь будет проверять как предыдущие условия, так и то, соответствует ли категория продукта одной из категорий в критериях поиска.

Этот подход позволяет динамически фильтровать продукты по нескольким категориям и интегрирует фильтрацию категорий в существующий предикат.

В конце приведенного выше кода вы получите LINQ-выражение, которое представляет собой лямбда-функцию, используемую для фильтрации продуктов на основе динамических условий. Это выражение может быть преобразовано в предикат для использования в LINQ-запросе, который можно применить к вашему ProductDbContext или любому IQueryable<Product>.

LINQ-выражение в данном случае будет комбинацией логических операций (AND и OR), которые фильтруют продукты. В псевдокоде это будет выглядеть так:

products.Where(x => (x.Category == "Category1" || x.Category == "Category2" || ...) && другие условия)

Если пользователь указывает и isActive, и категории, то мы получим следующее лямбда-выражение:

{x => ((True AndAlso (x.IsActive == True)) AndAlso (((False OrElse (x.Category == "TV"))))}

Для тестирования просто запустите приложение и отправьте следующий GET-запрос с телом через Postman:

expression trees in C# for categories

expression trees in C# for categories

Мы используем тот же подход для поля Names. Вот наш фрагмент кода:

if (productSearch.Names is not null && productSearch.Names.Any()) {     //x.Name     MemberExpression memberExp = Expression.Property(parameterExp, nameof(Product.Name));     Expression orExpression = Expression.Constant(false);     foreach (var productName in productSearch.Names)     {         var constExp = Expression.Constant(productName.Name);         BinaryExpression binaryExp = Expression.Equal(memberExp, constExp);         orExpression = Expression.OrElse(orExpression, binaryExp);     }     predicate = Expression.AndAlso(predicate, orExpression); } 

Этот фрагмент кода динамически создает условие фильтрации для имен продуктов, используя деревья выражений. Сначала он проверяет, что свойство productSearch.Names не равно null и содержит элементы. Если имена продуктов для фильтрации присутствуют, то продолжается построение выражения для сравнения свойства Name сущности Product.

Выражение memberExp ссылается на свойство Name продукта (аналогично x.Name в лямбда-выражении). Изначально создается выражение orExpression, которое устанавливается как false. Это выражение будет обновляться в цикле, чтобы накопить сравнения для каждого имени в коллекции productSearch.Names.

Внутри цикла для каждого имени в коллекции productSearch.Names создается константное выражение с именем продукта. Затем формируется бинарное выражение, которое проверяет, совпадает ли имя продукта с текущим именем из поиска. Цикл накапливает серию условий OR, используя Expression.OrElse, что создает логическую операцию OR между текущим orExpression и новым сравнением.

После завершения цикла итоговое выражение orExpression представляет собой цепочку условий OR, где имя продукта должно совпадать с одним из имен в коллекции productSearch.Names. Это выражение объединяется с существующим предикатом с помощью Expression.AndAlso, гарантируя, что фильтр по имени применяется наряду с любыми другими условиями, ранее определенными в предикате.

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

Если пользователь указывает только имена(names) в теле запроса, мы получим примерно следующее лямбда-выражение:

{x => (True AndAlso (((False OrElse (x.Name == "LG")) OrElse (x.Name == "LG2")) OrElse (x.Name == "Samsung")))}

Если мы получим все параметры фильтрации, такие как isActive, категории(categories) и имена(names) из тела запроса, то в итоге мы получим следующее лямбда-выражение:

{x => (((True AndAlso (x.IsActive == True)) AndAlso (((False OrElse (x.Category == "TV")) OrElse (x.Category == "Some Other")) OrElse (x.Category == "Mobile"))) AndAlso (((False OrElse (x.Name == "LG")) OrElse (x.Name == "LG2")) OrElse (x.Name == "Samsung")))}

Вот как это будет выглядеть при запуске приложения и отправке запроса:

expression trees in C# with names

expression trees in C# with names

Последним аргументом для нашей динамической фильтрации является цена(price). Это сложный объект, состоящий из минимального(min) и максимального(max) значений. Пользователь должен иметь возможность указать любое из них, оба или ни одного. Именно поэтому мы сделали эти параметры nullable.

Вот как выглядит наша реализация кода:

if (productSearch.Price is not null) {     //x.Price 400     MemberExpression memberExp = Expression.Property(parameterExp, nameof(Product.Price));     //x.Price >= min     if (productSearch.Price.Min is not null)     {         var constExp = Expression.Constant(productSearch.Price.Min);         var binaryExp = Expression.GreaterThanOrEqual(memberExp, constExp);         predicate = Expression.AndAlso(predicate, binaryExp);     }     //(x.Price >= min && x.Price <= max)     if (productSearch.Price.Max is not null)     {         var constExp = Expression.Constant(productSearch.Price.Max);         var binaryExp = Expression.LessThanOrEqual(memberExp, constExp);         predicate = Expression.AndAlso(predicate, binaryExp);     } } 

Этот код динамически создает предикат для фильтрации продуктов по диапазону цен с использованием деревьев выражений. Он начинает с проверки того, что объект productSearch.Price не равен null, что указывает на применение фильтра по цене.

Выражение memberExp создается для представления свойства Price продукта (x.Price). Это выражение используется для сравнения цены продукта с минимальными и максимальными значениями, указанными в объекте productSearch.Price.

Если указана минимальная цена (productSearch.Price.Min не равен null), создается выражение, проверяющее, больше ли цена продукта или равна минимальному значению. Это условие добавляется в общий предикат с использованием Expression.AndAlso, что означает, что продукт должен удовлетворять этому условию, чтобы быть включенным в результаты.

Аналогично, если указана максимальная цена (productSearch.Price.Max не равен null), создается еще одно выражение, проверяющее, меньше ли цена продукта или равна максимальному значению. Это условие также добавляется к существующему предикату с использованием Expression.AndAlso, гарантируя, что применяются оба условия — и минимальная, и максимальная цена.

Вкратце, код строит предикат, который фильтрует продукты по указанному диапазону цен, гарантируя, что продукты имеют цену, большую или равную минимальной (если указана), и меньшую или равную максимальной (если указана).

Если пользователь указывает только цену в теле запроса, мы получим примерно следующее лямбда-выражение:

{x => ((True AndAlso (x.Price >= 400)) AndAlso (x.Price <= 5000))}

Если мы получим все параметры фильтрации, такие как isActive, categories, names и price из тела запроса, то в итоге мы получим следующее лямбда-выражение:

{x => (((((True AndAlso (x.IsActive == True)) AndAlso (((False OrElse (x.Category == "TV")) OrElse (x.Category == "Some Other")) OrElse (x.Category == "Mobile"))) AndAlso (((False OrElse (x.Name == "LG")) OrElse (x.Name == "LG2")) OrElse (x.Name == "Samsung"))) AndAlso (x.Price >= 400)) AndAlso (x.Price <= 5000))}

Вот как это будет выглядеть при запуске приложения и отправке запроса:

expression treees in C# with price

expression treees in C# with price

Кстати, хотите увидеть всё на практике с подробным видео? Тогда вот моё видео, где я создаю всё с нуля и даю простое и понятное объяснение каждого шага

То же самый контент, где я всё объясняю на английском языкe.Кстати, эти видео — не переводы. Всё записано с нуля для каждого видео.

Изящное завершение

Эта статья служит практическим продолжением предыдущего урока по деревьям выражений в C#, с акцентом на их реальное использование в рамках веб-API на ASP.NET Core. Она исследует создание функционала динамической фильтрации с использованием минимального API, Entity Framework Core (EF Core) и деревьев выражений.

Проект включает создание базы данных продуктов с возможностью динамической фильтрации по таким атрибутам продукта, как IsActive, Category, Name и Price. В статье подчеркивается использование деревьев выражений для построения гибких и динамичных запросов без жесткого кодирования конкретных фильтров.

Настройка начинается с использования веб-API ASP.NET Core с базой данных в памяти для хранения, хотя можно использовать и другие базы данных, поддерживаемые EF Core. В статье делается акцент на использование минимального API вместо традиционных контроллеров для упрощения и повышения производительности, а также предоставляются инструкции для выполнения необходимых шагов, включая настройку контекста базы данных (DbContext) и инициализацию данных.

Одной из основных функций, продемонстрированных в статье, является то, как деревья выражений используются для динамического построения предикатов. Например, при фильтрации по свойству IsActive система проверяет, указал ли пользователь этот фильтр, и затем динамически создает условие, которое сравнивает статус активности продукта с предоставленным значением. Процесс расширяется, чтобы включить динамическую фильтрацию по другим свойствам, таким как Category, Name и Price, каждое из которых позволяет гибко настраивать критерии для запросов.

Используя деревья выражений, статья показывает, как можно строить сложные и гибкие запросы без необходимости писать множество методов с жестко закодированными запросами. Пример фильтрации продуктов по Name и Category демонстрирует, как логические условия OR могут быть динамически объединены в зависимости от ввода пользователя, что приводит к созданию лаконичной и многократно используемой логики запросов.

Кроме того, фильтрация по цене обрабатывается путем проверки как минимальных, так и максимальных значений и динамической настройки предиката, чтобы включить только те продукты, которые находятся в указанном диапазоне цен.

В заключение, эта статья демонстрирует мощь деревьев выражений в создании динамичных и гибких запросов в приложениях на C#. В ней приводятся практические примеры кода, использующие деревья выражений для построения запросов в веб-API на ASP.NET Core, предлагая практический способ управления сложными сценариями реального мира, такими как фильтрация баз данных продуктов на основе различного пользовательского ввода.

Хотите углубиться?

Я регулярно делюсь своим опытом на уровне senior на моих YouTube-каналах TuralSuleymaniTech на английском и TuralSuleymaniTechRu на русском, где разбираю сложные темы, такие как .NET, микросервисы, Apache Kafka, Javascript, проектирование программного обеспечения, Node.js и многое другое, делая их простыми для понимания. Присоединяйтесь к нам и повышайте свои навыки!


ссылка на оригинал статьи https://habr.com/ru/articles/848446/