Подходы к фильтрации данных на платформе .NET

от автора

Всем привет! Меня зовут Александр Кулик, я .NET-разработчик из проекта шопинга в Т-Банке. Занимаюсь бэкенд-разработкой по интеграции и адаптации данных от наших партнеров и внешних сервисов, а также созданием собственных разработок в области платежных операций для B2B-сферы.

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

  • Взять хорошо формализованную и поддерживаемую библиотеку от крупного вендора.

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

  • Написать свой велосипед, в котором уж точно будет «все что нужно и как надо».

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

В статье на примере одной задачи покажу недостатки и преимущества использования нескольких библиотек.

Ставим задачу

Предположим, у нас есть небольшой интернет-портал для внутренних пользователей фирмы численностью не более тысячи человек. К нам пришел запрос от бизнеса о добавлении фильтрации и пагинации данных, предназначенных для некоторого вывода в виде таблицы с несколькими столбцами. Такой запрос часто встречается при проектировании и реализации бэкенда, который должен обслуживать пользовательский UI в CRUD-режиме.

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

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

Есть требования для унификации фильтрации и пагинации на стороне сервера. Формализуем процесс работы с данными и разработаем тестовое приложение, которое станет нашим полигоном для испытаний.

Первое, что спроектируем, — структуру базы данных.

Er-диаграмма наших сущностей

Er-диаграмма наших сущностей

Есть три основные сущности, связанные с ними объекты и атрибуты:

  • Carts (корзина) — объект, содержащий данные о корзине пользователя, PromoCode — примененный промокод, UserName — имя совершающего покупку пользователя. Total — итоговая сумма заказа к оплате.

  • CartItems — содержимое корзины, Name — наименование покупки, Count — количество предметов, Price — цена за один предмет, CartId — ссылка на корзину.

  • Orders — оформленный заказ на оплату. Status — статус заказа (к оплате, оплачен, ошибка, возврат), CartId — ссылка на корзину.

Проектирование и доступ к данным будем вести с помощью Entity Framework. В тех проектах, что я встречал в последние годы, именно EF — стандарт для работы с базой данных. Остальные ORM хоть и встречались, но были исключением из общих правил.

Создадим небольшой проект, который будет включать:

  • WebAPI-контроллеры, которые принимают специальным образом оформленные запросы клиентом для фильтрации на сервере. Для наглядности используем простой JavaScript.

  • Некоторый статический UI в виде связки HTML + JavaScript, стилизованный с помощью библиотеки Getbootstrap. Он позволит наглядно и просто протестировать тот или иной подход.

  • Запуск в докере СУБД Postgress, которая будет управлять нашей демонстрационной базой.

Я подготовил данные для поиска, оформленные в теле миграции EF, которые располагаются по пути ShopApp\Migrations\%timestamp%_Init.cs. Исходники самого приложения можно найти на Github.

Определим некоторые требования к рассматриваемым движкам для поиска. Пусть они будут и не супернавороченные, но все же часто встречающиеся в повседневной практике:

  • Поиск на вхождение подстроки.

  • Умение сравнивать числа с некоторым значением.

  • Поиск в дочерних элементах основной сущности (в нашем случае — поиск по названию пункта из списка покупок в корзине).

  • Сортировка (желательно по перечислению нескольких полей).

  • Поддержка пагинации.

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

Используем надежный инструмент

Длительное время OData занимает свою нишу в качестве стандарта по доступу к данным. Подробные разборы несколько раз были на Хабре, ссылки на интересные материалы и на стандарт OData я оставил в конце статьи.

 Для .NET есть своя реализация, которую мы внедрим в наш проект. Нужно установить пакет Microsoft.AspNetCore.OData, зарегистрировать в OData-провайдере схему сущностей и внести в сервисы WebAPI приложения:

