Декабрь 2015

ТОМ 30, НОМЕР 13

Доступ к данным - Aurelia и DocumentDB. Часть 2

Джули Лерман | Декабрь 2015

Исходный код можно скачать по ссылке

Джули ЛерманTНовая JavaScript-инфраструктура Aurelia и сервис баз данных документов NoSQL, Azure DocumentDB, являются двумя технологиями, которые не так давно заинтересовали меня. В июне я исследовала DocumentDB на высоком уровне (msdn.com/magazine/mt147238). Затем в сентябре я поэкспериментировала со связыванием с данными через Aurelia (msdn.com/magazine/mt422580). По-прежнему заинтригованная обеими технологиями, я подумала, что было бы неплохо соединить их, используя Aurelia в качестве клиентского приложения, а DocumentDB в качестве хранилища данных. Хотя мое любопытство давало мне некоторое преимущество (упорство), ограниченный опыт в работе с этими двумя новыми технологиями и с JavaScript-инфраструктурами в целом не раз заводил меня в тупик при попытках скомбинировать их. Проблемы усугублялись тем фактом, что до этого никто не комбинировал эти технологии — по крайней мере, публично. О своих неудачных попытках я рассказала в рубрике за ноябрь (msdn.com/magazine/mt620011). Один очевидный и простой путь, который я пропустила, — использование существующего Web API, обертывающего взаимодействие с DocumentDB, вместо Web API, с которым я работала в своей первой статье по Aurelia. Я сделала такой выбор, потому что в том варианте не было никаких трудностей, а значит, и особого интереса.

В конце концов я задействовала другое серверное решение: Node.js. Имеется существующий пример, в котором используются DocumentDB Node.js SDK и другая клиентская часть. Я внимательно изучила его и поделилась с вами тем, чему научилась сама, в рубрике за ноябрь. Затем я приступила к воссозданию своего примера Ninja из сентябрьской рубрики, на этот раз используя Node.js как посредник между клиентской Aurelia и DocumentDB. На этом пути было много проблем, и они выходили далеко за рамки вопросов, относящихся к данным. Я получала колоссальную поддержку от членов основной группы Aurelia и особенно от Патрика Уолтерса (Patrick Walters) (github.com/pwkad), который не только помог мне в полной мере задействовать преимущества Aurelia, но и неявно вынудил меня глубже осваивать Git.

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

Архитектура решения

Для начала посмотрим на общую архитектуру этого решения (рис. 1).

Общая структура решения
Рис. 1. Общая структура решения

Client Side Клиентская сторона
Views & View-Models
Rendering
Routing
Binding
ES6
More
Представления и модели представлений
Рендеринг
Маршрутизация
Связывание
ES6
Другое
Server Side Серверная сторона
Models
Config
Credentials
Data Access Methods
Returns JSON
Модели
Конфигурации
Удостоверения
Методы доступа к данным
Возврат JSON
Microsoft Azure DocumentDB Node.js SDK Microsoft Azure DocumentDB Node.js SDK
Microsoft Azure Cloud Облако Microsoft Azure
DocumentDB DocumentDB

Aurelia — это инфраструктура клиентской стороны, поэтому она нужна здесь только для обработки операций на клиентской стороне, например для рендеринга HTML представления, а также для маршрутизации, связывания с данными и других релевантных задач. (Заметьте, что весь клиентский код можно отлаживать средствами разработки в браузерах, таких как Internet Explorer и Chrome.) В этом решении весь клиентский код размещается в папке public/app/src, а серверный — в папке root. Когда веб-сайт запускается, файлы для клиентской стороны компилируются Aurelia, чтобы обеспечить совместимость с браузерами, и помещаются в папку dist. Именно эти файлы посылаются клиенту.

