Всем привет! Меня зовут Александр Кулик, я .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 ожидают мержа месяцами.

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

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

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

Комментарии (0)