
Всем привет! Меня зовут Александр Кулик, я .NET-разработчик из проекта шопинга в Т-Банке. Занимаюсь бэкенд-разработкой по интеграции и адаптации данных от наших партнеров и внешних сервисов, а также созданием собственных разработок в области платежных операций для B2B-сферы.
Довольно долго я решал разнообразные проблемы во время реализации систем для бизнеса или пользователей. Со временем стал замечать, что каждый раз, приступая к проектированию или разработке, я выбираю между тремя вариантами:
-
Взять хорошо формализованную и поддерживаемую библиотеку от крупного вендора.
-
Взять небольшую, но популярную библиотеку на Github, которая не выглядит так монструозно и избыточно, как первая. Вариацией может быть случай, когда моя компания наработала опыт в некоторых технических областях и поддерживает иннерсорс-библиотеки, которые зарекомендовали себя и используются в соседних командах.
-
Написать свой велосипед, в котором уж точно будет «все что нужно и как надо».
Уверен, многие разработчики сталкивались с таким выбором, а потом с выявленными проблемами — и сожалели о своем решении. Как показала моя практика, нет серебряной пули и всегда, несмотря на выбранный вариант, приходится мириться с недостатками того или иного подхода.
В статье на примере одной задачи покажу недостатки и преимущества использования нескольких библиотек.
Ставим задачу
Предположим, у нас есть небольшой интернет-портал для внутренних пользователей фирмы численностью не более тысячи человек. К нам пришел запрос от бизнеса о добавлении фильтрации и пагинации данных, предназначенных для некоторого вывода в виде таблицы с несколькими столбцами. Такой запрос часто встречается при проектировании и реализации бэкенда, который должен обслуживать пользовательский UI в CRUD-режиме.
В интерфейсе должна быть таблица или более продвинутая версия Data Grid, при взаимодействии с которой пользователь может выбирать различные условия для интересующих данных. Это может быть сумма заказа больше некоторого значения, название товара в заказе с подстрокой и так далее.
Задачи такого типа можно было бы реализовывать снова и снова, тем более логика довольно простая. Но хорошо бы разработчику иметь универсальный инструмент, с помощью которого можно решать аналогичные задачи.
Есть требования для унификации фильтрации и пагинации на стороне сервера. Формализуем процесс работы с данными и разработаем тестовое приложение, которое станет нашим полигоном для испытаний.
Первое, что спроектируем, — структуру базы данных.
Есть три основные сущности, связанные с ними объекты и атрибуты:
-
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. Попробуем реализовать необходимые команды по фильтрации на фронте. Посмотрим на созданный фильтр при таких параметрах.
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 выбрать нужную вкладку и запустить поиск по тем же значениям, что в предыдущий раз. Обратите внимание, что нумерация страниц начинается с единицы.
Вот такой запрос создало наше 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-сервис происходит вызов логики фильтрации нашей библиотеки.
Попробуем опять вызвать метод этого контроллера с теми же параметрами, что в двух других случаях.
Представим созданный запрос для соответствующей точки бэкенда:
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/
Добавить комментарий