Статьи

Приложение Backbone.js со службой ODF для WCF

Сегодняшняя часть веб-приложения становится очень сложной. Клиенты ожидают функциональных высокопроизводительных приложений с красивым пользовательским интерфейсом, которые работают в браузере, как обычные настольные приложения. Чтобы выполнить эти требования, мы приходим к одностраничным приложениям (SPA). SPA заставляет нас писать много кода на стороне клиента (обычно это JavaScript). Многие клиентские скрипты со временем часто превращаются в неконтролируемый беспорядок.

Решение состоит в том, чтобы структурировать приложение с использованием клиентских сред. Backbone.js является одной из таких таблеток. Это JavaScript-фреймворк с открытым исходным кодом MVC. Разработчик должен описать модели и представления, настроить привязки событий, определить маршрутизацию и предоставить сервис данных (REST-подобный сервис JSON или XML) для своего приложения.

В этом уроке я хочу показать, как использовать Backbone.js и службу ODATA WCF для создания SPA. Я собираюсь использовать образец базы данных Microsoft Northwind. Приложение содержит три страницы: список категорий, список товаров из выбранной категории и сведения о товаре с возможностью его изменения.

Исходный код приложения доступен на GitHub:  https://github.com/tabalinas/BackboneOData

Вы готовы? Итак, начнем.

Подготовить заявку

Начните с создания простого веб-приложения ASP.NET с одной страницей Default.aspx в качестве главной страницы SPA.

Откройте Visual Studio и создайте пустое веб-приложение ASP.NET

Проект имеет следующую структуру

  • Сценарии — код JavaScript
  • Служба — содержит файлы сопоставления .edmx и файлы службы .svc
  • Стили — CSS стили
  • Default.aspx — главная страница SPA

WCF ODATA Сервис

В демонстрационных целях мы могли бы использовать одну из общедоступных служб ODATA, например, http://services.odata.org/Northwind/Northwind.svc . Но в этом случае одна и та же политика происхождения вызовет некоторые проблемы с манипулированием данными. Итак, давайте создадим нашу собственную службу ODF WCF.

Сначала создайте файл EDMX

Выберите «Создать из базы данных», настройте соединение с вашим MS SQL Server и выберите Northwind в качестве исходной базы данных. Затем выберите объекты БД, которые мы хотим отобразить. Для нашего приложения я выбрал категорию, продукт и поставщика.

Нажмите «Готово». Наш файл .edmx готов

Теперь создайте сервис данных WCF на основе созданного отображения. Дайте ему имя «Northwind.svc»

Измените код услуги следующим образом

public class Northwind : DataService<NORTHWNDEntities>
{
    // This method is called only once to initialize service-wide policies.
    public static void InitializeService(DataServiceConfiguration config)
    {
        // TODO: set rules to indicate which entity sets and service operations are visible, updatable, etc.
        // Examples:
        config.SetEntitySetAccessRule("*", EntitySetRights.All);
        config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V3;
    }
}

Поместите класс отображения в универсальный тип DataService (в нашем случае NORTHWINDEntities ). 

Предоставить доступ к объектам. Для демонстрации я дал полный доступ ко всем сущностям.

Сервис готов предоставить данные.

Основные компоненты

Как я уже упоминал, Backbone — это фреймворк MVC. Приложение Backbone имеет следующие компоненты:

  • Модель описывает сущности и бизнес-логику приложения.
  • Коллекция представляет собой партию сущностей. Это как модель другого типа, представляющая коллекцию конкретных моделей.
  • Вид — это визуальное представление конкретной модели. Здесь мы можем свободно использовать любой шаблонизатор. Подчеркнуть шаблоны являются наиболее распространенными, хотя.
  • Маршрутизатор похож на маршрутизацию в ASP.NET MVC только на стороне клиента. Он описывает навигацию приложения, маршруты и связанные методы обработки.

Меньше слов, больше действий. Давайте создадим страницу со списком доступных категорий продуктов от Northwind и покажем, как заставить все это работать вместе.

Список категорий

Категория Модель

app.Models.Category = Backbone.Model.extend({

    url: function() {
        return "Service/Northwind.svc/Categories(" + this.get("CategoryID") + ")";
    },

    idAttribute: "CategoryID",

    defaults: {
        CategoryID: 0,
        CategoryName: "",
        Description: ""
    }

});

Все модели расширяют Backbone.Model . приложение здесь наше корневое пространство имен приложения. Вы можете назвать это как хотите. app.Models — это пространство имен для всех моделей.

Свойства модели:

  • url возвращает URL, по которому сущность может быть достигнута на сервере. В нашем случае это «Service / Northwind.svc / Categories ({CategoryID})».
  • idAttribute сообщает, какое поле содержит идентификатор объекта.
  • По умолчанию это объект, содержащий поля модели со значениями по умолчанию.

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

Коллекция категорий

app.Models.CategoryList = Backbone.Collection.extend({

    url: "Service/Northwind.svc/Categories",
        
    model: app.Models.Category,
        
    load: function(callback) {
        this.fetch({
            success: callback
        });
    }

});

Коллекции должны расширять Backbone.Collection .

Свойства коллекции:

  • URL-адрес — это URL-адрес коллекции сущностей на сервере.
  • Модель является ссылкой на родственный класс модели. Свойство определяет элементы, которые содержат коллекцию типов.
  • load — выборочный метод выборки коллекции методовс успешным обратным вызовом. Этот метод инкапсулирует выборку коллекции, поэтому мы можем легко изменить ее в будущем (например, добавить фильтрацию / сортировку или что-то еще).

Вид по категориям

app.Views.CategoryListView = Backbone.View.extend({

    template: _.template($("#categoryListViewTemplate").html()),

    className: "category-list-view",

    render: function() {
        this.$el.html(this.template(this.model.toJSON()));
        return this;
    }

});

Каждое представление расширяет Backbone.View .

Посмотреть свойства:

  • template — это настраиваемое поле (не принадлежит Backbone.View ), ссылающееся нашаблон Underscore представления.
  • className — это имя класса CSS, добавляемое в контейнер корневых тегов представления. Div используется по умолчанию, но мы можем переопределить его, указавсвойство tagName .
  • render — основной метод представления, выполняющего фактическое отображение. В нашем случае мы помещаем результат рендеринга шаблона в контейнер корневого представления.

Default.aspx и Underscore Просмотреть шаблон

Default.aspx должен иметь следующую разметку

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="BackboneApp.Default" %>

<!DOCTYPE html>

<html>
<head runat="server">
    <title>Backbone.js with OData Demo Application</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="Styles/bootstrap.css" rel="stylesheet" />
    <link href="Styles/style.css" rel="stylesheet" />
</head>
<body>
    <!-- app page -->
    <form id="form1" runat="server">
        <header class="container">
            <h3 id="title"></h3>
        </header>
        <div id="content" class="container">
        </div>
    </form>

    <!-- templates -->
    <script id="categoryListViewTemplate" type="text/template">
        <div class="well">
            <ul class="list">
            {% _.each(this.model.models, function(category) { %}
                <li>
                    <h4><a href="#category/{{ category.get("CategoryID") }}">{{ category.get("CategoryName") }}</a></h4>
                    <p>{{ category.get("Description") }}</p>
                </li>
            {% }); %}
            </ul>
        </div>
    </script>

    <!-- scripts -->
    <script src="Scripts/jquery-1.7.1.js"></script>
    <script src="Scripts/underscore.js"></script>
    <script src="Scripts/backbone.js"></script>
    
    <script src="Scripts/App.js"></script>

    <script src="Scripts/Models/Category.js"></script>
    <script src="Scripts/Models/CategoryList.js"></script>
    
    <script src="Scripts/Views/CategoryListView.js"></script>
</body>
</html>

Тело Default.aspx состоит из трех разделов. Первая — разметка страницы приложения. Второй раздел — определение шаблонов. Третий имеет ссылки на необходимые скрипты.

Разметка страницы приложения — это форма, содержащая два дочерних html-элемента: заголовок с заголовком и основной div для разметки содержимого.

Шаблон для списка категорий — это ul с li для каждой категории. Модель для шаблона — это коллекция, поэтому мы должны перебрать  массив model.models, чтобы отобразить каждую категорию. Название категории — это ссылка на список товаров в категории. Я использовал  Bootstrap , чтобы сделать демо повеял, так что есть некоторые классы конкретных CSS любят хорошо .

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

Невозможно использовать шаблон Underscore с настройками по умолчанию на странице aspx, поскольку он содержит конфликтующие с тегами управления синтаксиса asp <%%>. Чтобы решить проблему, нам нужно выполнить следующий код перед запуском приложения.

// underscore template settings to prevent conflict with ASP tags <% %>
_.templateSettings = {
    interpolate: /\{\{(.+?)\}\}/g,      // print value: {{ value_name }}
    evaluate: /\{%([\s\S]+?)%\}/g,      // execute code: {% code_to_execute %}
    escape: /\{%-([\s\S]+?)%\}/g        // excape HTML: {%- <script> %} prints <script>s
};

В разделе скриптов есть ссылки на необходимые скрипты. Магистраль требует jQuery (или Zepto) и Underscore (или Lo-Dash). App.js является основным скриптом приложения. Также есть скрипты наших моделей и видов.

App.js и маршрутизация

Пространство имен и маршрутизация корневого приложения определены в App.js. Метод app.renderView визуализирует представление Backbone и помещает результат в div содержимого страницы.

// root namespace for application
window.app = {
    Models: {},

    Views: {},

    renderView: function(view, title) {
        $("#content").html(view.render().el);
        $("#title").text(title);
    }
};

// app router
app.ApplicationRouter = Backbone.Router.extend({

    routes: {
        "": "categoryList"
    },

    categoryList: function() {
        var categoryList = new app.Models.CategoryList(),
            categoryListView = new app.Views.CategoryListView({ model: categoryList });

        categoryList.load(function() {
            app.renderView(categoryListView, "Categories List");
        });
    }

});

Приложение роутер расширяет Backbone.Router . Маршруты объектов — это карта между шаблонами URL и методами, обрабатывающими запросы к этим URL. В нашем случае мы говорим, что пустой URL ссылается на метод categoryList. Метод categoryList создает модель и соответствующий вид. Затем он получает коллекцию с сервера и отображает представление.

В конце мы должны создать экземпляр маршрутизатора и запустить приложение. Это должно произойти на мероприятии готового документа.

// start app
$(function() {
    app.router = new app.ApplicationRouter();

    Backbone.history.start();
});

OData связанные настройки

К сожалению! Запускаем приложение и ничего не видим. Потому что наш запрос к службе OData не выполнен. Нам нужно сделать некоторые настройки, чтобы это работало.

Backbone использует функцию jQuery.ajax для отправки ajax-запроса на сервер. Добавьте специальный предварительный фильтр ajax.

// prepare request according to ODATA
$.ajaxPrefilter(function(options, originalOptions, xhr) {
    // set required HTTP headers
    options.contentType = "application/json; odata=verbose";
    options.headers = $.extend(options.headers || {}, {
        Accept: "application/json; odata=verbose"
    });

    // add default error handler
    var originalError = originalOptions.error;
    options.error = function(xhr, textStatus, errorThrown) {
        alert("Error during processing the request!");
        if(originalError) {
            originalError.apply(this, arguments);
        }
    };
        
    // patch success handler to retrieve data from ODATA json response
    var originalSuccess = options.success;
    if(originalSuccess && originalOptions.type === "GET") {
        options.success = function(data) {
            originalSuccess(data["d"]);
        };
    }
});

Предварительный фильтр добавляет contentType и принимает заголовок к запросу, переносит обработчик ошибок и исправляет обработчик успеха, чтобы получить результат json из ответа службы ODF WCF (поле d  ответа json).

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

Список продуктов

Теперь давайте создадим второй вид со списком товаров из выбранной категории.

Модель продукта

app.Models.Product = Backbone.Model.extend({

    url: function() {
        return "Service/Northwind.svc/Products(" + this.get("ProductID") + ")";
    },

    idAttribute: "ProductID",

    initialize: function(attributes) {
        this.set({ UnitPrice: parseFloat(attributes.UnitPrice) });
    },

    defaults: {
        ProductID: 0,
        ProductName: "",
        UnitPrice: 0.0
    }

});

Метод initialize является конструктором экземпляра модели. Входной параметр — это объект с атрибутами. В конструкторе мы анализируем значение цены за единицу товара, полученное с сервера, в виде строки.

Коллекция продуктов

app.Models.ProductList = Backbone.Collection.extend({

    url: "Service/Northwind.svc/Products",

    model: app.Models.Product,
        
    loadByCategory: function(categoryId, callback) {
        this.fetch({
            data: { "$filter" : "CategoryID eq " + categoryId },
            success: callback
        });
    }

});

Список товаров выбирается по категориям. Метод loadByCategory добавляет новый параметр $ filter в запрос, чтобы мы получали продукты из определенной категории.

Список товаров

app.Views.ProductListView = Backbone.View.extend({

    template: _.template($("#productListViewTemplate").html()),

    className: "product-list-view",
        
    render: function() {
        this.$el.html(this.template(this.model.toJSON()));
        return this;
    },
        
    events: {
        "click #backToCategories": "backToCategories"
    },

    backToCategories: function() {
        app.router.navigate("/", { trigger: true });
    }
});

Это представление содержит кнопку, которая возвращает нас на страницу со списком категорий. Чтобы добавить такую ​​логику в представление, я использую объект events , который отображает события в методы обработки. У события есть имя (щелчок) и селектор, к которому нужно прикрепить обработчик ( #backToCategories — кнопка с идентификатором backToCategories). Наш обработчик событий заставляет маршрутизатор перейти к корневому URL-адресу (страница с категориями).

Шаблон списка товаров

<script id="productListViewTemplate" type="text/template">
    <p><input id="backToCategories" type="button" value="< Back to Categories" class="btn" /></p>
    <div class="well">
        <ul class="list">
        {% _.each(this.model.models, function(product) { %}
            <li>
                <h4><a href="#product/{{ product.get("ProductID") }}">{{ product.get("ProductName") }}</a></h4>
                <p>Price: ${{ product.get("UnitPrice").toFixed(2) }}</p>
            </li>
        {% }); %}
        </ul>
    </div>
</script>

Здесь мы перебираем список продуктов и отображаем их по форматированной цене за единицу.

Список продуктов Маршрутизация

routes: {
    "": "categoryList",
    "category/:id": "productList"
},
...
productList: function(id) {
    var productList = new app.Models.ProductList(),
        productListView = new app.Views.ProductListView({ model: productList });

    productList.loadByCategory(id, function() {
        app.renderView(productListView, "Products List");
    });
},

Маршрут для списка товаров добавлен. В  шаблоне URL есть параметр : id . Значение параметра будет передано в функцию обработки.

Если мы запустим приложение и откроем категорию, то увидим список продуктов.

информация о продукте

Последняя страница — это информация о продукте, где пользователь может изменить название и цену продукта.

Модификация модели продукта

app.Models.Product = Backbone.Model.extend({

    url: function() {
        return "Service/Northwind.svc/Products(" + this.get("ProductID") + ")";
    },

    idAttribute: "ProductID",

    initialize: function(attributes) {
        this.set({ UnitPrice: parseFloat(attributes.UnitPrice) });
    },

    defaults: {
        ProductID: 0,
        ProductName: "",
        UnitPrice: 0.0
    },
        
    load: function(callback) {
        this.fetch({
            success: callback
        });
    }

});

Добавлен новый метод загрузки  для инкапсуляции выборки продукта.

Подробнее о продукте

app.Views.ProductDetailsView = Backbone.View.extend({

    template: _.template($("#productDetailsViewTemplate").html()),

    className: "product-details-view",
                
    render: function() {
        this.$el.html(this.template(this.model.toJSON()));
        return this;
    },

    events: {
        "click #backToProducts": "backToProducts",
        "click #saveProduct": "saveProduct",
    },

    backToProducts: function() {
        window.history.back();
    },

    saveProduct: function() {
        var message = this.$el.find("#message").empty();

        this.model.save({
            ProductName: $('#ProductName').val(),
            UnitPrice: $('#UnitPrice').val(),
        }, {
            success: function() {
                message.append($("<div />").addClass("alert alert-success").text("Product info successfully saved!"));
            },
            error: function() {
                message.append($("<div />").addClass("alert alert-error").text("Error occurred while saving product info!"));
            }
        });
    }

});

Наиболее интересным здесь является метод saveProduct . Он принимает данные от входов и требует сохранения метода модели. Также отображается сообщение о состоянии результата операции.

Подробнее о продукте Посмотреть шаблон

Шаблон представления сведений о продукте — из полей продукта.

<script id="productDetailsViewTemplate" type="text/template">
    <p><input id="backToProducts" type="button" value="< Back to Products" class="btn" /></p>
    <div class="well">
        <fieldset>
            <label>Product ID:</label>
            <input type="text" id="ProductID" disabled value="{{ ProductID }}">

            <label>Product Name:</label>
            <input type="text" id="ProductName" value="{{ ProductName }}">

            <label>Unit Price:</label>
            <input type="text" id="UnitPrice" value="{{ UnitPrice }}">
        </fieldset>
        <div id="message"></div>
        <input id="saveProduct" type="button" value="Save" class="btn-primary btn" />
    </div>
</script>

Детали продукта Маршрутизация

Путь к деталям продукта аналогичен маршруту списка продуктов.

routes: {
    "": "categoryList",
    "category/:id": "productList",
    "product/:id": "productDetails"
},
...
productDetails: function(id) {
    var product = new app.Models.Product({ ProductID: id }),
        productDetailsView = new app.Views.ProductDetailsView({ model: product });

    product.load(function() {
        app.renderView(productDetailsView, "Product \"" + product.get("ProductName") + "\"");
    });
}

Теперь, если мы откроем продукт из списка, отредактируем название и нажмем «Сохранить», мы получим следующий результат

Демонстрационное приложение довольно простое, но оно демонстрирует, как мы можем построить архитектуру клиентского кода с помощью Backbone.js. Также мы увидели, как создать WCF OData Service и заставить его работать с Backbone.