Фильтры контента на ASP.NET MVC и Entity Framework

в 6:31, , рубрики: .net, ASP, asp.net mvc 3, entity framework, web-разработка, метки: , , ,

Очень часто в различных веб-приложениях мы работаем с данными, выбранными из таблиц БД. И нередко необходимо предоставлять пользователю возможность фильтровать эти данные. Можно, конечно, для каждого случая собирать данные с формы в ручную и в ручную создавать соответствующий запрос под них. Но что если у нас 20 разных страниц, представляющих те, или иные данные? Обрабатывать в ручную все долго и не удобно, а поддерживать еще хуже. Моя версия решения данной проблемы на ASP.NET MVC + Entity Framework под катом.

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

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

public class Student
{
    [Key]
    public int Id { get; set; }

    // имя
    public string Name { get; set; }

    // группа
    public string Group { get; set; }

    // год защиты
    public short DefYear { get; set; }

    // статус защищенности ВКР
    public bool IsDefended { get; set; }
}

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

public class StudentFilter : FilterModel<Student>
{
    [FilterName("ФИО")]
    [InputType(InputTypeAttribute.InputTypies.TextBox)]
    public string Name { get; set; }

    [FilterName("Группа")]
    [InputType(InputTypeAttribute.InputTypies.TextBox)]
    public string Group { get; set; }

    [FilterName("Год защиты")]
    [InputType(InputTypeAttribute.InputTypies.DropDownList)]
    [SelectListProperty("DefYearList", "Неважно")]
    public short? DefYear { get; set; }

    [FilterName("Статус защиты ВКР")]
    [InputType(InputTypeAttribute.InputTypies.DropDownList)]
    [SelectListProperty("IsDefendedList", "Неважно")]
    public bool? IsDefended { get; set; }у

    public SelectList DefYearList
    {
        get { return new SelectList(new List<short> { 2011, 2012 }, DefYear); }
    }

    public SelectList IsDefendedList
    {
        get
        {
            var list = new List<object>
            {
              {new {Id="true", Title="Защищена"}},
              {new {Id="false", Title="Не защищена"}},
            };

            return new SelectList(list, "Id", "Title", IsDefended);
        }
    }

    public override Func<Student, bool> FilterExpression
    {
        get
        {
            return p =>
                (String.IsNullOrEmpty(Name) || p.Name.IndexOf(Name) == 0) &&
                (String.IsNullOrEmpty(Group) || p.Group.ToLower().IndexOf(Group.ToLower()) == 0) &&
                (DefYear == null || p.DefYear == DefYear) &&
                (IsDefended == null || (p.IsDefended == IsDefended));
        }
    }

    public override void Reset()
    {
        Name = null; Group = null; DefYear = null; IsDefended = null;
    }
}

Имея модель фильтра мы можем выбрать из БД данные, удовлетворяющие значениям, хранящимся в этой модель. Сделать это можно вот так

var filters = IocContainer.Resolve<IFilterFactory>();

var students = DbContext.Students
   .Where(Filter.FilterExpression)
   .OrderBy(s => s.Name)
   .ToList();

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

..................

<h2>Студенты</h2>

@Html.FilterForm(Filters.Find<UserFilter>())

..................

Filters этот то же хранилище фильтров, которое использовали при выборке из БД. Лично я его получаю с помощью Unity.

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

Теперь перейдем к описанию всего того, что обслуживает вышеописанный код. Начнем мы с модели данных для фильтров. По скольку для каждого представления данных на сайте фильтры могут быть разные и соответственно разные модели, необходимо определить ее интерфейс, который будем реализовывать. Модель для фильтра должна уметь генерировать expression для выборки из коллекций данных entity framework и должна уметь устанавливать свое начальное состояние, по этому интерфейс, а вернее абстрактный класс, получился следующий:

public abstract class FilterModel
{
    [Dependency]
    public IUnityContainer IocContainer { get; set; }

    public abstract void Reset();
}

public abstract class FilterModel<ModelType> : FilterModel
{
    public abstract Func<ModelType, bool> FilterExpression { get; }
}

Использую здесь именно абстрактный класс потому, что хотелось всегда держать контэйнер Unity под рукой. По этому свойство IocContainer можно смело убирать и превращать эти два класса в обычные интерфейсы.

Теперь, когда у нас есть модель, нам нужно ее где то хранить. Рассмотрим теперь интерфейс необходимого нам хранилища.

public interface IFilterFactory
{
    /// <summary>
    /// Поиск фильтра по типу
    /// </summary>
    /// <typeparam name="TFilter"></typeparam>
    /// <returns></returns>
    TFilter Find<TFilter>() where TFilter : FilterModel;

    /// <summary>
    /// Поиск фильтра по GUID типа
    /// </summary>
    /// <param name="guidString"></param>
    /// <returns></returns>
    FilterModel Find(String guidString);

    /// <summary>
    /// Замена объекта фильтра
    /// </summary>
    /// <param name="filter"></param>
    void Replace(FilterModel filter);

    /// <summary>
    /// Сброс данных фильтра на начальную позицию
    /// </summary>
    /// <param name="guidString">guid типа фильтра</param>
    void Reset(String guidString);
}

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

Вот базовая реализация интерфейса IFilterFactory.

public class DefaultFilterFactory : IFilterFactory
{
    protected List<object> Collection { get; private set; }

    [InjectionConstructor]
    public DefaultFilterFactory()
    {
        Collection = new List<object>();
    }

    [Dependency]
    public IUnityContainer IocContainer { get; set; }

    /// <summary>
    /// Поиск необходимого фильтра
    /// </summary>
    /// <typeparam name="TFilter">тип фильтра</typeparam>
    /// <returns></returns>
    public TFilter Find<TFilter>() where TFilter : FilterModel
    {
        try
        {
            return (TFilter)Collection.Single(f => f.GetType().FullName == typeof(TFilter).FullName);
        }
        catch
        {
            AddNew<TFilter>();

            return Find<TFilter>();
        }
    }

    /// <summary>
    /// Поиск фильтра по GUID его типа
    /// </summary>
    /// <param name="guidString">строковое представление GUID</param>
    /// <returns></returns>

    public FilterModel Find(String guidString)
    {
        return (FilterModel)Collection.Single(f => f.GetType().GUID.ToString() == guidString);
    }

    public void Replace(FilterModel filter)
    {
        try
        {
            var old = Collection.SingleOrDefault(f => f.GetType().FullName == filter.GetType().FullName);

            if (old != null)
            {
                if (!Collection.Remove(old))
                    throw new InvalidOperationException("Неудача при удалении старого фильтра");
            }
        }
        catch (InvalidOperationException)
        {
            throw;
        }
        catch
        {
            //сюда можно приделать логгер
        }

        Collection.Add(filter);
    }

    /// <summary>
    /// Добавление нового фильтра
    /// </summary>
    /// <typeparam name="TFilter">тип фильтра</typeparam>
    protected void AddNew<TFilter>() where TFilter : FilterModel
    {
        var filter = IocContainer.Resolve<TFilter>();

        filter.Reset();

        Collection.Add(filter);
    }

    /// <summary>
    /// Сброс фильтра по GUID его типа
    /// </summary>
    /// <param name="guidString">строковое представление GUID</param>
    /// <returns></returns>
    public void Reset(String guidString)
    {
        try
        {
            var filter = Find(guidString);

            filter.Reset();
        }
        catch
        {
            //сюда можно приделать логгер
        }
    }
}

Хранить объект класса в DefaultFilterFactory приходится в рамках сессии, что бы сохранять выбранные пользователем значения. Для этого я использую Unity с дописанным lifemanager-ом для сессий ASP.NET MVC, Вы можете использовать любой другой DI-фрэймворк или же работать с объектом самостоятельно. Так же можно написать другую реализацию интерфейса IFilterFactory, которая будет использовать для хранения xml например или же БД, тут фантазия может быть безгранична…

Дальше необходимо каким то образом собрать модель из запроса пользователя и поместить ее в хранилище. Для этого будем использовать специальный сборщик модели (тут каюсь, не додумал, грамотней было бы его унаследовать от IModelBinder и использовать как любой другой сборщик моделей в MVC)

public class FilterBinder
{
    /// <summary>
    /// Ключ словаря запроса, в значении которого будет лежать guid типа
    /// </summary>
    public static string TypeKey
    {
        get { return "_filter_type"; }
    }

    /// <summary>
    /// Лямда для отсеиневания свойств, которые не относятся к данным фильтра
    /// Все свойства модели фильтра, которые должны учавствовать в фильтрации необходимо отметить аттрибутом InputTypeAttribute
    /// </summary>
    public static Func<PropertyInfo, bool> IsFilterProperty
    {
        get
        {
            return p =>
              p.GetCustomAttributes(true).Count(a => a.GetType() == typeof(InputTypeAttribute)) > 0;
        }
    }

    /// <summary>
    /// Объект запроса
    /// </summary>
    public HttpRequest Request { get; protected set; }

    /// <summary>
    /// Unity контэйнер
    /// </summary>
    [Dependency]
    public IUnityContainer IocContainer { get; set; }

    /// <summary>
    /// Тип фильтра
    /// </summary>
    public Type FilterType
    {
        get { return IocContainer.Resolve<IFilterFactory>().Find(Request[TypeKey]).GetType(); }
    }

    public FilterBinder()
    {
        Request = HttpContext.Current.Request;
    }

    /// <summary>
    /// Сборка модели
    /// </summary>
    /// <returns></returns>
    public FilterModel BindFilter()
    {
        var filter = (FilterModel)IocContainer.Resolve(FilterType);

        // перебор всех свойств фильтра, отмеченных InputTypeAttribute
        foreach (var property in FilterType.GetProperties().Where(FilterBinder.IsFilterProperty))
        {
            object value = null;

            //если значение свойства строковое, просто присваиваем ему данные из запроса с ключем, совпадающим с именем свойства
            if (property.PropertyType == typeof(String))
            {
                value = Request[property.Name];
            }

            //если не строковое, пытаемся преобразовать типы
            else
            {
                try
                {
                    var parse = property.PropertyType.GetMethod("Parse", new Type[] { typeof(String) }) ??
                          property.PropertyType.GetProperty("Value").PropertyType.GetMethod("Parse", new Type[] { typeof(String) });

                    if (parse != null)
                        value = parse.Invoke(null, new object[] { Request[property.Name] });
                }
                catch
                {
                    value = null;
                }
            }

            //устанавливаем получившиеся значение в модель
            property.SetValue(filter, value, null);
        }

        return filter;
    }
}

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

public class _FilterController : ExpandController
{
    public ActionResult SetFilter(string backUrl)
    {
        try
        {
            var filter = IocContainer.Resolve<FilterBinder>().BindFilter();

            if (filter == null)
            {
                FlashMessanger.ErrorMessage = "Ошибка установки фильтра";

                return Redirect(backUrl);
            }

            Filters.Replace(filter);
        }
        catch
        {
            FlashMessanger.ErrorMessage = "Ошибка установки фильтра: истекло время сессии. Пожалуйста, повторите попытку";
        }

        return Redirect(backUrl);
    }

    public ActionResult Reset(string backUrl, string filter)
    {
        Filters.Reset(filter);

        return Redirect(backUrl);
    }
}

(ExpandController — расширенный базовый Controller, FlashMessanger — надстройка над TempData, не даю пояснений на этот счет, так как это совсем другая история)

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

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

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

/// <summary>
/// Указывает на свойство, которое вернет SelectList с вариантами значений
/// </summary>
public class SelectListPropertyAttribute : Attribute
{
    /// <summary>
    /// Имя свойсва
    /// </summary>
    public string Property { get; set; }

    /// <summary>
    /// название для null
    /// </summary>
    public string OptionLabel { get; set; }

    public SelectListPropertyAttribute(string property)
    {
        Property = property;

        OptionLabel = String.Empty;
    }

    public SelectListPropertyAttribute(string property, string optionLabel)
        : this(property)
    {
        OptionLabel = optionLabel;
    }
}

/// <summary>
/// Отмечает свойство для отображения в форме и для получения данных в него из запроса
/// Так же выбирает через какой элементы формы отображать свойство
/// </summary>
public class InputTypeAttribute : Attribute
{
    public enum InputTypies
    {
        TextBox, CheckBox, DropDownList
    }

    public InputTypies Type { get; set; }

    public InputTypeAttribute(InputTypies type)
    {
        Type = type;
    }
}

/// <summary>
/// Имя свойства для отображения в форме
/// </summary>
public class FilterNameAttribute : Attribute
{
    public string Name { get; set; }

    public FilterNameAttribute(string name)
    {
        Name = name;
    }
}

и код хелпера

public static class Helpers
{
    /// <summary>
    /// Имя контроллера, которому отправляем данные
    /// </summary>
    private const string CONTROLLER_NAME = "_Filter";

    /// <summary>
    /// Имя действия для установки фильтра
    /// </summary>
    private const string SET_ACTION_NAME = "SetFilter";

    /// <summary>
    /// Имя действия для сброса фильтра
    /// </summary>
    private const string RESET_ACTION_NAME = "Reset";

    /// <summary>
    /// Создание разметки формы
    /// </summary>
    /// <returns></returns>
    public static IHtmlString FilterForm(this HtmlHelper helper, FilterModel filter, string controllerName = CONTROLLER_NAME, string setActionName = SET_ACTION_NAME, string resetActionName = RESET_ACTION_NAME)
    {
        var url = new UrlHelper(HttpContext.Current.Request.RequestContext, helper.RouteCollection);

        //генерируем url на который будем отправлять форму
        var hrefSet = url.Action(setActionName, controllerName, new { area = "", backUrl = HttpContext.Current.Request.Url });

        var result = String.Format("<form action="{0}" method="post">nt<div class="filters">", hrefSet);

        result += helper.Hidden(FilterBinder.TypeKey, filter.GetType().GUID.ToString()).ToString();

        result += "<h3>Фильтры</h3>";

        // небольшие функции, которые нам пригодятся
        // возвращает имя свойства
        Func<PropertyInfo, string> getFilterName = p =>
            p.GetCustomAttributes(typeof(FilterNameAttribute), true).Any() ?
                ((FilterNameAttribute)p.GetCustomAttributes(typeof(FilterNameAttribute), true).Single()).Name :
                p.Name;

        // возвращает свойсвтво, содержащие SelectList возможных значений
        Func<PropertyInfo, PropertyInfo> getSelectListProperty = p =>
            !p.GetCustomAttributes(typeof(SelectListPropertyAttribute), true).Any() ?
                null :
                p.DeclaringType.GetProperty(
                    ((SelectListPropertyAttribute)p.GetCustomAttributes(typeof(SelectListPropertyAttribute), true).Single()).Property,
                    typeof(SelectList)
                );

        // возвращает имя свойства для отображения
        Func<PropertyInfo, string> getSelectListOptionLabel = p =>
            !p.GetCustomAttributes(typeof(SelectListPropertyAttribute), true).Any() ?
                null :
                ((SelectListPropertyAttribute)p.GetCustomAttributes(typeof(SelectListPropertyAttribute), true).Single()).OptionLabel;

        //цикл по всем свойсвам модели, отмеченным InputTypeAttribute
        foreach (var property in filter.GetType().GetProperties().Where(FilterBinder.IsFilterProperty))
        {
            result += "ntt<div class="filter_item">";

            result += "nttt<span>" + getFilterName(property) + "</span>";

            //получаем тип элемента формы, с помощью которого будем отображать свойсвто
            var type = (InputTypeAttribute)property.GetCustomAttributes(typeof(InputTypeAttribute), true).Single();

            // генерируем html свойства
            switch (type.Type)
            {
                case InputTypeAttribute.InputTypies.TextBox:
                    result += helper.TextBox(property.Name, property.GetValue(filter, null)).ToString();
                    break;

                case InputTypeAttribute.InputTypies.CheckBox:
                    result += helper.CheckBox(property.Name, property.GetValue(filter, null)).ToString();
                    break;

                case InputTypeAttribute.InputTypies.DropDownList:
                    var selectList = getSelectListProperty(property) != null ?
                        (SelectList)getSelectListProperty(property).GetValue(filter, null) :
                        new SelectList(new List<object>());

                    result += String.IsNullOrEmpty(getSelectListOptionLabel(property)) ?
                        helper.DropDownList(property.Name, selectList) :
                        helper.DropDownList(property.Name, selectList, getSelectListOptionLabel(property));
                    break;
            }
            result += "ntt</div>";
        }

        result += "ntt<div class="clear"></div>";

        result += String.Format(
            @"<input type='image' src='{0}' /><a href='{1}'><img src='{2}' alt='' /></a>",
            url.Content("~/Content/images/button_apply.png"),
            url.Action(resetActionName, controllerName, new { area = "", backUrl = HttpContext.Current.Request.Url, filter = filter.GetType().GUID }),
            url.Content("~/Content/images/button_cancel.png")
        );

        return helper.Raw(result + "nt</div>n</form>");
    }
}

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

1) Если пользователь откроет страницу, затем оставит ее на время, достаточное для того, что бы истекло время сессии, после чего заполнит форму фильтров и отправит ее, то его ждет разочарование. Система не сможет найти тип фильтра по его guid. Как решить проблему, пока не придумал, но активно размышляю на этот счет

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

3) Для метода Reset класса FilterModel можно написать реализацию по умолчанию, в которой все свойства, помеченные InputTypeAttribute будут установлены в нулевые значения.

4) Я не уверен в абсолютной правильности архитектуры всего этого.

Большое Вам спасибо, что дочитали мою статью до конца! Буду крайне признателен Всем, кто оставит свое мнение и замечания.

Автор: levchick

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js