Сентябрь 2016

ТОМ 31, НОМЕР 9

Инфраструктура Reactive - Создание асинхронных веб-страниц AJAX с помощью Reactive Extensions

Питер Вогел | Сентябрь 2016

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

Продукты и технологии:

RxJS, RxJS-DOM, jQuery, AJAX, RESTful-сервисы

В статье рассматриваются:

  • структуризация одностраничных приложений;
  • свободно связанные компоненты приложения;
  • интеграция RxJS с клиентскими DOM и jQuery;
  • интеграция клиентского кода с RESTful-сервисами.

В предыдущей статье я обсуждал, как использовать шаблон Observer для управления длительно выполняемыми задачами (msdn.com/magazine/mt707526). К концу той статьи я показал, что расширения Microsoft Reactive Extensions (Rx) предоставляют простой механизм для управления последовательностью событий из длительно выполняемого процесса в Windows-приложении.

Однако использование Rx просто для мониторинга последовательности событий от длительно выполняемой задачи — это лишь часть возможностей этой технологии. Изящество Rx в том, что ее можно применять для асинхронной интеграции любого процесса на основе событий с любым другим процессом. Для примера в этой статье я буду использовать Rx, чтобы выдавать асинхронные вызовы веб-сервису по щелчку кнопки на веб-странице (щелчок кнопки — это фактически последовательность из одного события). Чтобы задействовать Rx в клиентской веб-среде, я возьму Rx for JavaScript (RxJS).

Rx предоставляет стандартный способ для абстрагирования разнообразных сценариев и манипулирования ими с применением текучего, LINQ-подобного интерфейса, который позволяет составлять приложение из более простых строительных блоков. Rx дает возможность интегрировать UI-события с обработкой в серверной части и в то же время поддерживать их отделенными — с помощью Rx можно переписать UI без внесения соответствующих изменений в серверную часть (и наоборот).

Rx предоставляет стандартный способ для абстрагирования разнообразных сценариев и манипулирования ими с применением текучего, LINQ-подобного интерфейса, который позволяет составлять приложение из более простых строительных блоков.

RxJS также поддерживает четкое разделение между HTML и кодом, что в конечном счете дает вам связывание с данными без специальной HTML-разметки. Кроме того, RxJS опирается на существующие клиентские технологии (например, jQuery). Есть и еще одно преимущество Rx: все реализации Rx выглядят очень похоже: RxJS-код в этой статье очень сильно напоминает код для Microsoft .NET Framework, который я писал в своей предыдущей статье. Вы можете использовать навыки, приобретенные при работе в одной Rx-среде, в другой Rx-среде.

Приступаем к работе с RxJS

RxJS абстрагирует части приложения в две группы. Члены первой группы являются наблюдаемыми объектами (observables): по сути, все, что генерирует события. RxJS поддерживает богатый набор операторов для создания наблюдаемых, в том числе тех, которые можно сделать кажущимися генерирующими события (например, Rx способна преобразовывать массивы в источник событий). Операторы Rx также позволяют фильтровать и преобразовывать вывод событий. Возможно, имеет смысл рассматривать наблюдаемые как конвейер обработки для источника событий.

Члены второй группы являются наблюдателями (observers); они принимают результаты от наблюдаемых и обеспечивают обработку трех уведомлений от наблюдаемого: о новом событии (со связанными с ним данными), ошибке или заключительном событии последовательности. В RxJS наблюдатель может быть либо объектом с функциями для обработки одного или более из трех уведомлений, либо простым набором функций — по одной на каждое уведомление.

Чтобы связать эти две группы, наблюдаемый подписывает один или более наблюдателей на свой конвейер.

Вы можете добавить RxJS в своей проект через NuGet (ищите RxJS-All в библиотеке NuGet). Но должен вас предостеречь: когда я впервые добавил RxJS в проект, сконфигурированный под TypeScript, диспетчер NuGet запросил у меня, хочу ли я также добавить релевантные файлы определений TypeScript. Щелкнув Yes, я получил эти файлы, а потом около 400 ошибок «дубликат определения». Так что этот вариант я больше не принимаю.

Кроме того, есть ряд вспомогательных библиотек Rx, которые предоставляют полезные плагины для RxJS. Например, RxJS-DOM (доступна через NuGet как пакет RxJS-Bridges-HTML) обеспечивает интеграцию с событиями в клиентской DOM и с jQuery. Эта библиотека важна для создания адаптивных веб-приложений с использованием RxJS.