Серверный код должен быть знаком вам, если вы занимались веб-разработками в Microsoft .NET Framework. В среде .NET ваш отделенный код от Web Forms, контроллеры и другая логика традиционно компилировались в DLL, размещаемые на сервере. Конечно, многое из этого изменилось в ASP.NET 5 (к которой я чувствую себя более подготовленной благодаря работе над этим конкретным проектом). Мой проект Aurelia также содержит код для серверной стороны, но даже он является JavaScript-кодом под Node.js. Этот код не компилируется в DLL, а остается в своих индивидуальных файлах. Еще важнее, что он не отправляется клиенту и поэтому не раскрывается каждому встречному (конечно, это всегда зависит от мер безопасности). Как я описывала в прошлый раз, основной мотив моего желания оставить код на сервере заключается в том, что мне нужен был какой-то способ хранить свои удостоверения для взаимодействия с DocumentDB. Поскольку я хотела опробовать путь, предлагаемый Node.js, вышеупомянутый SDK намного облегчил взаимодействие с DocumentDB.

Мой серверный код (который я буду называть «моим API») состоит из четырех ключевых ингредиентов:

  • файла api.js, который действует как маршрутизатор (router), упрощая нахождение функций моего API;
  • базового модуля ninjas.js, содержащего API-функции getNinjas, getNinja и updateDetails;
  • класса контроллера, DocDbDao (вызываемого перечисленными API-функциями), который осуществляет взаимодействие с DocumentDb;
  • вспомогательного файла DocumentDb, которому известно, как обеспечить наличие релевантных базы данных и набора.

Соединение клиента, сервера и облака

В моем раннем примере с Aurelia метод, который получал все объекты ninja, выдавал прямой HTTP-вызов ASP.NET Web API, который с помощью .NET и Entity Framework взаимодействовал с базой данных SQL Server:

retrieveNinjas() {
  return this.http.createRequest
    ("/ninjas/?page=" + this.currentPage + "&pageSize=100&query=" +
      this.searchEntry)
      .asGet().send().then(response => {
        this.ninjas = response.content;
    });
  }

Результаты передавались паре «клиентское представление — модель представления» (ninjaList.js и ninjaList.html), которая выводила страницу, как на рис. 2.

Список ниндзя на моем веб-сайте Aurelia
Рис. 2. Список ниндзя на моем веб-сайте Aurelia

В новом решении этот метод, переименованный в getNinjas, теперь вызывает мой серверный Node.js API. На этот раз для вызова я использую более продвинутый httpClient.fetch (bit.ly/1M8EmnY) вместо httpClient:

getNinjas(params){
  return this.httpClient.fetch(`/ninjas?q=${params}`)
    .then(response => response.json())
    .then(ninjas => this.ninjas = ninjas);
}

Я настроила httpClient на базовый URL в другом месте.

Заметьте, что мой метод fetch вызывает URI, включающий элемент ninjas. Но он не ссылается на ninjas.js в API на серверной стороне. Это мог бы быть /foo — просто случайная ссылка, которая будет разрешена маршрутизатором на серверной стороне. Этот маршрутизатор (использующий Express, а не Aurelia, поскольку Aurelia обрабатывает только клиентскую сторону) указывает, что вызовы api/ninjas нужно направлять функции getNinjas из модуля ninjas. Вот код из api.js, где это как раз и определено:

router.get('/api/ninjas', function (request, response) {
  ninjas.getNinjas(req, res);
});

Теперь моя функция getNinjas (рис. 3) использует строковую интерполяцию (mzl.la/1fhuSIg) для формирования строки, которая представляет SQL для запроса DocumentDB, затем просит контроллер выполнить запрос и вернуть результаты. Если в UI запрошен фильтр по свойству name, я добавляю в запрос блок WHERE. Основной запрос проецирует только релевантные свойства, необходимые мне на странице, включая идентификатор. (Подробнее о запросах проекции DocumentDB см. по ссылке documentdb.com/sql/demo.) Запрос передается методу find контроллера docDbDao. Метод find, который остался в исходном виде, как я рассказывала в первой части этой серии, использует Node.js SDK наряду с удостоверениями, хранящимися в config.js, чтобы запросить у базы данных список объектов ninja. Функция getNinjas принимает эти результаты и возвращает их запросившему клиенту. Заметьте: хотя вызов DocumentDB возвращает результаты в формате JSON, для передачи результатов обратно мне все равно приходится явно использовать функцию response.json. Это оповещает вызвавшего о том, что результаты имеют формат JSON.

