
Всем привет! Меня зовут Александр Кулик, я .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 ожидают мержа месяцами.
Свои решения с первоначальной претензией на универсальность в самом начале пути не кажутся такими сложными. Порой нам, разработчикам, кажется, что пару дней или неделя — и решение будет готово. Но, по моему опыту, очень часто приходится идти на компромиссы и отказываться от многих фич — или разработка затягивается на долгие недели, чтобы продукт смог соответствовать хотя бы минимальным требованиям надежности.
Поделитесь в комментариях своим опытом. Какие варианты вы предпочитаете и почему?
Полезные ссылки: