Windows Azure Mobile Services custom API на примере скрам-доски

в 20:08, , рубрики: cloud hosting, javascript, windows azure, windows azure mobile services, windows azure storage, метки: , , , ,

В этой статье я хочу поделиться опытом создания велосипеда простой скрам-доски с бекендом в облаке на базе Windows Azure Mobile Services и Azure Cloud Storage.

Стоит сразу предупредить придирчивых читателей, что основным приоритетом была простота и минимизация времени. В конце концов, этот немаловажный артефакт гибкой разработки — всего лишь белая доска с бумажками, поэтому и в виртуальном аналоге будет только базовая функциональность.
Платформа Windows Azure была выбрана не случайно. Несмотря на то, что Azure, как и любая другая облачная платформа, не из дешёвых, в нашем сценарии, когда доской будет пользоваться небольшая agile команда, это решение не будет стоить почти ничего.
Статья в большей степени посвящена тому, что можно получить от Windows Azure, особенно если у вас уже есть подписка.
Весь исходный код доступен в открытом проекте на https://bitbucket.org/savamura/scrumboard.

Посмотреть на UI без бекенда можно тут.

Клиентская часть

Основная часть приложения — код клиента. Именно его написание заняло наибольшее количество времени (на самом деле, больше всего времени ушло на написание статьи). Несмотря на это, я не буду слишком подробно описывать реализацию, понадеявшись на понятный код. Для клиента понадобится HTML5 Boilerplate, jQuery, jQuery UI, knockout.js, underscore.js, немножко Backbone.js. Cмешать всё вместе в MVVM салат, добавить соль по вкусу.
Представление (View) — это одна html страничка с двумя кнопками (new sticker, save board), диалогом со свойствами стикера и, собственно, полем, куда эти стикеры «приклеиваются». Сверху располагается полоса с фазами, под которыми можно разместить стикер.
Модель данных предельно проста — это координаты стикера, надпись, дополнительная информация, а также ссылка на задачу в любимой системе управления задачами. Координаты стикера изменяются путём его перетаскивания, а связанные данные — с помощью диалогового окна. Перетаскиваемые стикеры и диалоговые окна играючи получаются с помощью jQuery.UI. Использование knockout.js позволяет отделить логику представления от элементов этого представления. Во всём приложении три ViewModel: BoardViewModel, StickerViewModel и StickerPropertiesDialogViewModel.
Мы вернёмся к клиенту чуть позже. Для начала создадим бекенд мобильного сервиса.

Azure Mobile Service и Custom API

Windows Azure Mobile Services — это специализированный облачный хостинг для node.js приложений. Помимо REST API доступны Push нотификации на основные мобильные платформы, аутентификация с использованием identity провайдеров (Google, Facebook, Twitter, Microsoft), запуск задач по расписанию. Можно создавать своё приложение непосредственно в веб-консоли, а можно настроить синхронизацию приложения с git репозиторием. Попробуем и то и другое.
Для начала создадим новый Mobile Service в веб-консоли Azure. Необходимо выбрать уникальное имя, регион, а также опцию «Create a free 20 MB SQL database». Отказаться от базы данных сразу нельзя, но можно удалить её после создания, либо не использовать. Дело в том, что из коробки доступно два типа REST API: базовый CRUD API для управления данными в таблицах SQL и Custom API, в котором можно делать что угодно. Немного странно, что SQL база данных так назойливо предлагается к использованию в облачном сервисе, (хотя если сравнить её стоимость со стоимостью no-SQL хранилища, то станет всё понятно). Мы бы, конечно, могли хранить данные в SQL базе, и, скорее всего, 20 мегабайт хватило бы для нашей доски, но мне кажется, что в облачном сервисе лучше использовать облачное хранилище, к тому же для нашей задачи оно отлично подходит. Именно поэтому мы напишем Custom API, в котором будем сохранять и загружать данные в Azure Table Storage.
Итак, идём во вкладку API и создаём новый. Задаём имя “board” и оставляем значения настроек доступа для HTTP запросов по умолчанию: “Anybody with the Application Key”. Позже эти значения можно будет изменить. По клику на вновь созданный API откроется online редактор кода с Hello World приложением. На этом мы, пожалуй, закончим знакомство с online редактором.
Чтобы иметь возможность обращаться к API с локальной машины во время отладки, надо не забыть прописать cross-origin resource sharing (CORS) правило во вкладке “configure”. Можно указать localhost или просто *, тем самым разрешив обращаться к API откуда угодно.

Деплоим в облако, используя git

Azure Mobile Services позволяет деплоить веб-приложение с помощью приватного git репозитория. Попробуем воспользоваться этой классной возможностью, к тому же это даст нам простой способ редактировать код в любимом IDE.
Идём во вкладку “Dashboard” и в колонке “Quick glance” кликаем по “Setup source control”. На момент написания статьи эта опция находилась в состоянии превью.
Вам будет предложено придумать имя пользователя и пароль к git репозиторию, после чего Azure немножко подумает и во вкладке “Configure” появятся путь к приватному репозиторию и секретный URL, синхронизирующий код репозитория с работающим приложением, однако, у меня синхронизация происходила автоматически. Теперь можно клонировать созданный репозиторий локально, воспользовавшись любимым git клиентом, например, SourceTree. Также клонируйте bitbucket.org/savamura/scrumboard.git. Внутри вы найдёте папку service, которую надо синхронизировать с той, что получилась в результате клонирования репозитория Azure. Прежде чем пульнуть изменения обратно в облако, давайте разберёмся с серверным кодом.

Код серверной части

В папке service есть четыре подпапки: api с кодом кастомного API, scheduler с кодом задач, выполняющихся по расписанию, shared может содержать общий код, npm модули. Папка table предназначена для скриптов, модифицирующих данные в соответствующей SQL таблице.
Каждый js скрипт внутри папки api должен иметь парный json файл с тем же именем. В нём содержатся настройки доступа к нашему API.
Board.js представляет собой node.js модуль, который используется веб-движком express внутри Azure Mobile Services.
Нашему приложению потребуются модули express, azure и underscore:

var express = require("express");
var azure = require("azure");
var _ = require("underscore");

Поскольку данные мы будем сохранять в Table Storage, нужно его подготовить. Вернитесь в веб-консоль управления Azure, создайте отдельный Cloud Storage и скопируйте Access Key. Для доступа к хранилищу воспользуемся клиентом TableService, написанным Microsoft.

function createTableService()
{
    return azure.createTableService(
        "your_storage_acount",
        "your_storage_account_key");
}

Таблицу, в которой будут храниться стикеры, можно создать как в веб-консоли, так из кода. Приложение у нас не слишком нагруженное и для иллюстрации я включил код, проверяющий существование таблицы, но проще подготовить её заранее, чтобы не усложнять обработку запросов.
Наш API будет обрабатывать всего лишь два типа запросов: get (вернёт всю доску) и post (сохранит её).
Внутри методов можно использовать трассировку console.log. Логи доступны в веб-консоли. Не очень удобно, но хоть как-то можно отлаживать.
Поскольку приложение выполняется в node.js, весь код асинхронный. Надо быть внимательным и не забыть завершить запрос (request.respond или response.send). Полная документация доступна на MSDN. Сначала мы проверяем существование таблицы и создаём её с помощью метода createTableIfNotExists. В метод передаётся имя таблицы и функция-колбек.

exports.post = function(request, response) {
    console.log(request.body); 
    var tableService = createTableService();
 
    tableService.createTableIfNotExists("Stickers", function(error)
    {
        if (!error)
        {
            console.log("Table Stickers created or existed");
            saveBoard(tableService, request.body, response);
        }
        else
        {
            console.error(error);
            response.send(
				statusCodes.INTERNAL_SERVER_ERROR, 
				{ message: "Table creation failed. " + error });
        }
    });
};

В случае если таблица существует, сохраняем данные:

function saveBoard(tableService, boardModel, response)
{
	tableService.beginBatch();
	_.each(
		boardModel.stickers,
		function(sticker)
		{
			sticker.PartitionKey = boardModel.boardId;
			sticker.RowKey = sticker.id;
			tableService.insertOrReplaceEntity(stickersTableName, sticker);
		});

	tableService.commitBatch(
		function(error, operationResponses, batchResponse)
		{
			var batchErrors = "";
			_.each(
				operationResponses,
				function(opresp)
				{
					if (opresp.error)
						batchErrors += opresp.error
				});

			console.log("Stickers saved. Error: " + error + " " + batchErrors);
			console.log(operationResponses);
			console.log(batchResponse);

			if (error || batchErrors !== "")
			{
				response.send(
					statusCodes.INTERNAL_SERVER_ERROR, 
					{ message : error + " " + batchErrors });
			}
			else
			{
				response.send(statusCodes.OK, { message : 'Batch commited!' });
			}
		});
}

Обратите внимание, что для сохранения данных стикеров используется батч. Необходимо помнить, что у батчей есть некоторые ограничения, например, PartitionKey должен быть одинаковым у всех записей, и число записей не должно превышать 100. В данном примере отсутствует разбиение всех записей на батчи по 100 штук.
Для того чтобы сохранить запись в таблицу, объектную иерархию необходимо линеаризовать. Другими словами, в отличие, например, от MongoDB, древовидную объектную структуру сохранить не получится. Линеаризация происходит на клиенте. В качестве PartitionKey используется идентификатор доски (guid), который сейчас захардкожен в клиенте, но в общем случае может быть любым. В качестве RowKey используется stickerId (guid), который также создаётся на клиенте.
Для того чтобы извлечь данные из таблицы, достаточно правильно сформировать запрос:

exports.get = function(request, response)
{
    console.log(request);
    var boardId = request.query.boardId;
    if (!boardId)
    {
        response.send(statusCodes.BAD_REQUEST, { message : 'boardId is undefined.' });
    }
    
    var query = azure.TableQuery.select()
        .from(stickersTableName)
        .where('PartitionKey eq ?', boardId);
    
    var tableService = createTableService();
    tableService.queryEntities(
        query, 
        function(error, entities)
		{
            if(!error)
			{
                response.send(statusCodes.OK, entities);
            }
            else
            {
                response.send(statusCodes.INTERNAL_SERVER_ERROR, error);
            }
        });
};

Вот, собственно, и весь базовый код сервера. Конечно, тут должна быть ещё валидация входных данных, обработка ошибок и проверка доступа. Также не мешало бы реализовать удаление стикеров и защиту от xss-атак. Все эти проблемы мы адресуем в следующих версиях. Главное — обеспечить базовую функциональность.
Нам осталось только связать код клиента и сервера.

MobileServicesClient

Код, осуществляющий вызовы серверной части инкапсулирован в отельный класс-обёртку StorageClient и, конечно, может быть заменён. В нём всего два метода: загрузить доску и сохранить доску. Информация обо всех стикерах сразу линеаризуется, сериализуется и передаётся на сервер. Конечно, можно было бы передавать только изменённые данные, но это несколько усложнит код.
Для осуществления удалённых вызовов custom API, можно воспользоваться клиентом WindowsAzure.MobileServicesClient, который предоставляет Microsoft. Он будет доступен по адресу your_custom_api.azure-mobile.net/client/MobileServices.Web-1.0.3.min.js (версия может меняться). Ссылку на него также можно скопировать из веб-консоли Azure, если кликнуть на значок облачка с молнией рядом с “Dashboard”, затем выберать платформу HTML/JavaScript и Connect an existing HTML app. Там же можно получить и ключ доступа к API (внизу кнопка manage keys – application key). Ключ можно пересоздать, при этом старый работать перестанет.
Конструктор StorageClient:

var StorageClient = function()
{
	this.azureClient = new WindowsAzure.MobileServiceClient(
		'https://your_custom_api.azure-mobile.net/',
		'your_application_key_goes_here');
};

Наш кастомный API называется board, поэтому, чтобы сохранить данные доски, нужно сделать следующее:

StorageClient.prototype.saveBoard = function(data)
{
	this.azureClient
		.invokeApi(
			"board",
			{
				method: "POST",
				body: data
			})
		.done(
			function(response)
			{
				console.log(response);
				$.jGrowl("Board saved!");
			},
			function(error)
			{
				var xhr = error.request;
				$.jGrowl("Error calling azure API " + xhr.status + " " + xhr.responseText);
			});
};

azureClient.invokeApi возвращает promise, на завершение которого, можно подписаться.
Метод загрузки данных выглядит похожим образом, однако, мы дополнительно передаём в него методы, которые надо будет вызвать по завершении загрузки.

StorageClient.prototype.loadBoard = function(boardId, onBoardLoaded, onBoardLoadFailed)
{
	var query = this.azureClient
		.invokeApi(
			"board",
			{
				method: "GET",
				parameters: { boardId: boardId }
			})
		.done(
			function(xmlHttpRequest)
			{
				console.log(xmlHttpRequest);
				onBoardLoaded(xmlHttpRequest);
			},
			function(error)
			{
				var xhr = error.request;
				onBoardLoadFailed(xhr);
			});
};
Деплой клиента в Blob Storage

Будем считать, что мы отладили наше приложение локально. Пришло время его задеплоить. Как вы уже догадались, хостить статический контент лучше всего в Azure Blob Storage. Для этого понадобится контейнер, который можно создать, например, через веб-консоль Azure. Во время создания, укажите уровень доступа Public Blob, что будет значить, что доступ ко всем блобам в контейнере будет публичным, однако метаданные самого контейнера останутся приватными.
Теперь необходимо выгрузить файлы с кодом клиента в облако. Для этой задачи отлично подойдёт консольная утилита AzCopy, которую можно загрузить с Microsoft Download Center. Кликните Download и выберите WindowsAzureStorageTools.msi. После установки скопируйте все файлы, которые необходимо загрузить в облако в отдельную папку.
Выполните
AzCopy <папка с проектом> <путь к контейнеру> /destKey:<ключ доступа> /S

Если сразу после этого вы попробуете открыть в браузере ваше приложение (index.html), то обнаружите, что браузер откроет диалоговое окно для выбора пути сохранения файла. Виной тому content type блоба, который по умолчанию выставляется в application/octet-stream.
Это нужно исправить, написав небольшое консольное приложение на C#. В нём мы пробежимся по всем блобам в контейнере и выставим им стандартный Content Type в зависимости от расширения. Не забудьте подключить nuget пакет Windows Azure Storage.

namespace BlobMetadataChanger
{
    class Program
    {
        static void Main(string[] args)
        {
            var connectionString = args[0];
            var containerName = args[1];
            var storageAccount = CloudStorageAccount.Parse(connectionString);
            var blobClient = storageAccount.CreateCloudBlobClient();
            var container = blobClient.GetContainerReference(containerName);
            var blobs = container.ListBlobs(useFlatBlobListing: true).Cast<CloudBlockBlob>();

            foreach (var blob in blobs)
            {
                UpdateBlobContentTypeIfExtensionMatch(blob, ".html", "text/html; charset=UTF-8");
                UpdateBlobContentTypeIfExtensionMatch(blob, ".css", "text/css; charset=UTF-8");
                UpdateBlobContentTypeIfExtensionMatch(blob, ".js", "application/javascript");
                UpdateBlobContentTypeIfExtensionMatch(blob, ".png", "image/png");
                UpdateBlobContentTypeIfExtensionMatch(blob, ".gif", "image/gif");
                UpdateBlobContentTypeIfExtensionMatch(blob, ".jpg", "image/jpg");
            }                 
        }

        static void UpdateBlobContentTypeIfExtensionMatch(
			CloudBlockBlob blob, 
			string extension, 
			string contentType)
        {
            if (blob.Name.EndsWith(extension))
            {
                blob.Properties.ContentType = contentType;
                blob.SetProperties(); 
            }
        }
    }
}

Всё, после выполнения этой утилиты с правильными параметрами, вы сможете открыть приложение через браузер.

Контроль доступа

На этом, пожалуй, можно было и закончить, но мы упустили одну немаловажную деталь: контроль доступа. Сейчас кто угодно, обладающий ссылкой на приложение, может посмотреть задачи, которые вы должны выполнить, чтобы захватить мир. По правде сказать, ваши планы, скорее всего, никому не интересны и, если в качестве имени контейнера вы выберите guid, то можно не беспокоиться. Однако бывает всякое и, если вы хотите контролировать ситуацию, придётся приложить ещё немного усилий.
Открываем веб-консоль Azure, находим своё мобильное приложение и идём во вкладку “Identity”. Допустим, что у всех в команде есть твиттер акаунт, поэтому мы зарегистрируем нашу доску как веб-приложение там: https://dev.twitter.com/apps/new. После заполнения всех полей вы получите заветные Consumer Key и Consumer Secret, которые надо скопировать в соответствующие поля во вкладке “Identity”. Кроме того, очень важно указать в качестве Callback URL в настройках twitter приложения основной URL вашего мобильного сервиса, а также поставить галку перед “Allow this application to be used to Sign in with Twitter”. Я понял это не сразу, поэтому пришлось помучиться.
Теперь достаточно, в http приложении после создания клиента MobileServiceClient и до отправки запроса на получение данных доски, вызвать

azureClient.login("twitter").then(loadBoard, onError);

В деталях процесс описан в этом уроке.
В коде веб-приложения в начале каждого из методов custom API необходимо проверить userId.

function CheckUser(request)
{
	var allowedUsers = [ “Twitter:1111111”, “Twitter:22222222” ];
	return _.find(
		allowedUsers, 
		function(allowedUser) { request.user.userId == allowedUser }) !== undefined;
}
Заключение

Вот мы и добрались до завершения этой статьи, но не до конца темы. На прошлой неделе вышла новая порция обновлений облачного сервиса Azure, среди которых появление поддержки CORS в Cloud Storage. Это значит, что переписав код клиента storageClient и настроив нужным образом правила CORS, можно сохранять данные в Table Storage непосредственно из браузера. В этом случае не нужно даже писать серверную часть в виде Azure Mobile Service, так как основная его функция именно в том, чтобы сохранить данные в таблицу. Конечно, мы потеряем возможность аутентифицировать пользователей, но зато получим работающее приложение, состоящее только из кода браузерного клиента. Уже появилась хабрастатья на эту тему.

Автор: savamura

Источник


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


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