HTML 5: Пример использования knockout, amplify и underscore или JsSite как стартовая архитектура для сайта (обновление)

Сайтостроение | создано: 21.02.2013 | опубликовано: 21.02.2013 | обновлено: 13.01.2024 | просмотров: 10511

Речь пойдет о разработке сайтов на HTML5. Мы все прекрасно знаем, что HTML5 это ничто иное как HTML4 + CSS3 + JavaScript, причем HTML4 дополненный некоторым количеством новых тегов. Спецификация по HTML5 в стадии рассмотрения и далека от своего утверждения. В статье описан js-контрол DataSource и все "зачем", "как" и "почему".

Цель

В одной из прошлых статьей было рассказано о nuget-пакете под названием JsSite. В последнее время достаточно часто и продуктивно пришлось работать с этим пакетом, и как  следствие, сам пакет претерпел большое количество изменений. Цель данной статьи, описать возможности (в том числе и новые), которые предоставляет данный набор скриптов.

Еще раз хочу предупредить, что JsSite всего лишь простой пример построения архитектуры сайта с использованием knockoutjs, amplifyjs, moment и других полезных библиотек на JavaScript. Но этот пакет влючены некоторые полезные, по моему мнению, контролы, в частности DataSource? о котором и пойдет речь в этой статье.

Пример использования или How to use.

В примере будем строить MVC приложение, которое будет отображать список сотрудников (возьмем из nuget-пакета SampleData) с использованием AJAX, Web API и knockoutjs. Ключевой момент в том, чтобы не просто отображать данные, а разбить их на страницы, подключить простейший фильтр, и возможность задавать количество записей на странице.

Более того, очень хочется один раз написать сервис для работы с сущностью, а потом по возможности использовать его на разных страницах и/или в разных запросах (в том числе типа “Master/Details”).

Подготовка к работе

Создаем новое ASP.NET MVC приложение. Я выбрал новый (появился после  ASP.NET and Web Tools 2012.2) шаблон MVC4 Basic, в нем папки Controller и Model пусты. Проект я назову JsSitePackageDemo2, а вы как посчитаете нужным. Запустим обновление всех предустановленных пакетов, выполнив команду update-package в Package Manager Console. И после этого поставим несколько дополнительных пакетов:

1) jssite:

PM> Install-Package jssite
Attempting to resolve dependency 'toastr (≥ 1.1.4.2)'.
Attempting to resolve dependency 'jQuery (≥ 1.6.3)'.
Attempting to resolve dependency 'AmplifyJS (≥ 1.1.0)'.
Attempting to resolve dependency 'knockoutjs (≥ 2.2.1)'.
Attempting to resolve dependency 'Knockout.Mapping (≥ 2.4.0)'.
Attempting to resolve dependency 'underscore.js (≥ 1.4.3)'.
Attempting to resolve dependency 'Moment.js (≥ 1.7.2)'.
Successfully installed 'toastr 1.1.5'.
Successfully installed 'AmplifyJS 1.1.0'.
Successfully installed 'Knockout.Mapping 2.4.0'.
You are downloading underscore.js from Jeremy Ashkenas, the license agreement to which is available at https://github.com/documentcloud/underscore/blob/master/LICENSE. Check the package for additional dependencies, which may come with their own license agreement(s). Your use of the package and dependencies constitutes your acceptance of their license agreements. If you do not accept the license agreement(s), then delete the relevant components from your device.
Successfully installed 'underscore.js 1.4.4'.
Successfully installed 'Moment.js 1.7.2'.
Successfully installed 'JsSite 0.4.2'.
Successfully added 'toastr 1.1.5' to JsSitePackageDemo2.
Successfully added 'AmplifyJS 1.1.0' to JsSitePackageDemo2.
Successfully added 'Knockout.Mapping 2.4.0' to JsSitePackageDemo2.
Successfully added 'underscore.js 1.4.4' to JsSitePackageDemo2.
Successfully added 'Moment.js 1.7.2' to JsSitePackageDemo2.
Successfully added 'JsSite 0.4.2' to JsSitePackageDemo2.

PM>

2) SampleData:

PM> Install-Package SampleData
Successfully installed 'SampleData 1.2.2'.
Successfully added 'SampleData 1.2.2' to JsSitePackageDemo2.

PM>

Подключаю новые CSS, которые появились с установленным пакетом JsSite (см. строка 2):

bundles.Add(new StyleBundle("~/Content/css").Include("~/Content/site.css"
    , "~/Content/toastr.css", "~/Content/site.pages.css"));

Можно теперь приступить непосредственно к кодированию. Если учесть, что контролеров в этом шаблоне нет, а в файле RouteConfig.cs прописан маршрут на контролер Home:

routes.MapRoute(
    name: "Default",
    url: "{controller}/{action}/{id}",
    defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
);

Назревает вопрос, зачем разработчики оставили его или зачем удалил Home контролер? Ну, да ладно, суть не в этом. Меняю наименование контролера на “Site” и создаю новый контролер с этим названием.

jssite-demo2-1

Добавлю к методу Index представление (View). На этом представлении и будем упражняться в написании кода. Первым делом добавлю ссылки на скрипты библиотек в секцию scripts. Если учесть что jQuery уже установлена (bundle/jquery в шаблоне), то мне остается добавить ссылки на сторонние библиотеки и на скрипты из пакета JsSite:

@section scripts
{
    <!-- third-party library -->
    <script src="~/Scripts/toastr-1.1.5.min.js"></script>
    <script src="~/Scripts/amplify.min.js"></script>
    <script src="~/Scripts/moment.min.js"></script>
    <script src="~/Scripts/underscore.min.js"></script>
    <script src="~/Scripts/knockout-2.2.1.js"></script>
    <script src="~/Scripts/knockout.mapping-latest.js"></script>

    <!-- jssite and project library -->
    <script src="~/Scripts/app/site.core.js"></script>
    <script src="~/Scripts/app/site.bindingHandlers.js"></script>
    <script src="~/Scripts/app/site.controls.js"></script>
}

Я пока не заморачиваюсь на оптимизацию скриптов: “склеивание” и “сжатие”, но в реальном проекте без этого не обойтись. Один из вариантов решения был описан ранее.

В папке App есть еще один файл, который я не добавил на страницу. Дело в том, что этот файл всего лишь пример написания сервиса для DataSource. Мы займемся написанием сервиса чуть позже. Сначала серверная часть.

Web API + OData

Создаем новый API-контролер назовем его PersonController:

jssite-demo2-2

Не обойдите вниманием, шаблон – “API controller…”. Вот так выглядит код этого контролера после некоторых доработок причем несложных, но не окончательных:

public class PersonController : ApiController {
    private readonly List<Person> _list;

    public PersonController() {
        _list = People.GetPeople();
    }

    // GET api/person
    public IEnumerable<Person> Get() {
        return _list;
    }

    // GET api/person/5
    public string Get(int id) {
        return "value";
    }

    // POST api/person
    public void Post([FromBody]string value) {
    }

    // PUT api/person/5
    public void Put(int id, [FromBody]string value) {
    }

    // DELETE api/person/5
    public void Delete(int id) {
    }
}

Строка 2: создаем переменную для хранения списка пользователей. Не забудьте добавить namespace SampleData.

Строки 4-6: в конструкторе наполняем список.

Если воспользоваться прекрасной утилитой Fiddler, то можно протестировать сервис, отправив запрос:

jssite-demo2-3

и получить ответ в:

jssite-demo2-4

OData – теперь это просто

Небольшое лирическое отступление. Протокол OData теперь есть в MVC4 (не полная реализация, но и это уже не мало). Для того, чтобы превратить наш PersonController в контролер, который будет понимать OData запросы, надо установить еще один nuget-пакет Microsoft.AspNet.WebApi.OData, который добавит магический атрибут Queryable.

PM> Install-Package Microsoft.AspNet.WebApi.OData
Attempting to resolve dependency 'Microsoft.Net.Http (≥ 2.0.20710.0 && < 2.1)'.
Attempting to resolve dependency 'Microsoft.AspNet.WebApi.Client (≥ 4.0.20710.0 && < 4.1)'.
Attempting to resolve dependency 'Newtonsoft.Json (≥ 4.5.6)'.
Attempting to resolve dependency 'Microsoft.AspNet.WebApi.Core (≥ 4.0.20710.0 && < 4.1)'.
Attempting to resolve dependency 'Microsoft.Data.OData (≥ 5.2.0 && < 5.3.0)'.
Attempting to resolve dependency 'System.Spatial (= 5.2.0)'.
Attempting to resolve dependency 'Microsoft.Data.Edm (= 5.2.0)'.
**** cutted ****
Successfully installed 'Microsoft.AspNet.WebApi.OData 4.0.0'.
Successfully added 'System.Spatial 5.2.0' to JsSitePackageDemo2.
Successfully added 'Microsoft.Data.Edm 5.2.0' to JsSitePackageDemo2.
Successfully added 'Microsoft.Data.OData 5.2.0' to JsSitePackageDemo2.
Successfully added 'Microsoft.AspNet.WebApi.OData 4.0.0' to JsSitePackageDemo2.

PM>

Если вы не знакомы с протоколом OData, то советую ознакомиться на упомянутом выше сайте. Там же вы можете найти документацию по использованию (спецификацию). После установки пакета можно поставить атрибут [Queryable] над методом Index в контролере PersonController.

[Queryable]
public IEnumerable<Person> Get() {
    return _list;
}

Что это дает? Это на самом деле, не побоюсь этого слова, “революционная” технология построения запросов непосредственно в строке браузера, то есть формировать запросы к БД можно из строки браузера (!) напрямую. Спецификация протокола очень большая, чтобы рассказать о ней в одном маленьком абзаце, но чтобы было понятен принцип, ознакомьтесь со схемой:

jssite-demo2-5

Начало развития OData берет в холодном феврале 2009 года, но на сколько я помню, такой принцип обработки запросов был озвучен еще раньше – в октябре 2007 года (проект “Астория”). На данный момент не все команды по спецификации поддерживаются в ASP.NET MVC 4 Web.API, а список поддерживаемых вы можете найти на ASP.NET.

Прелесть данного подхода в том, что “запрос выполняется непосредственно на SQL сервере”. Это лирическое отступление имело место быть, потому что вчера Microsoft.AspNet.WebApi.OData вышел в статусе Release под номер версии 4.0.0.

Web API

Вернемся к наши Web.API. Для того, чтобы сервис смог вернуть данные разбитые на страницы (это же наша цель, правда?), надо в метод Get() “опустить” как минимум один параметр – номер страницы (pageIndex), а если вы хотите управлять количеством записей на странице со стороны клиента, то второй параметр должен быть – размер страницы (pageSize). У вас не получится без перенастройки Web.API маршрутов “протолкнуть” упомянутые параметры в метод вызова. Настроим маршрут. Я добавил один новый маршрут (в файле WebApiConfig) для того, чтобы Web.API стал “понимать” новые параметры “номер страниц” и “размер страниц” (см. строки 3-7), а не только идентификатор (Id см. строки 9-12) :

public static class WebApiConfig {
    public static void Register(HttpConfiguration config) {
        config.Routes.MapHttpRoute(
            name: "PersonApi",
            routeTemplate: "api/{controller}/{index}-{size}",
            defaults: new { index = 0, size = 10 }
        );

        config.Routes.MapHttpRoute(
            name: "DefaultApi",
            routeTemplate: "api/{controller}/{id}",
            defaults: new { id = RouteParameter.Optional }
        );
    }
}

Строке 5: обратите внимание на “черточку”, она нам пригодится позже (вообще-то она для “красоты”, чтобы было проще ссылаться в статье). После того как маршруты проложены настроены, можно поработать над методом Get контролера PersonController.

public HttpResponseMessage Get(int? index, int? size) {
    var items = _list.AsQueryable();
    if (index.HasValue && size.HasValue) {
        items = items.Skip(index.Value * size.Value)
            .Take(size.Value);
    }
    if (items.Any()) {
        var data = items.ToArray();
        var result = new ApiResult { Items = data, Total = _list.Count() };
        return Request.CreateResponse(HttpStatusCode.OK, result);
    }
    return Request.CreateResponse(HttpStatusCode.BadRequest); ;
}

Строка 1: Возвращаем не просто коллекцию объектов, а обернутую в специальный класс HttpResponseMessage, который дает множество полезных штучек, как например, статус операции запроса. А также добавляем параметры в сигнатуру метода.

Строка 3-6:  Если “номер страницы” и  “размер страницы” получены, осуществляем выборку.

Строка 9: Создаем возвращаемый объект (см. следующий листинг). Можно использовать и анонимный тип, но нравится типизация:

public class ApiResult {
   public IEnumerable<Person> Items { get; set; }
   public int Total { get; set; }
}

Далее по листингу метода Get().

Строка 10: Возвращает полученный результат с указанием статуса. Обратите внимание на параметр “Items” и “Total”. Для того, чтобы пейджер заработал, ему надо знать сколько всего записей. Эти параметры использует site.controls.DataSource() из пакета JsSite.

Следующим этапом – JavaScript!

Модель сервиса site.services.person.js

Раз уже API-сервис готов, то время пришло для js-сервиса. При установке пакета JsSite в папке App также появляется файл site.services.js. Как уже говорилось, это демонстрационный пример сервиса, который нужен для работы site.controls.DataSource().  Я его переработал, адаптировав под класс Person, и поменял его название. Теперь он называется site.services.person.js и содержит он много строк. Методы addPerson, updatePerson, deletePerson я не стал реализовывать, но я всё равно приведу файл целиком, а после разберем по строкам:

(function (site) {

    "use strict";

    site.services.init = function () {

        //#region service Person
        site.amplify.request.define("loadPerson", "ajax", {
            url: "api/Person/{0}-{1}",
            dataType: "json",
            type: "GET"
        }),
         site.amplify.request.define("addPerson", "ajax", {
             url: "api/Person/",
             dataType: "json",
             cache: false,
             type: "POST"
         }),
         site.amplify.request.define("updatePerson", "ajax", {
             url: "api/Person",
             dataType: "json",
             cache: false,
             type: "PUT"
         }),
         site.amplify.request.define("deletePerson", "ajax", {
             url: "api/Person",
             dataType: "json",
             cache: false,
             type: "DELETE"
         });
        //#endregion

    }();

    site.services.person = function () {
        var
            loadPerson = function (params, back) {
                return site.amplify.request({
                    resourceId: "loadPerson",
                    data: {index: params.index(), size: params.size()},
                    success: function (json) {
                        //  -> here you code json-data processing <-
                        if (json && json.Items) {
                            site.logger.success("Loaded: "
                                + json.Items.length
                                + " Total: " + json.Total);
                        }
                        if (typeof back == "function") {
                            params.total(json.Total);
                            back.call(this, json.Items);
                        }
                    },
                    error: function (message, status) {
                        site.logger.error(message, status);
                        back.call(this);
                    }
                });
            },
            addPerson = function (jsonPerson) {
                return site.amplify.request({
                    resourceId: "addPerson",
                    data: jsonPerson,
                    success: function (json, status) {
                        //  -> here you code json-data processing <-
                        if (typeof back == "function") {
                            back.call(this, json);
                        }
                    },
                    error: function (message, status) {
                        site.logger.error(message, status);
                        back.call(this);
                    }
                });
            },
            updatePerson = function (jsonPerson) {
                return site.amplify.request({
                    resourceId: "updatePerson",
                    data: jsonPerson,
                    success: function (json, status) {
                        //  -> here you code json-data processing <-
                        if (typeof back == "function") {
                            back.call(this, json);
                        }
                    },
                    error: function (message, status) {
                        site.logger.error(message, status);
                        back.call(this);
                    }
                });
            },
            deletePerson = function (jsonPerson) {
                return site.amplify.request({
                    resourceId: "deletePerson",
                    data: jsonPerson,
                    success: function (json, status) {
                        //  -> here you code json-data processing <-
                        if (typeof back == "function") {
                            back.call(this, json);
                        }
                    },
                    error: function (message, status) {
                        site.logger.error(message, status);
                        back.call(this);
                    }
                });
            };

        return {
            load: loadPerson,
            put: updatePerson,
            post: addPerson,
            del: deletePerson
        };

    }();

})(site);

Внимание: Не забудьте добавить ссылку на этот скрипт на Index.cshtml.

Итак, что же делает этот код? По порядку.

Строка 5-33: Инициализируем сервис. Настройка amplify под работу с Web.API. Вот тут-то и пригодилась “галочка” (см. строка 9).

Строка 35-106: Сервис для работы с сущностью Person. Все действия в одном месте и, что самое главное, одни раз! Далее DataSource будет брать этот сервис и работать с его методами.

Ах, да! Самое главное! Вы можете приватные методы называть как хотите, а вот наружу должны быть “выставлены” методы именно с таким название как указано в строке 109-112: “load”, “put”, “post” “del”. Это важно!

Получение данных или Load Data Method

Строки 37-58 в предыдущем листинге задает метода получения списка пользователей. В этом методе используется название идентификатор “loadPerson”, который был инициализирован в ранее в строках 8-11. В строке 40 полученные параметры из DataSource (“index” и “size”) передаем в запрос, amplifyjs расставит параметры в соответствии с указание (см. строка 9) через “черточку”.

Строка 41-56: Обработчики полученных данных. В строках 43-47 проверяем полученных объект и выдаем сообщение, а далее в строках 48-51 возвращаем полученные данные (json.Items) в DataSource.

Строка 55: Если возникает ошибка сервиса, то возвращаем в DataSource “ничего” :)

А где же DataSource или покажите ViewModel представления

Самым простым кодом, в примере использования будет js-viewModel для моей страницы Index.cshtml. По большому счету, он содержит всего один контрол – DataSource, именно к нему и осуществляется привязка на форма (в следующем абзаце). Вот ViewModel:

$(function () {

    "use strict";

    site.vm.viewModel = function () {
        var clock = new site.controls.Clock(),
            meta = new site.fw.Metadata(
                "Demo JsSite",
                "Демонстрация работы библиотеки",
                "http://www.calabonga.net"),
            ds = new site.controls.DataSource(
                {
                    autoLoad: true,
                    service: site.services.person
                }
            );

        return {
            ds: ds,
            meta: meta,
            clock: clock
        };
    }();

    ko.applyBindings(site.vm.viewModel);

});

Строка 6: Создаем объект “часы”. Я его добавляю на форму обычно первым, чтобы перед тем как начать программирование проверить, что скрипты правильно подключены, инициализированы и привязка HTML (applyBindings) настроена корректно.

Строка 7: Создаем для красоты объект “метаданные”.

Строка 11: И, наконец, создаем тот самый объект “DataSource”, в конструктор которого “опускаем” параметры “autoLoad” со значением “true”, кстати, это значение по умолчанию. А вторым параметром “service” значение которого, как раз и является наш person-сервис, описанный выше.

Все параметры и возможности DataSource планируется описать в следующей статье.

Вернемся на форму

Для того, чтобы полученные данные отобразились на представлении, изменим содержание Index.cshtml. Добавим разметку, обратите внимание, что объект привязки DataSource в строке 14 и 23:

<h2 data-bind="text: meta.title"></h2>
<p data-bind="text: meta.description"></p>
<div data-bind="text: clock.time"
     style="color:#888; position: fixed; top:20px; left:50%;"></div>


<table>
    <thead>
        <tr>
            <th>Name</th>
            <th>Age</th>
            <th>Country</th>
        </tr>
    </thead>
    <tbody data-bind="foreach: ds.items">
        <tr>
            <td><span data-bind="text: Name"></span></td>
            <td><span data-bind="text: Age"></span></td>
            <td><span data-bind="text: Country"></span></td>
        </tr>
    </tbody>
</table>

<div data-bind="pager: ds"></div>

И как результат:

jssite-demo2-6

Зависимости или Master/Details

Я создал API-сервиc и js-сервис для сущности “Department” (тоже есть в SampleData), и немного поправил ViewModel, чтобы при выборе подразделения менялся список сотрудников:

$(function () {

    "use strict";

    site.vm.viewModel = function () {
        var clock = new site.controls.Clock(),
            meta = new site.fw.Metadata(
                "Demo JsSite",
                "Демонстрация работы библиотеки",
                "http://www.calabonga.net"),
            queryParams = { DepartmentId: ko.observable(), size:4 },

            ds = new site.controls.DataSource(
                { autoLoad: false, service: site.services.person },queryParams),

            ds2 = new site.controls.DataSource({
                service: site.services.department
            }),

            selectDepartment = function (item) {
                ds.queryParams.DepartmentId(item.Id);
            };

        ds.queryParams.DepartmentId.subscribe(function() {
            ds.load();
        });

        return {
            select: selectDepartment,
            ds2: ds2,
            ds: ds,
            meta: meta,
            clock: clock
        };
    }();

    ko.applyBindings(site.vm.viewModel);

});

Конечно же, пришлось поправить маршруты, чтобы новый параметр “departmentId” был доступен для PersonController. Да и сам метод Get у PersonController’а пришлось доработать, чтобы он “понимал” новый параметр и заработала связка “мастер/детали”.

Итак, в строке 11 создал параметр queryParams для DataSource, а в строка 14 его использую. В этой же строке отключена загрузка по умолчанию (autoLoad: false) для списка сотрудников. В queryParams также переопределен размер страниц (size) по умолчанию (равен 10) на новый размер 4.

Строки 16-18: Создал DataSource для сущности Department.

Строки 20-22: Функция обработки сlick по строке в таблице подразделений (см. листинг ниже строка 11).

Строки 29 и 30 выставляют новые классы и переменные наружу.

Новое представление

Добавил таблицу с подразделениями (строк 2-17).

<h2>Подразделения</h2>
<table>
    <thead>
        <tr>
            <th>Id</th>
            <th>Name</th>
            <th>Description</th>
        </tr>
    </thead>
    <tbody data-bind="foreach: ds2.items">
        <tr data-bind="click: $root.select" style="cursor:pointer;" >
            <td><span data-bind="text: Id"></span></td>
            <td><span data-bind="text: Name"></span></td>
            <td><span data-bind="text: Description"></span></td>
        </tr>
    </tbody>
</table>
<h2>Сотрудники</h2>
<div data-bind="pager: ds2"></div>

<table>
    <thead>
        <tr>
            <th>Id</th>
            <th>Department</th>
            <th>Name</th>
            <th>Age</th>
            <th>Country</th>
        </tr>
    </thead>
    <tbody data-bind="foreach: ds.items">
        <tr>
            <td><span data-bind="text: Id"></span></td>
            <td><span data-bind="text: DepartmentId"></span></td>
            <td><span data-bind="text: Name"></span></td>
            <td><span data-bind="text: Age"></span></td>
            <td><span data-bind="text: Country"></span></td>
        </tr>
    </tbody>
</table>

<div data-bind="pager: ds"></div>

jssite-demo2-7

Заключение

Что имеем в результате? Вся базовая логика работы с сущностью пишется один раз в одном месте. Достаточно гибкий способ устанавливать зависимости обеспечивает большой функционал при помощи knockout. Если кого-то заинтересовала разработка, то темой следующей статьи можно сделать “параметры, методы, договоренности при использовании DataSource” или вообще выложить DataSource, например, на github.com. В дальнейшем, планируется доработка DataSource для работы по протоколу OData, о котором было упомянуто в статье.