public static void RegisterOData(this IMvcBuilder mvcBuilder) {     var modelBuilder = new ODataConventionModelBuilder();     modelBuilder.EnableLowerCamelCase();     modelBuilder.EnableLowerCamelCaseForPropertiesAndEnums();     modelBuilder.EnumType<OrderStatus>();     modelBuilder.EntitySet<CartItem>("CartItems");     modelBuilder.EntitySet<Order>("Orders");     modelBuilder.EntitySet<Cart>("OdataCarts");     mvcBuilder.AddOData(options =>         options.EnableQueryFeatures(null).AddRouteComponents(             routePrefix: "odata",         model: modelBuilder.GetEdmModel())); }

Представленный код не production ready, так как не содержит никаких ограничений: 

  • по количеству возвращаемых элементов. Например, можно возвратить очень большой список и занять большой кусок памяти;

  • структуре элементов. Возможно, разработчик не захочет отдавать некоторые поля на фронт или другому сервису;

  • доступным фичам вроде сортировки или доступ к значениям свойств вложенных массивов дочерних объектов и так далее.

Создадим контроллер, который наследуется от ODataController. Определим API GET метод, размеченный EnableQueryAttribute и возвращающий IQueryable нашей сущности — списку корзин.

public class OdataCartsController : ODataController {     private readonly ShopAppContext _appContext;       public OdataCartsController(ShopAppContext appContext)     {         _appContext = appContext;     }       [EnableQuery]     public ActionResult<IQueryable<Cart>> Get()     {     return Ok(_appContext.Carts); } }

Можно запускать проект и делать запросы с помощью операторов OData. Попробуем реализовать необходимые команды по фильтрации на фронте. Посмотрим на созданный фильтр при таких параметрах.

Скриншот UI страницы с поиском через OData

Скриншот UI страницы с поиском через OData

JavaScript-приложение создало запрос для конечной точки на бэкенде, принимающей параметры OData:

Query

odata/OdataCarts?$count=true&$expand=cartItems,order&filter=contains(userName, ‘iva’) and Order/status eq ‘init’ and Total gt 50 and cartItems/Any(w:contains(w/name,’Кар’))&$orderby=Total desc&$skip=0&$top=3

Разберем запрос на составные части:

  • odata/OdataCarts — адрес нашего контроллера.

  • $count=true — флаг, указывающий, что нам нужно возвращать полное количество элементов без пагинации, но с примененными фильтрами.

  • $expand=cartItems,order — требование на раскрытие дочерних элементов. В нашем случае это заказ и список покупок.

  • contains(userName, ‘iva’) — фильтр на подстроку в колонке username.

  • Order/status eq ‘init’ — фильтр на точное равенство поля status в дочерней сущности. Статус — это Enum в коде.

  • cartItems/Any(w:contains(w/name,’Кар’)) — фильтр на поиск вхождения подстроки в списке покупок корзины. Кейс нетривиальный, так как OData приходится искать в дочерних сущностях со связями «один ко многим».

  • orderby=Total desc — оператор сортировки данных, стандарт позволяет делать сортировку по многим полям.

  • $skip=0&$top=3 — операторы для постраничного ввода.

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

Многие компоненты для Web имеют стандартную связку с OData. Например, если использовать грид от Kendo UI, он может работать по рассматриваемому стандарту.

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

Ищем альтернативы

Подход с OData импонирует потенциальной надежностью и стабильностью. Но что делать, если применение такого монстра избыточно? Ведь с большими возможностями могут потенциально прийти и некоторые неудобства точечного тюнинга.

На просторах Github и в некоторых докладах по теме .NET я встречал упоминания нескольких инструментов, подходящих под наши нужды. Разберем одну из библиотек, которая в первом приближении может попасть во все наши предъявленные требования, — Sieve. Хотя библиотека пару лет не обновлялась, все же попытаемся выжать то, что нам нужно.

Для начала сконфигурируем библиотеку. Настроим специальный процессор для конфигурации и разметки сущности корзины:

public class ApplicationSieveProcessor : SieveProcessor {     public ApplicationSieveProcessor(         IOptions<SieveOptions> options,         ISieveCustomSortMethods customSortMethods,         ISieveCustomFilterMethods customFilterMethods)         : base(options, customSortMethods, customFilterMethods)     {     }       protected override SievePropertyMapper MapProperties(SievePropertyMapper mapper)     {         mapper.Property<Cart>(p => p.Id)         .CanFilter();           mapper.Property<Cart>(p => p.Total)         .CanFilter()         .CanSort();           mapper.Property<Cart>(p => p.PromoCode)         .CanFilter()         .CanSort();           mapper.Property<Cart>(p => p.UserName)         .CanFilter()         .CanSort();           mapper.Property<Cart>(p => p.Order!.Status)         .CanFilter();       return mapper; } } 

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

Интерфейс для методов сортировки:

public class SieveCustomSortMethods : ISieveCustomSortMethods {     }

Интерфейс для методов фильтрации:

public class SieveCustomFilterMethods : ISieveCustomFilterMethods {     public IQueryable<Cart> CartItemName(IQueryable<Cart> source, string op, string[] values)     {         var result = source.Where(p => p.CartItems.Any(i => i.Name.Contains(values.Single())));           return result; } } 

Мы определили новый метод предиката для вложенных элементов списка покупок в корзине, так как рассматриваемая библиотека не имеет подобной функции «из коробки».

Теперь реализованные интерфейсы зарегистрируем в DI и конфигурации. Подробнее о параметрах конфигурации можно почитать на стартовой странице проекта в Github:

builder.Services.AddScoped<ISieveProcessor, ApplicationSieveProcessor>(); builder.Services.AddScoped<ISieveCustomSortMethods, SieveCustomSortMethods>(); builder.Services.AddScoped<ISieveCustomFilterMethods, SieveCustomFilterMethods>();   builder.Services.Configure<SieveOptions>(builder.Configuration.GetSection("Sieve"));

В завершение реализуем контроллер для принятия запросов от клиентов:

public class SieveCartsController : Controller {     private readonly ISieveProcessor _processor;       private readonly ShopAppContext _appContext;       public SieveCartsController(ISieveProcessor processor, ShopAppContext appContext)     {         _processor = processor;         _appContext = appContext;     }       [HttpGet("sieveCarts")]     public async Task<JsonResult> GetSieveCarts(SieveModel sieveModel)     {         var result = _appContext.Carts         .Include(i=>i.Order)         .Include(i=>i.CartItems)         .AsNoTracking();           var countQuery = _processor.Apply(sieveModel, result, applyPagination: false);           var count = await countQuery.CountAsync();           result = _processor.Apply(sieveModel, result);           return Json(new SieveResult<Cart>()         {         Items = result.ToArray(),         Count = count         },new JsonSerializerOptions         {         ReferenceHandler = ReferenceHandler.IgnoreCycles,         WriteIndented = true,         Converters =         {             new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)         },         PropertyNamingPolicy = JsonNamingPolicy.CamelCase     }); } } 

Из кода можно выделить несколько моментов:

  • Библиотека позволяет определять формат вывода на свое усмотрение. Первоначально она только фильтрует данные, а каким образом и где они будут использоваться — решает разработчик.

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

  • В примере мы должны заранее определить все Include дочерних сущностей для формирования графа объектов.

  • В примере мы возвращаем сами сущности в ответе, но можно и мапить через промежуточные DTO.

После вводной теоретической части можно через UI выбрать нужную вкладку и запустить поиск по тем же значениям, что в предыдущий раз. Обратите внимание, что нумерация страниц начинается с единицы.

Скриншот UI страницы с поиском через Sieve

Скриншот UI страницы с поиском через Sieve
Вот такой запрос создало наше JavaScript-приложение для конечной точки на бэкенде, принимающей параметры Sieve:

sieveCarts?filters=userName@=ivan,order.status==Init,total>=50,CartItemName@=Каран&page=1&pageSize=3&sorts=-total

Части структуры запроса:

  • sieveCarts — адрес нашего контроллера.

  • userName@=ivan — фильтр на подстроку в колонке username.

  • order.status==Init — фильтр на точное равенство поля status в дочерней сущности. В отличие от OData, для энамов сравнение чувствительно к регистру.

  • total>=50 — фильтр сравнения для чисел с каким-либо значением.

  • CartItemName@=Каран — фильтр на поиск вхождения подстроки в списке покупок корзины. В Sieve нет функции для встроенного поиска по вложенным параметрам массива (Cart->CartItems->Name). Он позволяет определять свои функции фильтрации (это было показано в примере кода выше) и обращаться к ним по имени.

  • sorts=-total — параметры сортировки, где — перед названием колонки указывает на обратный порядок, descending.

  • page=1&pageSize=3 — операторы для постраничного вывода. Нумерация начинается с единицы.

Вывод: библиотека Sieve — лаконичное и настраиваемое средство для взаимодействия с фронтом. Когда достаточно стандартных средств и возможности расширения, ее можно взять на вооружение в один из проектов, если не смущает тишина в поддержке последние годы. Но всегда можно сделать форк и поправить найденные баги уже в своем репозитории.

Пишем свой велосипед

Наверное, в жизни каждого разработчика были моменты, когда, столкнувшись с серьезным багом, хотелось выкинуть весь принесенный извне код и реализовать «теперь точно как следует, без багов и вот этого всего».

В моей практике была задача мигрировать проект, написанный для .NET Framework 4.7, на .NET Core 2.1, и в этом проекте активно использовалась OData. Тогда реализации под .NET Core не было или она находилась в зачаточном состоянии, и использовать ее для прода было бы не совсем дальновидно. 

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

Архитектурно библиотека работает на вольной интерпретации шаблона спецификации: когда на клиенте мы просто описываем некий структурированный псевдокод, а наш интерпретатор переводит заранее определенные операторы в соответствующие спецификации. Они основаны на Expression с последующим построением IQueryable-запросов для EF и выполнением их через DbContext. 

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

Прежде чем подключать ее в наш проект, следует сказать, что в библиотеке есть несколько особенностей:

  • Она фильтрует сущности, а возвращает строго DTO. Это сделано специально, чтобы ни у кого не возникло желания отдавать на фронт Entity.

  • Для маппинга Entity->dto используется AutoMapper.

  • Сортировка возможна только по одному полю. Это особенность реализации, но несложно расширить на несколько полей.

  • Архитектурно эта библиотека позволяет объединять логические операторы в группы, математически это можно представить в виде дополнительных скобок: (Expression 1 and Expression 2) or (Expression 3 and Expression 4).

  • Как и в Sieve, нумерация страниц начинается с единицы.

Начнем с определения топологии наших сущностей:

public static IServiceCollection AddRaCruds(this IServiceCollection serviceCollection)    {    var entityTopology = new EntityTopology();    var configurator = new EntityTopologyConfigurator(entityTopology);        configurator.AddFilter<Cart, CartDto>()        .ForDbContext<ShopAppContext>()        .WithParameters(p =>        {                p.IncludeProperties = [nameof(Cart.Order), nameof(Cart.CartItems)];        })            .AddCustomSpecification<CartItemNameSpecification>("cartItemName")        .CompleteFilter()        .Complete();          serviceCollection.RegisterEntities(entityTopology);      return serviceCollection;    } 

Код стандартный — определяет DbContext, связи сущности с другими объектами  и DTO на основе структуры сущности, указывает на вложенные сущности для вывода значений, а еще добавляет кастомную спецификацию CartItemNameSpecification. Как и в случае с прошлой библиотекой, эта спецификация позволяет искать во вложенных пунктах покупки корзины:

public class CartItemNameSpecification : ISpecification<Cart> { private readonly string _value;   private readonly string _parameter;   public CartItemNameSpecification(string parameter, string value) {     _value = value;     _parameter = parameter; }   public Expression<Func<Cart, bool>> ToExpression() {         Expression<Func<Cart, bool>> cartItemNameContains = p => p.CartItems.Any(i => i.Name.Contains(_value));       return cartItemNameContains; } }

Сами DTO имеют тривиальную структуру, их можно посмотреть в исходном коде. 

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