Рис. 3. Функция getNinjas в модуле ninjas.js на серверной стороне

getNinjas: function (request, response) {
  var self = this;
  var q = '';
  if (request.query.q != "undefined" && req.query.q > "") {
    q= `WHERE CONTAINS(ninja.Name,'${req.query.q}')`;
  }
  var querySpec = {
    query:
   `SELECT ninja.id, ninja.Name,ninja.ServedInOniwaban,
     ninja.DateOfBirth FROM ninja ${q}`
  };
  self.docDbDao.find(querySpec, function (err, items) {
    if (err) {
      // TODO: обработка ошибок
    } else {
      response.json(items);
    }
  })
},

Ответ клиентской стороны на запрос редактирования

Как видно на рис. 2, можно редактировать объект ninja, щелкнув значок карандаша. Вот ссылка, созданная мной в разметке страницы:

<a href="#/ninjas/${ninja.id}" class=
  "btn btn-default btn-sm">
  <span class="glyphicon glyphicon-pencil" />
</a>

Щелкнув значок карандаша для первой строки, идентификатор которой равен «1», я получаю такой URL:

http://localhost:9000/app/#/ninjas/1

Используя функционал маршрутизации в Aurelia на клиентской стороне в app.js, я указала, что при запросе этого шаблона URL, должен быть вызван модуль edit и в метод activate модели представления edit следует передать id в качестве параметра, обозначенного символом подстановки (*Id):

{ route: 'ninjas/*Id', moduleId: 'edit', title:'Edit Ninja' },

Edit ссылается на модуль edit.js на клиентской стороне, связанный с представлением edit.html. После этого функция activate из модуля edit вызывает другую функцию в этом модуле, retrieveNinja, передавая запрошенный Id:

retrieveNinja(id) {
  return this.httpClient.fetch(`/ninjas/${id}`)
    .then(response => response.json())
    .then(ninja => this.ninja = ninja);
}

Передача запроса редактирования в API на серверной стороне

И вновь я использую httpClient.fetch для запроса api/ninjas/[id] (в моем случае — api/ninjas/1) от API. Маршрутизатор на серверной стороне сообщает, что, когда будет принят запрос с этим шаблоном, его следует направить функции getNinja модуля ninjas. Вот как выглядит эта маршрутизация:

router.get('/api/ninjas/:id', function(request, response) {
  ninjas.getNinja(request, response);
});

Затем метод getNinja выдает другой запрос к контроллеру docDbDao, на этот раз к функции getItem, чтобы получить данные ninja из DocumentDb. Результатами являются JSON-документы, которые хранятся в базе данных DocumentDB, как показано на рис. 4.

Рис. 4. Запрошенный мной документ хранится в наборе DocumentDb Ninjas

{
  "id": "1",
  "Name": "Kacy Catanzaro",
  "ServedInOniwaban": false,
  "Clan": "American Ninja Warriors",
  "Equipment": [
    {
      "EquipmentName": "Muscles",
      "EquipmentType": "Tool"
    },
    {
      "EquipmentName": "Spunk",
      "EquipmentType": "Tool"
    }
  ],
  "DateOfBirth": "1/14/1990",
  "DateCreated": "2015-08-10T20:35:09.7600000",
  "DateModified": 1444152912328
}

Передача результатов обратно клиенту

Этот JSON-объект возвращается функции ninjas.getNinja, которая потом возвращает его вызвавшему, в данном случае модулю edit.js на клиенте. Далее Aurelia связывает edit.js с шаблоном edit.html и выводит страницу, которая рассчитана на отображение этого графа.

Представление edit дает возможность пользователям модифицировать четыре части данных: имя и дату рождения ниндзя (строковые данные), клан (через раскрывающийся список) и то, служил ли данный ниндзя в Онивабан. Раскрывающийся список Clan использует особый тип веб-компонента — настраиваемый элемент (custom element). Реализация спецификации настраиваемого элемента в Aurelia уникальна. Поскольку я использую эту функциональность для привязки данных (столбца с данными), позвольте мне показать, как это делается.

