Инструменты Node.js разработчика. Удаленный вызов процедур на веб-сокетах

в 18:09, , рубрики: javascript, node.js, nodejs, React, react-admin, ReactJS, socket.io, WebSocket, Разработка веб-сайтов

О технологии websocket часто рассказывают страшилки, например что она не поддерживается веб-браузерами, или что провайдеры/админы глушат трафик websocket — поэтому ее нельзя использовать в приложениях. С другой стороны, разработчики не всегда заранее представляют подводные камни, которые имеет технология websocket, как и любая другая технология. По поводу мнимых ограничений сразу скажу, что технологию websocket сегодня поддерживают 96.8% веб-браузеров. Вы можете сказать, что оставшиеся за бортом 3,2% — это много, это миллионы пользователей. Я с Вами вполне соглашусь. Только все познается в сравнении. Тот же XmlHttpRequest, который все и уже не первый год используют в технологии Ajax, поддерживается 97.17% веб-браузеров (не сильно больше, правда?), а fetch — вообще, 93.08% веб-браузеров. В отличие от websocket, такой процент (а раньше он был еще меньше) уже давно никого не останавливает при использовании технологии Ajax. Так что использовать в настоящее время fallback на long polling не имеет никакого смысла. Хотя бы потому, что веб-браузеры, которые не поддерживают websocket — это те же самые веб-браузеры, которые не поддерживают XmlHttpRequest, и в реальности никакого fallback не произойдет.

Вторая страшилка, про бан на websocket со стороны провайдеров или админов корпоративных сетей — также необоснованный, так как сейчас все используют протокол https, и понять что открыто соединение websocket (не взломав https) невозможно.

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

Итак, объект WebSocket в веб-браузере имеет, прямо скажем, очень лаконичный набор методов: send() и close(), а также унаследованные от объекта EventTarget методы addEventListener(), removeEventListener() и dispatchEvent(). Поэтому разработчик должен при помощи библиотек (как правило) или самостоятельно (практически невозможно) решить несколько задач.

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

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

Большинство библиотек для работы с websocket поддерживает прозрачное для программиста повторное соединение с сервером. Использовать такую библиотеку всегда более надежно, чем разрабатывать свою реализацию.

Далее необходимо реализовать инфраструктуру для отправки и получения асинхронных сообщений. Использовать для этого «голый» обработчик события onmessage без дополнительной обвязки неблагодарное занятие. В качестве такой инфраструктуры может выступать, например удаленный вызов процедур (RPC). В спецификацию json-rpc, специально для работы с транспортом websocket, был введен идентификатор id, который позволяет сопоставить вызов удаленной процедуры клиентом с ответным сообщением от веб-сервера. Это протокол я бы предпочел всем остальным возможностям, однако пока что не нашел удачной реализации этого протокола для серверной части на node.js.

И, наконец, необходимо реализовать масштабирование. Вспомним, что периодически происходит разрыв соединения между клиентом и сервером. Если нам мало мощности одного сервера, мы можем поднять еще несколько серверов. В этом случае, после разрыва соединения, не гарантируется подключение к тому же самому серверу. Как правило для координации нескольких серверов websocket используют сервер redis или кластер серверов redis.

И, к сожалению, рано или поздно мы все равно упремся в производительность системы, так как возможности node.js по количеству одновременно открытых соединений websocket (не следует путать это с быстродействием) существенно ниже чем у специализированных серверов типа очередей сообщений и брокеров. А необходимость перекрестного обмена между всеми экземплярами серверов websocket через кластер серверов redis, после некоторой критической точки, не будет давать существенного прироста в количестве открытых соединений. Путь решения этой проблемы, в использовании специализированных серверов, например AMQP и MQTT, которые работают, в том числе и с транспортом websocket. Но сегодня мы их не будем рассматривать.

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

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

Я сразу исключу из рассмотрения те библиотеки, который реализуют исключительно только fallback на устаревшие виды транспорта, так как на сегодняшний день этот функционал не актуальный, а библиотеки, которые реализуют более широкий функционал, как правило, реализуют и fallback на устаревшие виды транспорта.

Начну с наиболее популярной библиотеки — socket.io. Сейчас можно услышать мнение, скорее всего справедливое, о том, что эта библиотека медленная и затратная по ресурсам. Скорее всего так и есть, и она работает медленнее чем нативные websocket. Однако, на сегодняшний день это наиболее развитая по своим средствам библиотека. И, еще раз повторюсь, при работе с websocket, основным сдерживающим фактором выступает не быстродействие, а количество одновременно открытых соединений с уникальными клиентами. А это вопрос лучше решать уже вынесением соединений с клиентами на специализированные серверы.

Итак, soсket.io реализует надежное восстановление при разрыве соеднинения с сервером и масштабирование при помощи сервера или кластера серверов redis. socket.io, фактически, реализует свой индивидуальный протокол обмена сообщениями, который позволяет реализовать обмен сообщениями между клиентом и сервером без привязки к определенному языку программирования.

Интересной возможностью socket.io есть подтверждение обработки события, в котором с сервера на клиент можно вернуть произвольный объект, что позволяет реализовать удаленный вызов процедур (хотя он не соответсвует стандарту json-rpc).

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

Библиотека faye faye.jcoglan.com. Реализует протокол bayeux, который разработан в проекте CometD и реализует подписку/рассылку сообщений на каналы сообщений. Также в этом проекте поддерживается масштабирование при помощи сервера или кластера серверов redis. Поптыка найти способ реализации RPC не увенчалась успехом, так как она не укладывалась в схему протокола bayeux.

В проекте socketcluster socketcluster.io, упор сделан на масштабирование сервера websocket. При этом, кластер серверов websocket создается не на основе сервера redis, как в первых двух упомянутых библиотеках, а на основе node.js. В связи с этим, при разворачивании кластера нужно было запускать достаточно сложную инфраструктуру брокеров и воркеров.

Теперь перейдем к реализации RPC на socket.io. Как я уже сказал выше, в этой библиотеке уже реализовна возможность для обмена объектами между клиентом и сервером:

import io from 'socket.io-client';

const socket = io({
  path: '/ws',
  transports: ['websocket']
});

const remoteCall = data =>
  new Promise((resolve, reject) => {
    socket.emit('remote-call', data, (response) => {
      if (response.error) {
        reject(response);
      } else {
        resolve(response);
      }
  });
});


const server = require('http').createServer();
const io = require('socket.io')(server, { path: '/ws' });

io.on('connection', (socket) => {
  socket.on('remote-call', async (data, callback) => {
handleRemoteCall(socket, data, callback);
  });
});

server.listen(5000, () => {
  console.log('dashboard backend listening on *:5000');
});

const handleRemoteCall = (socket, data, callback) => {
  const response =...
  callback(response)
}

Такова общая схема. Сейчас рассмотрим каждую из частей в привязке к конкретному приложению. Для построения админки я использовал библиотеку react-admin github.com/marmelab/react-admin. Обмен данными с сервером в этой библиотеки реализован с использованием провайдера данных, который имеет очень удобную схему, практически своеобразный стандарт. Например для получения списка вызывается метод:


dataProvider(
  ‘GET_LIST’,
   ‘имя коллекции’,
  { 
    pagination: {
       page: {int},
       perPage: {int}
    },
     sort: {
       field: {string},
       order: {string}
    },
    filter: {
      Object
    }
}

Этот метод в асинхронном ответе возвращает объект:


{
    data: [ коллекция объектов],
    total: общее количество объектов в коллекции
}

В настоящее время существует впечатляющее количество реализаций провайдеров данных react-admin для различных серверов и фреймворков (например firebase, spring boot, graphql и т.п.). В случае с RPC реализация получилась наиболее лаконичной, так как объект передается в исходном виде в вызов функции emit:


import io from 'socket.io-client';

const socket = io({
  path: '/ws',
  transports: ['websocket']
});

export default (action, collection, payload = {}) =>
  new Promise((resolve, reject) => {
    socket.emit('remote-call', {action, collection, payload}, (response) => {
      if (response.error) {
        reject(response);
      } else {
        resolve(response);
      }
    });
});

К сожалению, на серверной стороне пришлось сделать чуть больше работы. Чтобы организовать сопоставление функций, обрабатывающих удаленный вызов, был разработан роутер, похожий на роутер express.js. Только вместо сигнатуры middleware (req, res, next) реализация опирается на сигнатуру (socket, payload, callback). В результате получился всем нам привычный код:


const Router = require('./router');

const router = Router();

router.use('GET_LIST', async (socket, payload, callback) => {
  const limit = Number(payload.pagination.perPage);
  const offset = (Number(payload.pagination.page) - 1) * limit
  return callback({data: users.slice(offset, offset + limit ), total: users.length});
});

router.use('GET_ONE', async (socket, payload, callback) => {
  return callback({ data: users[payload.id]});
});

router.use('UPDATE', async (socket, payload, callback) => {
  users[payload.id] = payload.data
  return callback({ data: users[payload.id] });
});

module.exports = router;

const users = [];
for (let i = 0; i < 10000; i++) {
  users.push({ id: i, name: `name of ${i}`});
}

С подробностями реализации роутера можно познакомиться в репозитории проекта.

Все что осталось это назначить провайдер для компонента Admin:


import React from 'react';
import { Admin, Resource, EditGuesser } from 'react-admin';
import UserList from './UserList';
import dataProvider from './wsProvider';

const App = () => <Admin dataProvider={dataProvider}>
  <Resource name="users" list={UserList} edit={EditGuesser} />
</Admin>;

export default App;

apapacy@gmail.com
14 июля 2019 года

Автор: apapacy

Источник


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


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