Делаем фильтры «как в экселе» на ASP.NET Core

от автора

«Сделайте нам фильтры «как в экселе», — довольно популярный запрос на разработку. К сожалению, реализация запроса "слегка" длинее, чем его лаконичная постановка. Если вдруг вы никогда не пользовались этими фильтрами, то вот пример. Основная фишка в том, что в строчке с названиям колонок появляются выпадающие списки со значениями из выбранного диапазона. Например в колонках А и B — 4000 строк и 3999 значений (первую строчку занимают названия колонок). Таким образом, в соответсвтующих выпадающих списках будет по 3999 значений. В колонке C — 220 строк и 219 значений в выпадающем списке соответственно.

ToDropdownOption

В .NET испокон веков существует прекрасный интерфейс IQuerable<T>, предоставляющий доступ к разнообразным источникам данных. Его и будем использовать. Определим метод-расширения ToDropdownOption поверх интерфейса.

public static IQueryable<DropdownOption<TValue>> ToDropdownOption<TQueryable, TValue, TDropdownOption>(    this IQueryable<TQueryable> q,    Expression<Func<TQueryable, string>> labelExpression,    Expression<Func<TQueryable, TValue>> valueExpression)    where TDropdownOption: DropdownOption<TValue> {    // Вызываем конструктор по умолчанию     // В Cache<TValue, TDropdownOption>.Constructor кешируется reflection    var newExpression = Expression.New(Cache<TValue, TDropdownOption>.Constructor);     // Подробнее об этой особой уличной магии здесь    // https://habr.com/ru/company/jugru/blog/423891/#predicate-builder    var e2Rebind = Rebind(valueExpression, labelExpression);    var e1ExpressionBind = Expression.Bind(        Cache<TValue, TDropdownOption>.LabelPropertyInfo, labelExpression.Body);    var e2ExpressionBind = Expression.Bind(        Cache<TValue, TDropdownOption>.ValuePropertyInfo, e2Rebind.Body);     // Инициализируем значения Label и Value    var result = Expression.MemberInit(        newExpression, e1ExpressionBind, e2ExpressionBind);    var lambda = Expression.Lambda<Func<TQueryable, DropdownOption<TValue>>>(        result, labelExpression.Parameters);     /*    В итоге получим    return q.Select(x => new DropdownOption<TValue>    {      Label = labelExpression      Value = valueExpression    });    Но такой код не скомплируется,    поэтому пришлось написть с помощью API Expression Trees    */    return q.Select(lambda); }

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

Сами классы DropdownOption и DropdownOption<T> вылгядят следующим образом.

public class DropdownOption {    // Запрещаем программно создавать нетипизированные DropdownOption    // за пределами сборки    internal DropdownOption() {}     internal DropdownOption(string label, object value)    {        Value = value ?? throw new ArgumentNullException(nameof(value));        Label = label ?? throw new ArgumentNullException(nameof(label));    }     // Делаем свойства неизменяемыми за пределеами сборки    public string Label { get; internal set; }     public object Value { get; internal set; } }  public class DropdownOption<T>: DropdownOption {     internal DropdownOption() {}      // Типизированные опции создавать за пределами сборки     public DropdownOption(string label, T value) : base(label, value)     {         _value = value;     }      private T _value;      // Перекрываем базовое свойство типизированным     public new virtual T Value     {         get => _value;        internal set        {            _value = value;            base.Value = value;        }     } }

Трюк с internal-конструктором позволяет привести любой DropdownOption<T> к DropdownOption без generic-параметра, одновременно, не позволяя создавать экземпляры класса без generic-параметра за пределами сборки.

Будет здорово когда/если ковариантные возвращаемые типы будут реализованы. С ними можно избавиться от перекрытия через new. Пока имеем, что имеем.

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

public IEnumerable GetDropdowns(IQueryable<SomeData> q) =>     q.ToDropdownOption(x => x.String, x => x.Id)

IDropdownProvider

Где вызывать этот метод расширения? Допустим, мы работаем с таким контроллером:

public IActionResult GetData(     [FromServices] IQueryable<SomeData> q     [FromQuery] SomeDataFilter filter) =>     Ok(q     .Filter(filter)     .ToList());

Классы SomeData и SomeDataFilter определены следующим образом:

public class SomeDataFilter {    public int[] Number { get; set; }     public DateTime[]? Date { get; set; }     public string[]? String { get; set; } }  public class SomeData {    public int Number { get; set; }     public DateTime Date { get; set; }     public string String { get; set; } } 

А метод Filter следующим образом:

public static IQueryable<SomeData> Filter(     this IQueryable<SomeData> q,     SomeDataFilter filter) {     if (filter.Number != null)     {         q = q.Where(x => filter.Number.Contains(x.Number));     }      if (filter.Date != null)     {         q = q.Where(x => filter.Date.Contains(x.Date));     }      if (filter.String != null)     {         q = q.Where(x => filter.String.Contains(x.String));     }      return q; }

Для реальных проектов, этот метод можно сделать обобщенным Как именно описано здесь

SomeDataFilter содержит массивы значений из выпадающих списков, заполненных пользователем, а значит мы где-то в другом месте передали их на фронтенд, используя метод вроде этого:

public IActionResult GetSomeDataFilterDropdownOptions(    [FromServices] IQueryable<SomeData> q) {    var number = q        .ToDropdownOption(x => x.Number.ToString(), x => x.Number)        .Distinct()        .ToList();     var date = q        .ToDropdownOption(x => x.Date.ToString("d"), x => x.Date)        .Distinct()        .ToList();     var @string = q        .ToDropdownOption(x => x.String, x => x.String)        .Distinct()        .ToList();     return Ok(new    {        number,        date,        @string    }); }

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

public interface IDropdownProvider<T> {   Dictionary<string, IEnumerable<DropdownOption>> GetDropdownOptions(); }

И перенесем код получения опций в класс, реализующий интерфейс:

public class SomeDataFiltersDropdownProvider: IDropdownProvider<SomeDataFilter> {    private readonly IQueryable<SomeData> _q;     public SomeDataFiltersDropdownProvider(IQueryable<SomeData> q)    {        _q = q;    }     public Dictionary<string, IEnumerable<DropdownOption>> GetDropdownOptions()    {        return new Dictionary<string, IEnumerable<DropdownOption>>()        {            {                "name", _q                .ToDropdownOption(x => x.Number.ToString(), x => x.Number)                .Distinct()                .ToList();            },            {                "date", _q                .ToDropdownOption(x => x.Date.ToString("d"), x => x.Date)                .Distinct()                .ToList();                       },            {                "string", _q                .ToDropdownOption(x => x.String, x => x.String)                .Distinct()                .ToList();            }        };    } }

Осталось написать вот такой обобщенный метод контроллера, который будет по названию типа искать соответствующий DropdownProvider и вызывать его метод.

[HttpGet] [Route("Dropdowns/{type}")] public async IActionResult Dropdowns(      string type,       [FromServices] IServiceProvider serviceProvider      [TypeResolver] ITypeResolver typeResolver) {    var t = typeResolver(type);    if (t == null)    {        return NotFound();    }     // Преобразование к dynamic, чтобы не париться с приведением типов.    // T неизвестен, потому что метод контроллера не содержит дженерика.    dynamic service = serviceProvider        .GetService(typeof(IDropdownProvider<>)        .MakeGenericType(t));     if (service == null)    {        return NotFound();    }     var res = service.GetDropdownOptions();    return Ok(res); }

Одновременные запросы

На этом можно было бы и закончить, но, как говорится, есть нюанс. В примере сверху запросы к БД выполняются последовательно, хотя они не зависят друг от друга. Чем больше колонок с фильтрами, тем больший выигрыш можно получить за счет параллельного выполнения запросов. Реализации IQueryable чаще всего базируются на той или иной ORM, а реализации Unit Of Work ORM часто не потокобезопасны (иначе слишком сложно было бы реализовать change tracking). Поэтому будем использовать отдельные области видимости (scope) ServiceProvider и асинхронные версии методов.

public static async Task<TResult> InScopeAsync<TService, TResult>(     this IServiceProvider serviceProvider,     Func<TService, IServiceProvider, Task<TResult>> func) {     using var scope = serviceProvider.CreateScope();      return await func(         scope.ServiceProvider.GetService<TService>(),         scope.ServiceProvider); }

В итоге код DropdownProvider можно переписать в следующем виде:

public async Task<Dictionary<string, IEnumerable<DropdownOption>>>    GetDropdownOptionsAsync() {     var dict = new Dictionary<string, IEnumerable<DropdownOption>>();     var name = sp.InScopeAsync<IQueryable<SomeData>>(q => q         .ToDropdownOption(x => x.Number.ToString(), x => x.Number)         .Distinct()         .ToListAsync());      var date = sp.InScopeAsync<IQueryable<SomeData>>(q => q         .ToDropdownOption(x => x.Date.ToString("d"), x => x.Date)         .Distinct()         .ToListAsync());         var @string = sp.InScopeAsync<IQueryable<SomeData>>(q => q         .ToDropdownOption(x => x.String, x => x.String)         .Distinct()         .ToListAsync());      // Теперь все запросы выполняются параллельно     await Task.WhenAll(new []{name, date, @string}});     dict["name"] = await name;     dict["date"] = await date;     dict["string"] = await @string;     return dict; }

Осталось прибрать код, устранить дублирование и предоставить более удобное API. Для этого хорошо подойдет шаблон проектирования строитель. Я опущу детали реализации. Пытливый читатель наверняка сможет спроектировать аналогичный API самостоятельно.

public async Task<Dictionary<string, IEnumerable<DropdownOption>>>     GetDropdownOptionsAsync() {      return sp         .DropdownsFor<SomeDataFilters>          .With(x => x.Number)         .As<SomeData, int>(GetNumbers)          .With(x => x.Date)         .As<SomeData, DateTime>(GetDates)          .With(x => x.String)         .As<SomeData, string>(GetStrings) }

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


Комментарии

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

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