Привязка данных с помощью настраиваемого элемента Aurelia

Как и другие пары «представление — модель представления» в Aurelia, настраиваемый элемент состоит из представления для разметки и модели представления, содержащей логику. На рис. 5 показан третий используемый файл, clans.js, который предоставляет список кланов.

Рис. 5. Clans.js

export function getClans(){
  var clans = [], propertyName;
  for(propertyName in clansObject) {
    if (clansObject.hasOwnProperty(propertyName)) {
      clans.push({ code: clansObject[propertyName], name: propertyName });
    }
  }
  return clans;
}
var clansObject = {
  "American Ninja Warriors": "anj",
  "Vermont Clan": "vc",
  "Turtles": "t"
};

Представление для этого элемента (dropdown.html) использует Bootstrap-элемент select, который я все равно рассматриваю как раскрывающийся список:

<template>
  <select value.bind="selectedClan">
    <option repeat.for="clan of clans" model.bind="clan.name">${clan.name}</option>
  </select>
</template>

Заметьте, что value.bind и repeat.for, которые должны быть знакомы вам, если вы читали мою предыдущую статью по связыванию с данными через Aurelia. Элемент option внутри select обеспечивает привязку к модели clan, определенной в clans.js, а затем отображает название клана. Поскольку мой объект Clans прост и содержит лишь имя и код (который является лишним в этом примере), я могла бы использовать здесь только value.bind. Но я придерживаюсь применения model.bind, так как мне легче запомнить этот шаблон.

Модуль dropdown.js связывает разметку с кланами, как показано на рис. 6.

Рис. 6. Код настраиваемого элемента для модели в dropdown.js

import $ from 'jquery';
import {getClans} from '../clans';
import {bindable} from 'aurelia-framework';
export class Dropdown {
  @bindable selectedClan;
  attached() {
    $(this.dropdown).dropdown();
  }
  constructor() {
    this.clans = getClans();
  }
}

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

<dropdown selected-clan.two-way="ninja.Clan"></dropdown>

Заметьте, что я использую здесь два специфичных для Aurelia средства. Во-первых, мое свойство в модели представления называется selectedClan, а в разметке — selected-clan. По соглашению в Aurelia, основанном на требовании HTML к набору атрибутов буквами нижнего регистра, все пользовательские свойства с экспортируемыми именами формируются буквами нижнего регистра и пишутся через дефис, поэтому ожидается, что дефис будет перед тем местом, где ставится буква верхнего регистра (например, selectedClan — selected-clan). Во-вторых, вместо value.bind я явным образом использую здесь двухстороннюю привязку, чтобы clan заново связывался с ninja при смене выбранного элемента.

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

Передача изменений обратно в DocumentDB

Моя разметка считывает две части данных амуниции в графе и отображает их. Ради краткости я отложу редактирование данных амуниции до другого случая.

Теперь последняя порция работы — передать изменения обратно в API и отправить их в DocumentDB. Это инициируется кнопкой Save. В разметке для кнопки Save используется другая парадигма Aurelia — click.delegate, основанная на делегировании событий JavaScript; это позволяет мне делегировать действие функции save, определенной в edit.js.

Функция save (рис. 7) создает новый объект, ninjaRoot, используя релевантные свойства из свойства ninja, которое было определено в getNinja, а тот потом связывается с разметкой, разрешая пользователю выполнять обновление из браузера.

Рис. 7. Функция ninjas.save

save() {
  this.ninjaRoot = {
    Id: this.ninja.id,
    ServedInOniwaban: this.ninja.ServedInOniwaban,
    Clan: this.ninja.Clan,
    Name: this.ninja.Name,
    DateOfBirth: this.ninja.DateOfBirth
  };
  return this.httpClient.fetch('/updateDetails', {
    method: 'post',
    body: json(this.ninjaRoot)
  }).then(response => {this.router.navigate('ninjaList');
  });
}

Затем save использует теперь уже знакомый httpClient.fetch для запроса API URL, вызвавшего udpateDetails, и передает объект ninjaRoot как тело запроса. Также обратите внимание на то, что я указываю это как метод post, а не get. API-маршрутизатор сообщает Node.js о направлении вызова методу updateDetails модуля ninjas:

router.post('/api/updateDetails', function(request,response){
  ninjas.updateDetails(request,response);
});

Теперь посмотрим на серверный метод updateDetails в ninjas.js:

updateDetails: function (request,response) {
  var self = this;
  var ninja = request.body;
  self.docDbDao.updateItem(ninja, function (err) {
    if (err) {
      throw (err);
    } else {
      response.send(200);
    }
  })
},

Я извлекаю ninjaRoot, хранящийся в теле запроса, и присваиваю его переменной ninja, а затем передаю эту переменную методу updateItem контроллера. Как показано на рис. 8, я слегка модифицировала updateItem по сравнению с первой частью цикла статей для принятия своего типа ninja.

Рис. 8. Метод updateItem контроллера docDbDao взаимодействует с DocumentDB через Node.js SDK

updateItem: function (item, callback) {
  var self = this;
  self.getItem(item.Id, function (err, doc) {
    if (err) {
      callback(err);
    } else {
      doc.Clan=item.Clan;
      doc.Name=item.Name;
      doc.ServedInOniwaban=item.ServedInOniwaban;
      doc.DateOfBirth=item.DateOfBirth;
      doc.DateModified=Date.now();
      self.client.replaceDocument(doc._self, doc, function (err, replaced) {
        if (err) {
          callback(err);
        } else {
          callback(null, replaced);
        }
      });
    }
  });
},

UpdateItem извлекает документ из базы данных по id, обновляет релевантные свойства этого документа, а потом с помощью SDK-метода DocumentDBClient.replaceDocument передает изменения в мою базу данных Azure DocumentDB. О завершении операции меня оповещает обратный вызов. Затем этот обратный вызов отмечается методом updateDetails, который возвращает код ответа 200 клиентскому модулю, вызывавшему данный API. Если вы снова взглянете на клиентский метод save, то заметите, что его обратный вызов направляется ninjaList. Поэтому после успешной отправки обновления пользователю выводится исходная страница со списком ниндзя. Любые изменения в только что отредактированном ниндзя будут видны в этом списке.

Остались ли мы ниндзя?

Это решение ввергло меня в бездну проблем и тупиковых ситуаций. Хотя моей основной целью было взаимодействие Aurelia с базой данных DocumentDB, я также хотела получить от этих технологий другие их преимущества. Это потребовало бы освоения столь многого в мире JavaScript, управления установками node, использования Visual Studio Code ввиду его способности отлаживать Node.js и более глубокого изучения DocumentDB. Наверняка вам может понадобиться делать массу других вещей даже в таком простом примере, как мой, но эта статья должна дать вам базовое понимание того, как все это работает.

Важно помнить, что DocumentDB, как и любая база данных NoSQL, рассчитана на большие объемы данных. Она не экономична для столь крошечных объемов данных, как в моем примере. Но для исследования функциональности подключения к базе данных и взаимодействия с данными больших объемов не нужно — хватило и пяти объектов.


Джули Лерман (Julie Lerman) — Microsoft MVP, преподаватель и консультант по .NET, живет в Вермонте. Часто выступает на конференциях по всему миру и в группах пользователей по тематике, связанной с доступом к данным и другими технологиями Microsoft .NET. Ведет блог thedatafarm.com/blog и является автором серии книг «Programming Entity Framework» (O’Reilly Media, 2010), в том числе «Code First Edition» (2011) и «DbContext Edition» (2012), также выпущенных издательством O’Reilly Media. Вы можете читать ее заметки в twitter.com/julielerman и смотреть ее видеокурсы для Pluralsight на juliel.me/PS-Videos.

Выражаю благодарность за рецензирование статьи эксперту Патрику Уолтерсу (Patrick Walters).