Хабрахабр

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

О технологии 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 без дополнительной обвязки неблагодарное занятие. Далее необходимо реализовать инфраструктуру для отправки и получения асинхронных сообщений. В спецификацию json-rpc, специально для работы с транспортом websocket, был введен идентификатор id, который позволяет сопоставить вызов удаленной процедуры клиентом с ответным сообщением от веб-сервера. В качестве такой инфраструктуры может выступать, например удаленный вызов процедур (RPC). Это протокол я бы предпочел всем остальным возможностям, однако пока что не нашел удачной реализации этого протокола для серверной части на node.js.

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

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

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

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

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

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

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

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

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

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

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

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

import io from 'socket.io-client'; const socket = io({ path: '/ws', transports: ['websocket']
}); const remoteCall = data => new Promise((resolve, reject) => 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 года

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть