Thursday, November 26, 2015

Использование Autocomplete для своих приложений ASP.NET MVC5

Здравствуйте, уважаемые читатели. Как я и обещал, приступаю к писанию тем по ASP.NET MVC 5 и по разработке под web. Сегодня мы с вами рассмотрим, как работает jQuery.Widget Autocomplete, для того чтобы не грузить большие объемы данных на клиент. В общем, будем использовать всего понемногу. Стек технологий у нас будет такой: ASP.NET MVC 5, jQuery 1.10.2, jQuery UI, Unity.MVC, knockoutjs и Bootstrap. Мы начнем с самого простого варианта и будем постепенно улучшать наш пример. Надеюсь, что это поможет вам в изучении ASP.NET MVC и также позволит избежать множества граблей, на которые я наступал.  Начнем с того, что будем строить вокруг стандартного шаблона ASP.NET MVC 5. Для этого создадим новый проект, выберем шаблон ASP.NET Web Application и зададим ему имя “AutocompleteSample”.
Затем c предложенных темплейтов выбираем MVC и отключаем галочку, если у вас стоит на “Host in cloud”.
Это мы делаем для того, чтобы у нас уже был сгенерирован готовый шаблон с приписыванием всех необходимых начальных настроек. Лишнее мы удалять пока не будем. Это не та задача, которую я пытаюсь решить с помощью данной статьи.
Примечание. Вы можете писать код, например, не в Visual Studio 2015, поэтому интерфейс может сильно отличаться. Также для нашего примера нам не нужно будет использовать возможности C# 6, поэтому вы смело можете использовать .Net Framework 4.5 и ASP.NET MVC 4. Все будет работать отлично.
Теперь время доставить себе все необходимые пакеты с помощью NuGet, которые мы будем использовать. Начнем сначала с knockoutjs, которая нам будет нужна для клиентской части.
Затем нам нужно поставить Unity.MVC, который мы будем использовать немного, когда будем улучшать наш пример.
Теперь начнем с реализации. Нам нужны будут какие-то данные тестовые для загрузки. Для того чтобы их получить, для нашего тестового примера есть несколько простых способов. Создать базу данных с помощью Entity Framework и сгенерировать там тестовые данные. Либо взять данные с файла, чтобы не заморачиваться с Entity Framework. Мы будем для примера использовать файл с топ-дорогих машин, который вы можете взять по ссылке cars.txt. Этот файл мы добавим себе в папку App_Data.
Ниже представлено на рисунке, что представляет собой данный файл.
Информация для данного файла взята со статьи Most expensive cars in the world. Highest price. Теперь перейдем в папку Models и добавим новый класс, который назовем Car. Ниже приведена реализация этого класса.
public class Car
{
    public int Id { get; set; }
    public string Name { get; set; }
    public double Price { get; set; }
    public int Spead { get; set; }
    public double Acceleration { get; set; }
    public int Power { get; set; }
    public double Displacement { get; set; }
    public int Weight { get; set; }
}
Мы проделали уже почти 90% нужной работы. На самом деле, это была шутка. Поэтому не расслабляемся и переходим в наш контроллер, чтобы реализовать метод, который будет нам возвращать нужный результат. У меня этот метод выглядит следующим образом:
public ActionResult GetCars()
{
    var path = HttpContext.Server.MapPath("~/App_Data/cars.txt");
    var cars = System.IO.File.ReadAllLines(path)
        .Skip(1)
        .Select(x => x.Split(new[] { ',' }, StringSplitOptions.None))
        .Select(item =>
        {
            return new Models.Car()
            {
                Id = int.Parse(item[0]),
                Name = item[1].Trim(new[] { '"' }),
                Price = double.Parse(item[2]),
                Spead = int.Parse(item[3]),
                Acceleration = double.Parse(item[4]),
                Power = int.Parse(item[5]),
                Displacement = double.Parse(item[6]),
                Weight = int.Parse(item[7])
            };
        })
        .ToList();

    return Json(cars, JsonRequestBehavior.AllowGet);
}
Полный код класса HomeController приведен ниже.
public class HomeController : Controller
{
    public ActionResult Index()
    {
        return View();
    }

    public ActionResult GetCars()
    {
        var path = HttpContext.Server.MapPath("~/App_Data/cars.txt");
        var cars = System.IO.File.ReadAllLines(path)
            .Skip(1)
            .Select(x => x.Split(new[] { ',' }, StringSplitOptions.None))
            .Select(item =>
            {
                return new Models.Car()
                {
                    Id = int.Parse(item[0]),
                    Name = item[1].Trim(new[] { '"' }),
                    Price = double.Parse(item[2]),
                    Spead = int.Parse(item[3]),
                    Acceleration = double.Parse(item[4]),
                    Power = int.Parse(item[5]),
                    Displacement = double.Parse(item[6]),
                    Weight = int.Parse(item[7])
                };
            })
            .ToList();

        return Json(cars, JsonRequestBehavior.AllowGet);
    }
    public ActionResult About()
    {
        ViewBag.Message = "Your application description page.";

        return View();
    }

    public ActionResult Contact()
    {
        ViewBag.Message = "Your contact page.";

        return View();
    }
}
Теперь переходим к реализации нашего представления. Для этого переходим в папку Views\Home и открываем файл Index.cshtml. Самое время объяснить, зачем был создан отдельный метод GetCars в контроллере HomeController. Это сделано для того, чтобы данный метод можно было отдельно вызывать с клиента через Ajax запрос. 
Далее заходим в файл Index.cshtml, о котором я написал выше, и все удаляем оттуда. Вставляем следующий код:
@{
    ViewBag.Title = "Home Page";
}
<script src="~/Scripts/jquery-1.10.2.js"></script>
<script src="~/Scripts/knockout-3.3.0.js"></script>

<div class="form-control-static">
    <div class="form-group">
        Select car from list:
        <select class="form-control" data-bind="options: cars, optionsText: 'Name', optionsCaption: 'Select car...', value: selectedCar"></select>
    </div>
    <div class="form-group">
        <input type="submit" id="submit" class="btn btn-info"/>
    </div>

    <div class="form-group">
        <div id="result-box"></div>
    </div>
</div>

<script type="text/javascript">
    function CarViewModel() {
        this.cars = ko.observableArray();
        this.selectedCar = ko.observable();
    }

    $(function () {
        var carVM = new CarViewModel();

        $.get("/Home/GetCars", function (data) {
            carVM.cars(data);
        });

        ko.applyBindings(carVM);
    })
</script>
Здесь нет ничего особенного. Просто создается модель CarViewModel, в которой мы указываем две переменные за обновлениями, за которыми мы будем следить. В knockoutjs это работает по принципу, как observable свойство, как, например, INotifyPropertyChanges в WPF. То есть, вы что-то изменяете, и эти изменения сразу видите на UI. Загрузка данных у нас происходит через вызов функции $.get, которая вызывает метод в контроллере GetCars. Вызвали метод, получили результат, присвоили полученные данные в нашу переменную, – и все. Теперь запустим наш проект и посмотрим на результат.
Выглядит все достаточно симпатично, плюс ко всему еще и работает. Но есть несколько минусов. Во-первых, если у нас много данных, то мы все их тащим на клиент. Во-вторых, у нас нет никакого кеширования данных. В-третьих, достаточно неудобно это все мотать на клиенте и искать нужную нам машину в огромном списке. В-четвертых, у нас кнопка submit сейчас для красоты. Она не блочится, когда ничего не выбрано, и она не запрашивает никакой информации с сервера. Поэтому у нас предостаточно работы впереди. 
Пожалуй, начнем потихоньку переделывать наш пример, тем более сходу мы нашли много узких мест. Для начала расширим наш блок divresult_box” и добавим, чтобы при выборе значения со списка была доступна дополнительная информация по машине.
<div class="form-group" data-bind="with: selectedCar">
    <div id="result-box">
        <label for="carName">Name: </label>
        <label id="carName" class="form-control" data-bind="text: Name"></label>
        <label for="carPrice">Price: </label>
        <label id="carPrice" class="form-control" data-bind="text: Price"></label>
        <label for="carSpeed">Price: </label>
        <label id="carSpeed" class="form-control" data-bind="text: Speed"></label>
        <label for="carAcceleration">Acceleration: </label>
        <label id="carAcceleration" class="form-control" data-bind="text: Acceleration"></label>
        <label for="carPower">Power: </label>
        <label id="carPower" class="form-control" data-bind="text: Power"></label>
        <label for="carDisplacement">Displacement: </label>
        <label id="carDisplacement" class="form-control" data-bind="text: Displacement"></label>
        <label for="carWeight">Weight: </label>
        <label id="carWeight" class="form-control" data-bind="text: Weight"></label>
    </div>
</div>
Как видите, изменений получилось немного. Если мы запустим еще раз наш пример, то получим следующий результат:
У нас уже есть рабочий пример, правда, непонятно, зачем кнопка submit с такой логикой. Пока у нас данных немного, все работает отлично, но что делать, если данных сотни тысяч? Не грузить же нам всю базу на клиент. Вторая проблема связана с прокруткой этого списка. На наших несколько записей это не составляет проблем, но что делать, если записей больше 1000? Вам вряд ли удобно будет прокручивать ваш список в самый низ. Да и записи наши не кэшируются, что приводит к тому что при повторном запросе мы опять будем вычитывать весь список. Поэтому начнем с оптимизации. Добавим в наш пример кеширование. Создадим новую папку, которую назовем Helpers, и добавим туда следующий интерфейс ICacheService:
public interface ICacheService
{
    T GetOrSet<T>(string cacheKey, Func<T> getItemCallback) where T : class;
}
В эту же папку добавим реализацию в виде класса CacheService, который наследуем от интерфейса ICacheService. Реализация, соответственно, приведена ниже.
public class CacheService : ICacheService
{
    public T GetOrSet<T>(string cacheKey, Func<T> getItemCallback) where T : class
    {
        T item = MemoryCache.Default.Get(cacheKey) as T;
        if (item == null)
        {
            item = getItemCallback();
            MemoryCache.Default.Add(cacheKey, item, DateTime.Now.AddMinutes(10));
        }
        return item;
    }
}
Реализацию этого CacheService я взял со stackoverflow. Для того чтобы класс MemoryCache у вас стал доступный, вам нужно добавить reference на библиотеку System.Runtime.Caching
Примечание. Вы можете, вместо такого подхода, использовать HttpContext и класс Session. Все зависит от вашего желания. Теперь перейдем в наш контроллер и перепишем наш метод GetCars. чтобы он кешировал результат. Для этого нам нужно сделать dependency injection, чтобы мы могли в нашем контроллере использовать интерфейс ICacheService. Измененный класс контролера показан ниже.
public class HomeController : Controller
{
    private readonly ICacheService _cacheService;
    public HomeController(ICacheService cacheService)
    {
        _cacheService = cacheService;
    }
    public ActionResult Index()
    {
        return View();
    }

    public ActionResult GetCars()
    {
        var cars = _cacheService.GetOrSet("Cars", () => GetAllCars());

        return Json(cars, JsonRequestBehavior.AllowGet);
    }

    private List<Car> GetAllCars()
    {
        var path = HttpContext.Server.MapPath("~/App_Data/cars.txt");
        var cars = System.IO.File.ReadAllLines(path)
            .Skip(1)
            .Select(x => x.Split(new[] { ',' }, StringSplitOptions.None))
            .Select(item =>
            {
                return new Models.Car()
                {
                    Id = int.Parse(item[0]),
                    Name = item[1].Trim(new[] { '"' }),
                    Price = double.Parse(item[2]),
                    Speed = int.Parse(item[3]),
                    Acceleration = double.Parse(item[4]),
                    Power = int.Parse(item[5]),
                    Displacement = double.Parse(item[6]),
                    Weight = int.Parse(item[7])
                };
            })
            .ToList();

        return cars;
    }
    public ActionResult About()
    {
        ViewBag.Message = "Your application description page.";

        return View();
    }

    public ActionResult Contact()
    {
        ViewBag.Message = "Your contact page.";

        return View();
    }
}
Как видите, для того чтобы получить список машин, я вынес логику в отдельный метод, который назвал GetAllCars. А метод изменил так, чтобы он кешировал результат в памяти, как вы можете увидеть выше. Конструктор класса HomeController теперь принимает в качестве параметра интерфейс ICacheService. К сожалению, наш пример еще не заработает, если его запустить, потому что нужно указать нашему Unity.Mvc, который мы ставили вначале, что нужно связать ICacheService с классом CacheService. Для этого откроем папку App_Start и найдем класс UnityMvcActivator.
Пока, вроде, ничего не должно вызвать у вас какие-либо трудности. Как только мы нашли этот файл, откройте его для редактирования. Добавим в конец метода Start наше связывание, как показано на рисунке ниже.
public static void Start()
{
    var container = UnityConfig.GetConfiguredContainer();

    FilterProviders.Providers.Remove(FilterProviders.Providers.OfType<FilterAttributeFilterProvider>().First());
    FilterProviders.Providers.Add(new UnityFilterAttributeFilterProvider(container));

    DependencyResolver.SetResolver(new UnityDependencyResolver(container));

    // TODO: Uncomment if you want to use PerRequestLifetimeManager
    // Microsoft.Web.Infrastructure.DynamicModuleHelper.DynamicModuleUtility.RegisterModule(typeof(UnityPerRequestHttpModule));
    container.RegisterType<ICacheService, CacheService>(new ContainerControlledLifetimeManager());
}
Если у вас не будет доступа к методу RegisterType, добавьте следующий namespace:
using Microsoft.Practices.Unity;
После этого можно запустить пример на выполнение и убедиться, что все работает, как и раньше. Теперь следующая часть нашего задания. Кеширование мы добавили, теперь нужно добавить autocomplete, вместо того чтобы выбирать нужное значение со всего списка. Для этого мы воспользуемся jQuery.Widget Autocomplete, использование которого вы можете посмотреть здесь. Для примера нам не нужно будет использовать knockoutjs, а достаточно использовать JQuery + Ajax запросы с сервера. 
Добавим новый метод в HomeController, который назовем Autocomplete, и который будет принимать как параметр строку.
public JsonResult Autocomplete(string term)
{
    var cars = _cacheService.GetOrSet("Cars", () => GetAllCars());
    var result = cars.Where(s => s.Name.ToLower().Contains
                    (term.ToLower())).Select(w => w.Name).ToList();
    return Json(result, JsonRequestBehavior.AllowGet);
}
Теперь функция GetCars нам не нужна, ведь данные будут подтягиваться с помощью функции Autocomplete. Мы можем не загружать весь список наружу, а отдавать только найденные значения и, например, в количестве 20 записей.  Давайте посмотрим, как изменится наш код на представлении с использованием данного подхода.
@{
    ViewBag.Title = "Home Page";
}

<link rel="stylesheet" href="http://code.jquery.com/ui/1.11.4/themes/smoothness/jquery-ui.css">
<script src="http://code.jquery.com/jquery-1.10.2.js"></script>
<script src="http://code.jquery.com/ui/1.11.4/jquery-ui.js"></script>

<div class="form-control-static">
    <div class="form-group">
        <label for="autocompleteCar">Enter car name: </label>
        <input id="autocompleteCar" class="form-control">
    </div>
    <div class="form-group">
        <input type="submit" id="submit" class="btn btn-info"/>
    </div>

    <div class="form-group" id="result-box">
        <label for="carName">Name: </label>
        <label id="carName" class="form-control"></label>
        <label for="carPrice">Price: </label>
        <label id="carPrice" class="form-control"></label>
        <label for="carSpeed">Price: </label>
        <label id="carSpeed" class="form-control"></label>
        <label for="carAcceleration">Acceleration: </label>
        <label id="carAcceleration" class="form-control"></label>
        <label for="carPower">Power: </label>
        <label id="carPower" class="form-control"></label>
        <label for="carDisplacement">Displacement: </label>
        <label id="carDisplacement" class="form-control"></label>
        <label for="carWeight">Weight: </label>
        <label id="carWeight" class="form-control"></label>
    </div>
</div>

<script type="text/javascript">
    $("#autocompleteCar").autocomplete({
        source: '@Url.Action("Autocomplete", "Home")',
        minLength: 1,
        width: 200
    });

    $(function () {
        $("#result-box").hide();
    })
</script>
Мы убрали весь байндинг, который у нас был, через knockoutjs и реализовали подгрузку данных через jQuery UI – метод autocomplete. Если вы посмотрите внимательно на код, то сможете увидеть, что стили и скрипты я подгружаю удаленно.
<link rel="stylesheet" href="http://code.jquery.com/ui/1.11.4/themes/smoothness/jquery-ui.css">
<script src="http://code.jquery.com/jquery-1.10.2.js"></script>
<script src="http://code.jquery.com/ui/1.11.4/jquery-ui.js"></script>
Все дело в том, что-то Autocomplete, который можно поставить через NuGet Package Manager, не подставился у меня. А причина достаточно проста.
Поэтому после долгих копаний на stackoverflow было принято решение сделать именно так. Тем более, это тестовое задание. Запустим наш проект и посмотрим на результат.
Как видите, список может быть очень большой, который неудобно скролить. Решить это можно двумя способами. Первый  изменить количество результатов, возвращаемых с сервера. Второй  изменить размер на клиенте.  Но это будет вам как домашнее задание. Тем более, что это сделать несложно. Теперь реализуем нашу кнопку submit, которая у нас висит без дела. Для этого перейдем в наш контроллер и добавим новый метод GetCarDetails, как показано в примере ниже.
public JsonResult GetCarDetails(string carName)
{
    if (string.IsNullOrEmpty(carName))
    {
        return Json(new { Success = false, ErrorMessage = "Car name is empty" }, JsonRequestBehavior.AllowGet);
    }
    var cars = _cacheService.GetOrSet("Cars", () => GetAllCars());

    var car = cars.FirstOrDefault(x => x.Name == carName);

    if (car == null)
        return Json(new { Success = false, ErrorMessage = "Car not found" }, JsonRequestBehavior.AllowGet);

    return Json(new { Success = true, Object = car }, JsonRequestBehavior.AllowGet);
}
Выше вы видите один из способов, как можно с помощью JsonResult вернуть ошибку на клиент. Есть и другие способы: например, добавить отдельно фильтр на это все дело, как показано здесь. После этого нужно поправить нашу клиентскую логу. Поскольку все изменения у нас затрагивают исключительно наш JavaScript code, нам нужно поправить именно этот блок.
<script type="text/javascript">
    $("#autocompleteCar").autocomplete({
        source: '@Url.Action("Autocomplete", "Home")',
        minLength: 1,
        width: 200
    });

    $("#result-box").hide();

    $('#submit').click(function (evt) {
        evt.preventDefault();

        $.ajax({
            url: '@Url.Action("GetCarDetails", "Home")',
            type: "POST",
            dataType: "json",
            data: { carName: $("#autocompleteCar").val() },
            success: function (data) {
                if (data.Success === true) {
                    if (data.Object != null) {
                        $("#carName").text(data.Object.Name);
                        $("#carPrice").text(data.Object.Price);
                        $("#carSpeed").text(data.Object.Speed);
                        $("#carAcceleration").text(data.Object.Acceleration);
                        $("#carPower").text(data.Object.Power);
                        $("#carDisplacement").text(data.Object.Displacement);
                        $("#carWeight").text(data.Object.Weight);
                        $("#result-box").show();
                    }
                }
                else { alert(data.ErrorMessage); }
            },
            error: function (xhr) {
                alert(xhr.responseText);
            }
        });
    });
</script>
Я добавил реализацию нажатия на кнопку submit, в которой вызывается отдельный ajax метод, который загружает все необходимые данные. Теперь запустим наш проект и посмотрим, что из этого получилось.

Итоги

В этой статье мы с вами с нуля реализовали пополнение autocomplete на jQuery UI и проанализировали работу json и knockoutjs; посмотрели, как работает dependency injection в Unity.Mvc, добавили кеширование и многое другое. В общем, мы попробовали всего понемногу. Что не было сделано в этой статье и что стоит упомянуть, – у нас не добавлена валидация, так как при незаполненной машине, доступна кнопка submit; также отсутствует форматирование для цены, мощности и т.д. Не хватает разделения на PartialView, чтобы можно было нормально форматировать наш UI. И еще много разных мелких деталей, которые следовало бы добавить, но поскольку человек не способен воспринимать много информации за один раз, поговорим об этом в одном из следующих материалов. Если статья вам понравилась, буду благодарен за ее расшаривание, чтобы, возможно, она помогла кому-то еще. Также отвечу на ваши вопросы в комментариях. Успешного изучения ASP.NET MVC.
Исходники к статье: AutocompleteSample

No comments:

Post a Comment