Как и большинство плагинов JavaScript для Rx, RxJS-DOM «вешает» новую функциональность на объект Rx, центральный в библиотеке RxJS. RxJS-DOM добавляет в объект Rx свойство DOM, имеющее ряд полезных функций, в том числе несколько функций, служащих мостом к jQuery-подобным функциям. Например, что выдать AJAX-вызов с помощью RxJS-DOM для получения JSON-объектов, вы написали бы такой код:

return Rx.DOM.getJSON("...url...")

Чтобы использовать и Rx, и RxJS-DOM, вам понадобятся всего два тега script:

<script src="~/Scripts/rx.all.js"></script>
<script src="~/Scripts/rx.dom.js"></script>

Использование как rx.all.js, так и rx.dom.js — решение тяжеловесное, поскольку они включают всю функциональность из обеих библиотек. К счастью, Rx и RxJS-DOM распределяют свою функциональность по нескольким, более «легким» библиотекам, чтобы вы могли включать только библиотеки, содержащие функциональность, необходимую в вашей странице (в документации на GitHub вы найдете, к какой библиотеке относится интересующий вас оператор).

Интеграция RESTful-сервиса

Типичный сценарий в JavaScript-приложении заключается в том, что пользователь щелкает какую-то кнопку, чтобы получить результат от веб-сервиса асинхронным вызовом. Первый шаг в создании решения RxJS — преобразование события щелчка кнопки в наблюдаемый объект, что легко сделать за счет интеграции RxJS/DOM/jQuery. Приведенный ниже код, например, использует jQuery для получения ссылки на элемент на форме с идентификатором getButton, а затем создает наблюдаемый объект из события щелчка этого элемента:

var getCust = $('#getButton').get(0);
var getCustObsvble = Rx.DOM.fromEvent(getCust, "click");

Функция fromEvent позволяет создавать наблюдаемый объект из любого события любого элемента. Однако RxJS содержит несколько сокращений для более «популярных» событий, включая событие щелчка. Я мог бы, например, использовать для создания наблюдаемого объекта из события щелчка кнопки и такой код:

var getCustObsvble = Rx.DOM.click(getCust);

Я могу создать более функциональный конвейер обработки для этого события, чтобы упростить обработку в своем приложении. Так, мне может понадобиться предотвратить обработку сценария, где пользователь быстро щелкает кнопку дважды (слишком быстро, чтобы я успел отключить кнопку и исключить двойной щелчок). Вместо написания уймы кода, оперирующего интервалами ожидания можно обрабатывать такой случай добавлением в конвейер функции debounce, указывающей, что я хочу видеть лишь щелчки, интервал между которыми минимум две секунды:

var getCustObsvble = Rx.DOM.click(getCust).debounce(2000);

Я могу также выполнять какую-то обработку события, используя, например, flatMapLatest, которая позволяет включать в конвейер функцию преобразования (нечто вроде LINQ SelectMany). Базовая функция, flatMap, выполняет преобразование над каждым событием в последовательности. Функция flatMapLatest заходит немного дальше и решает типичную проблему с асинхронной обработкой: flatMapLatest отменяет более ранние операции асинхронной обработки, если преобразование запускается во второй раз, но асинхронный запрос еще выполняется.

В случае RxJS нет нужды предоставлять обратные вызовы для запроса; результаты вызова автоматически передаются через наблюдаемый объект любым наблюдателям.

В случае flatMapLatest, если пользователь два раза щелкает кнопку (с интервалом более двух секунд), то, когда flatMapLatest все еще преобразует предыдущее событие, эта функция отменит предыдущее событие. Это избавляет меня от необходимости писать код для обработки отмены асинхронных операций.

Первый шаг в использовании flatMapLatest — создание своей функции преобразования. Для этого я просто обертываю показанный ранее вызов getJSON в функцию. Поскольку моя функция связана с щелчком кнопки, мне не нужны никакие данные со страницы (по сути, эта функция называется просто преобразованием, так как она игнорирует ввод от события).

Вот функция, которая выдает запрос сервису Web API, попутно используя jQuery для получения идентификатора клиента от элемента на странице:

function getCustomer()
{
  return Rx.DOM.getJSON("api/customerorders/customerbyid/" +
    $("custId").val());
}

В случае RxJS нет нужды предоставлять обратные вызовы для запроса; результаты вызова автоматически передаются через наблюдаемый объект любым наблюдателям.

Чтобы интегрировать это преобразование в свою цепочку обработки, я просто вызываю flatMapLatest из наблюдателя, передавая ссылку на функцию:

var getCustObsvble = Rx.DOM.click(getCust).debounce(2000).flatMapLatest(getCustomer);

Теперь я должен подписать обрабатывающие функции на наблюдаемый объект. В подписке можно назначить до трех функций: одну для обработки уведомления при приеме нового события, вторую для отчета по любым уведомлениям об ошибке и третью для обработки уведомления, отправленного в конце последовательности событий. Поскольку мой конвейер обработки событий щелчка предназначен специально для создания последовательности из одного события, мне не надо предоставлять функцию «конец последовательности».

Конечный код, который назначает две необходимые функции, может выглядеть так:

var getCustObsvble.subscribe(
  c   => $("#custName").val(c.FirstName),
  err => $("Message").text("Unable to retrieve Customer: " +
    err.description)
);

Если бы я полагал, что этот набор функций будет полезен в других местах (или просто хотел бы упростить код подписки), я мог бы создать объект-наблюдатель. Этот объект имеет те же функции, которые я использовал раньше, просто приписанные свойствам с именами onNext, onError и onComplete. Поскольку мне не требуется обрабатывать onComplete, мой объект-наблюдатель мог бы выглядеть следующим образом:

var custObservr = {
  onNext:  c   => $("#custName").val(c.FirstName),
  onError: err => $("#Message").text(
    "Unable to retrieve Customer: " + err.description)
};

При отдельном наблюдателе код моего наблюдаемого объекта, используемый для подписки наблюдателя, становится проще:

var getCustObsvble.subscribe(custObservr);

Собирая все воедино, мне просто нужна функция ready для страницы, которая получает кнопку, подключает конвейер, превращающий кнопку в очень изощренный наблюдаемый объект, а затем подписывает наблюдатель на этот конвейер. Поскольку RxJS реализует текучий интерфейс, я мог бы сделать это всего двумя строками кода: одной строкой кода на jQuery для получения элемента-кнопки и другой строкой кода RxJS для создания конвейера и подписки наблюдателя. Однако разбиение кода конструирования конвейера RxJS и кода подписки более чем на две строки сделает функцию ready более простой в восприятии для следующего программиста.

Конечная версия моей функции ready выглядела бы так:

$(function () {
  var getCust = $('#getButton').get(0);
  var getCustObsvble =
    Rx.DOM.click(getCust).debounce(2000).
    flatMapLatest(getCustomer);
  getCustObsvble.subscribe(custObservr);
});

В качестве бонуса этот процесс, интегрирующий событие click, вызов веб-сервиса и отображение данных выполняются асинхронно. RxJS абстрагирует все устрашающие детали, избавляя меня от этой работы.

Абстрагирование последовательностей событий

Абстрагируя процесс (и предоставляя богатый набор операторов), Rx делает многое похожим. Это позволяет вносить существенные изменения в процесс без значимой структурной модификации кода.

Например, поскольку RxJS-DOM интерпретирует наблюдаемый объект с одним событием (щелчком кнопки) во многом аналогично наблюдаемому объекту, генерирующему постоянную последовательность событий (вроде перемещения мыши), я могу значительно изменить UI, не делая ничего особенного в коде. Скажем, я решил, что вместо щелчка кнопки пользователем для выдачи запроса к веб-сервису я буду получать данные о клиенте, когда пользователь вводит идентификатор клиента.

Абстрагируя процесс (и предоставляя богатый набор операторов), Rx делает многое похожим.

Первым делом нужно сменить элемент, за событиями которого я намерен наблюдать. В данном случае это означает переключение с кнопки на текстовое поле, где содержится идентификатор клиента (я также изменю имя переменной, содержащей элемент):

var getCustId = $('#custId').get(0);

Я мог бы преобразовать любое количество событий для этого текстового поля в наблюдаемый объект. Если бы, например, я использовал событие потери фокуса ввода этим текстовым полем, то должен был бы соответственно вызывать веб-сервис, когда пользователь покидает это поле. Однако я предпочел реализовать более гибкий вариант. Вместо этого я получаю объект клиента, как только пользователь вводит в текстовое поле «достаточное» количество символов. Это означает переход на событие keyup, где при каждом нажатии на клавишу генерируется последовательность событий.

Это изменение в моем конвейере выглядит так:

var getCustObsvble = Rx.DOM.keyup(getCustId)

Когда пользователь вводит символы, я мог бы дождаться неоднократного вызова функции преобразования. По сути, если пользователь вводит быстрее, чем я в состоянии извлекать объекты Customer, в конечном счете у меня появится несколько незавершенных запросов. Я мог бы положиться на flatMapLatest, которая очищает эти запросы, но есть решение получше: в моей системе Customer Orders Management идентификатор клиента всегда имеет длину в четыре символа. В итоге пропадает смысл вызывать функцию преобразования, пока в текстовом поле не появится ровно четыре символа (и, между прочим, поскольку конвейер принимает идентификатор клиента и возвращает полный объект Customer, моя функция преобразования теперь действительно выполняет преобразование).

Чтобы реализовать это условие, мне нужно лишь добавить Rx-функцию filter в конвейер. Эта функция работает во многом аналогично LINQ-блоку Where: она должна быть передана другой функцией (функцией selector), которая содержит проверку и возвращает булево значение на основе данных, связанных с самым последним событием. И только те события, которые проходят проверку, будут передаваться подписанному на них наблюдателю. Функция filter будет автоматически передавать самый последний объект события в последовательности, поэтому в моей функций selector я могу использовать целевое свойство объекта события, чтобы извлекать текущее значение текстового поля, а затем проверять его длину.

Еще одно изменение в конвейере: я удалю функцию debounce, так как не вижу у нее никаких преимуществ в новом UI. Тем не менее, после значительных изменений взаимодействий в моем UI переработанный код, который создает наблюдаемый объект и подписывает наблюдатели, все равно остается структурно идентичным предыдущей версии:

var getCustObsvble = Rx.DOM.keyup(getCustId)
  .filter(e => e.target.value.length == 4)
  .flatMapLatest(getCustomer);
getCustObsvble.subscribe(custObservr);

И конечно, мне незачем вносить изменения в функции custObservr: они изолированы от изменений в UI.

Можно выполнить дополнительную модификацию, на этот раз в функции преобразования. Ей всегда передавалось три параметра — я просто игнорировал их, когда источником событий была кнопка. Первый параметр, передаваемый функции преобразования flatMapLatest, — это объект для наблюдаемого события. Я могу задействовать преимущества этого объекта события, чтобы исключить jQuery-код, который получал идентификатор клиента, и вместо этого извлекать значение текстового поля из объекта события. Это изменение делает мой код в большей мере свободно связанным, поскольку функция преобразования больше не привязана к конкретному элементу на странице.

Новая функция преобразования выглядит так:

function getCustomer(e)
{
  return Rx.DOM.getJSON("api/customerorders/customerbyid/" +
    e.target.value);
}

В этом заключается изящество абстракции Rx: смена элемента, генерирующего единственное событие, на элемент, создающий последовательность событий, потребовала модификации всего одной строки кода, которая образует конвейер (и дала мне возможность улучшить функцию преобразования). Изо всех внесенных мной изменений только одно скорее всего создаст проблемы: переименование переменной, где хранится элемент ввода. Это изменение также подчеркивает, что, как и в LINQ, секрет успеха в Rx заключается в освоении доступных операторов.

Абстрагирование вызовов веб-сервиса

Хотя абстракция Rx прекрасно справляется с изменениями в UI, от хорошей абстракции требуется делать то же самое и в обработке серверной части. Например, как быть, если страница изменена так, что я получаю не только данные о клиенте, но и информацию о его заказах? Чтобы сделать это интереснее, будем исходить из того, что нет никакого навигационного свойства, которое позволило бы мне получать данные о заказах как часть объекта Customer, и что мне придется делать второй вызов.

В случае Rx, я сначала создаю вторую функцию преобразования, которая конвертирует идентификатор клиента в набор заказов клиента:

function getCustomerOrders(e) {
  return Rx.DOM.getJSON("customerorders/ordersbycustomerid/" +
    e.target.value);
}

Затем нужно создать второй наблюдаемый объект, использующий это преобразование (данный код очень сильно похож на тот, который создает наблюдаемый объект клиента):

var getOrdersObsvble = Rx.DOM.keyup(getCustId)
  .filter(e => e.target.value.length == 4)
  .flatMapLatest(getCustomerOrders);

К этому моменту вы, вероятно, ожидали, что комбинирование и координирование этих наблюдаемых объектов потребует значительных усилий. Но, поскольку Rx делает все наблюдаемые объекты кажущимися очень похожими, вы можете объединять вывод от нескольких наблюдаемых в одну последовательность, обрабатываемую единственным (и сравнительно простым) наблюдателем.

Так, если у меня есть несколько наблюдаемых объектов, получающих заказы из нескольких источников, я мог бы использовать Rx-функцию merge для объединения всех заказов в одну последовательность, на которую можно было бы подписать единственный наблюдатель. Например, в следующем коде комбинируются наблюдаемые объекты, получающие текущие (getCurrentOrdersObsvble) и оплаченные заказы (getPostedOrdersObsvle); полученная последовательность затем передается одному наблюдателю с именем allOrdersObservr:

Rx.Observable.merge(getCurrentOrdersObsvble,
  getPostedOrdersObsvble).subscribe(allOrdersObservr);

В данном случае я хочу сделать нечто более интересное: объединить наблюдаемый объект, получающий объект Customer, с наблюдаемым объектом, получающим все заказы для этого клиента. К счастью, в RxJS есть оператор для этого: combineLatest. Оператор combineLatest принимает два наблюдаемых объекта и функцию обработки. Этой функции, переданной в combineLatest, отправляются самые последние результаты из каждой последовательности; кроме того, можно указывать, как должны объединяться результаты. В моем случае combineLatest обеспечивает больше функциональности, чем нужно мне, поскольку у меня только один результат от каждого наблюдаемого объекта: объект Customer от одного наблюдаемого и все заказы данного клиента от другого.

В следующем коде я добавляю новое свойство (Orders) в объект Customer от первого наблюдаемого объекта, а затем помещаю в это свойство результат от второго наблюдаемого объекта. Наконец, я подписываю наблюдатель CustOrdersObsrvr на обработку нового объекта — Customer+Orders:

Rx.Observable.combineLatest(getCustObsvble, getOrdersObsvble,
  (c, ords) => {c.Orders = ord; return c;})
  .subscribe(CustOrdersObsrvr);

Теперь мой custOrdersObservr может работать с созданным мной новым объектом:

var custOrdersObservr = {
  onNext: co => {
    // ...код, обновляющий страницу данными
    // о клиенте и его заказах...
  },
  onError: err => $("#Message").text(
    "Unable to retrieve Customer and Orders: " +
     err.description)
};

Заключение

Абстрагируя компоненты приложения всего до двух видов объектов (наблюдаемых и наблюдателей) и предоставляя богатый набор операторов для управления и преобразования результатов от наблюдаемых, RxJS обеспечивает высокую гибкость (и удобство в сопровождении) создаваемых веб-приложений.

Разумеется, всегда есть опасность при использовании мощной библиотеки, абстрагирующей сложные процессы в простой код: когда ваше приложение делает то, чего вы не ожидали, отладка может превратиться в кошмар, потому что вы не видите индивидуальные строки кода, скрытые этой абстракцией. Безусловно, вам больше не нужно писать весь этот код, и, кроме того, предполагается, что в уровне абстракции будет меньше ошибок, чем в коде, который бы вы писали. Это обычный компромисс между мощью библиотеки и доступом ко всему коду.

Решив использовать RxJS, в конечном счете вы получите выигрыш от того, что сможете существенно изменять свое приложение без значительного изменения его кода.


Питер Вогел (Peter Vogel) — архитектор систем и руководитель в PH&V Information Services. PH&V предоставляет комплексные консалтинговые услуги в самых разных областях — от дизайна UX до объектного моделирования и проектирования баз данных. С ним можно связаться по адресу peter.vogel@phvis.com.

Выражаю благодарность за рецензирование статьи экспертам Microsoft Стивену Клири (Stephen Cleary), Джеймсу Маккафри (James McCaffrey) и Дэйву Секстону (Dave Sexton).