public class RaCrudsController : Controller {     [HttpGet("raCarts")]     [ProducesResponseType(typeof(PagingResult<CartDto>), StatusCodes.Status200OK)] public async Task<ActionResult> GetRaCarts([ModelBinder(typeof(FilterParametersModelBinder))] FilterParameters filterParameters, [FromServices] EntityFilterBase<CartDto> entityFetcher, CancellationToken cancellationToken) {     var pagingResult = await entityFetcher.FilterAsync(filterParameters, cancellationToken);       return Json(pagingResult, new JsonSerializerOptions     {           WriteIndented = true,         Converters =         {             new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)         },         PropertyNamingPolicy = JsonNamingPolicy.CamelCase     }); } }

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

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

Скриншот UI страницы с поиском через RA-библиотеку

Скриншот UI страницы с поиском через RA-библиотеку

Представим созданный запрос для соответствующей точки бэкенда:

raCarts?statements=[ {     "spec": "contains",     "name": "userName",     "value": "ivan" }, {     "spec": "greaterThanOrEqual",     "op": "and",     "name": "total",     "value": "50" }, {     "spec": "equals",     "op": "and",     "name": "order.status",     "value": "0" }, {     "spec": "cartItemName",     "op": "and",     "name": "items",     "value": "Каран" } ]page=1&pageSize=3&orderBy=total&orderKind=desc 

У нас получился довольно многословный запрос. Разберемся, какие параметры за что отвечают:

  • statements — параметры выражений фильтрации, где spec — название спецификации, стандартной или определенной пользователем как cartItemName, op — логический оператор, может быть or или and. name — имя параметра, в нашем случае свойства в Entity, по которому нужно произвести фильтрацию. value — значение для фильтрации.

  • orderBy — содержит название колонки для сортировки.

  • orderKind — тип сортировки, asc или desc.

  • page=1&pageSize=3 — параметры пагинации.

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

Разберемся, как с помощью библиотеки сделать вложенные выражения. Для этого есть специальный параметр child — с его помощью можно объединять запросы в логические цепочки. Пример такого объединения:

{ "statements": [     {         "child": [             {                     "spec": "cartItemName",                     "op": "and",                 "name": "items",                     "value": "Атлас"             }         ]     },     {         "child": [             {                     "spec": "cartItemName",                     "op": "and",                     "name": "items",                     "value": "Кар"             },             {                     "spec": "equals",                     "op": "and",                     "name": "promoCode",                     "value": "Last"             }         ],         "op": "or"     } ], "pageSize": 1, "currentPage": 4, "orderBy": "UserName", "orderKind": "desc" }

Мы задали два условия через логическое «или»: первое — наличие названия пункта в списке покупок, второе — сочетание двух условий с помощью логической операции «и»: наличие названия в списке покупок и использование промокода. 

Мы можем иметь сколь угодно большую вложенность, ограниченную только физическим размером нашего стека.

Можно протестировать запрос или создать и исполнить свои. Нужно запустить в докере (docker-compose up) приложение или перейти по ссылке в локально развернутое приложение. Там развернут полноценный UI от Swagger, который может упростить взаимодействие с сервером. Целевой метод для тестирования — GET /raCarts.

Заключение

Рутинные задачи, к которым можно причислить и фильтрацию по параметрам, уже решены многими и не единожды, даже в различных объемах.

Увы, но даже тут нет серебряной пули.

Большие и стандартизованные библиотеки с самого начала предложат богатую функциональность, на первый взгляд способную удовлетворить первоначальные потребности. Но иногда они могут подвести, как это было у меня с миграцией с .NET Framework на .NET Core.

Сторонние библиотеки средней руки с открытым исходным кодом, написанные энтузиастами и имеющие небольшое комьюнити, тоже иногда забрасываются авторами. Тогда необходимые Pull Request ожидают мержа месяцами.

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

Поделитесь в комментариях своим опытом. Какие варианты вы предпочитаете и почему?

Полезные ссылки:


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *