Уже не единожды на просторах интернетов обсуждались плюсы вебсокетов над xmlhttprequest(ajax) запросами. Из них основные — это скорость доставки(т.к. соединение всегда открыто) и экономия ресурсов (т.к. соединение открыто единожды). Особенно хорошо вебсокеты показывают себя при больших нагрузках, где производительность начинает отличаться не в разы, а на порядок.
НО! Как по мне, есть один большой минус — это потеря состояние вызова. О чем это я? Ajax уже везде обернуто в Promise (будь то браузерный fetch, axios или superagent) и мы имеем удобный интерфейс, пользуясь ajax. Вот пример:
fetch('www.tralala.com/api', {}) .then(console.log) .catch(console.error)
Так на каждый вызов fetch мы описываем удачную логику в then, а неудачную в catch. И сколько бы вызовов не произошло, мы точно знаем на какой запрос получили ответ. Удобненько? Да! С вебсокетами все сложнее:
const ws = new WebSocket("wss://tralala.com/ws") ws.onmessage = event => { console.log(event.data) // Тут у нас строка } // Отправить можем только строку ws.send(JSON.stringify({ data: "привет" }))
Если вы уже игрались в вебсокеты, то знаете, что при получении пакета вебсокет возвращает нативное событие в котором в поле data будет лежать строка(и только строка), в которой может быть как и успешный ответ, так и ошибка. И вообще на кой нам строка…
А если мы отправим 20 запросов в очень короткий промежуток, а сервер каждый запрос обрабатывает с разной скоростью (и вернет ответы по мере готовности), то получится, что пакеты-ответы мы получим обратно в случайном порядке (так кстати обычно и происходит), как определить, на какой запрос мы получили ответ? Получается, что у нас есть крутая, быстрая сабля, махать которой мы не умеем. Жизнь начинает казаться серой, давайте исправлять!
На просторах Github я не нашел достойной реализации, скорее потому что универсальной её сделать сложно, но я уверен, что мы можем описать кейс под конкретно наш случай и уложиться в пару десятков строк. Готовьте спицы и резину, пишем велосипед!
Итак, давайте определимся с концепцией:
- Во-первых, для любого общения нужен протокол (именно поэтому люди здороваются, прежде чем начать общение, устанавливают язык/протокол/формат общения). Давайте его придумаем! Условимся, что клиент всегда будет называть метод который хочет вызвать, а сервер выдавать результат вызова этого метода, либо ошибку (где будут поля message и code ошибки), если такового не существует.
- Мы хотим какую-то простую функцию, которую будем вызывать, например так: WS(“название_метода”, “параметры”).then().catch()
- Чтобы на конкретный ответ мы знали, какой запрос ему соответствовал, нам нужен маркер (uniq_id), это будет любой уникальный айди. Я составлю его из случайно сгенерированной строки и текущего таймпстемпа. И когда сервер пришлет помеченный нашим id ответ, который мы можем ассоциировать с тем, что лежит на фронтенде, но нам нужен какой-то message-broker для правильного ассоциирования айди.
- Чтобы хранить и управлять id, можно было обойтись массивом, либо списком и парой функциями поверх, но мы возьмем вот эту полезную штуку. Классический паттерн Event Emitter, который дает возможность подписываться/отписываться на события и вызывать callback по событию. Названием события будет уникальный id.
- В качестве сервера возьмем node.js ws и express.js . Потому что быстро и просто. Но вы можете попробовать другой бекенд.
Начнем с сервера:
Во-первых, создадим json заглушки, которые просто будут возвращать какие-то данные, без разницы. Это нужно будет для тестов. Вы их найдете в ./app/stub/
Далее основа сервера, index.js
const express = require('express') const app = express() const WebSocket = require('ws') const { privileges, users } = require('./client/stub/index') const { utils: { throwError, handleInvalidJSON, handleInvalidData, handleUnknownMethod, getRandomInt } } = require('./app') const appPort = 3000 const socketPort = 3001 // Сверим наш css и js app.use(express.static(`${__dirname}/public`)) // отдаем index.html по рутовому роуту app.get('/', (req, res) => { res.sendFile(`${__dirname}/public/index.html`) }) // Запускаем экспресс приложение по порту appPort app.listen(appPort, initWebSockets) function initWebSockets() { // Запускаем вебсокет сервер const WS = new WebSocket.Server({ port: socketPort }) WS.on('connection', socket => { // После соединения вебсокетов, подписываем на событие message socket.on('message', data => { // Пытаемся распарсить JSON строку try { data = JSON.parse(data) } catch (e) { return handleInvalidJSON(socket) } // Распаковываем распарсенные данные из JSON const { method, id = null, params = {} } = data // Вам скорее всего пригодится, сюда можно передать параметры для описание логики вашего кастомного приложения, но я просто залогирую это console.log('request params: ', params) try { // Убедимся что фронтенд не забыл передать id(маркер) if (!id) throwError('id is required', 400) // Это не нужно для вашей реализаци, тут я просто искусственно создаю разное время ответа сервера const timeout = getRandomInt(500, 1250) // Простым образом создается задержка ответа setTimeout(() => { // Роутинг по методам switch(method) { case 'getUsers': return socket.send(JSON.stringify({ id, data: users })) case 'getPrivileges': return socket.send(JSON.stringify({ id, data: privileges })) default: handleUnknownMethod(method, id, socket) } }, timeout) } catch (err) { // Обработаем все возникшие ошибки и покажем это фронтенду handleInvalidData(err, id, socket) } }) }) }
Методы-хелперы вынесены в отдельный файл app/utils/index.js
/** * Более удобный генератор ошибок * @param {String} message * @param {String|Number} code */ exports.throwError = (message = 'Internal error', code = 500) => { const err = new Error(message) err.code = code throw err } /** * Handler of the invalid JSON * @param {Object} socket */ exports.handleInvalidJSON = socket => { const errData = JSON.stringify({ id: null, error: { message: 'Invalid JSON', code: 400 } }) socket.send(errData) } /** * Обработчик для невалидных данных * @param {Object} err * @param {String|Number} id * @param {object} socket */ exports.handleInvalidData = (err, id, socket) => { const { message, code } = err const errData = JSON.stringify({ id, error: { message, code } }) socket.send(errData) } /** * Обработчик для метода, которого не существует * @param {String} method * @param {String|Number} id * @param {Object} socket */ exports.handleUnknownMethod = (method, id, socket) => { const err = new Error(`Unknown method: ${method}`) err.code = 400 this.handleInvalidData(err, id, socket) } /** * Возвращаем случайное число в промежутке * @param {Number} min * @param {Number} max * @returns {Number} */ exports.getRandomInt = (min, max) => { return Math.floor(Math.random() * (max - min) + min) }
Теперь клиентская часть!
CSS и index.html не особо интересно, там 4 кнопки которые вызывают соответствующие функции и записывают результат в соседний блок. Все находится в папке public
// Как вы заметили я использую функцию require из node.js // Для подгрузки event-emitter на фронтенде, // чтобы легко этого добиться, вы можете взять // watchify - это модуль из browserify или использовать любой другой сборщик, например webpack // https://github.com/browserify/browserify // https://github.com/browserify/watchify const emitter = require('event-emitter')() const hasListeners = require('event-emitter/has-listeners') // Функция возвращает уникальную строку состоящу из timestamp#random_str const getUniqId = () => Date.now().toString() + '#' + Math.random().toString(36).substr(2, 9) // Создаем вебсокет соединение с бекендом const ws = new WebSocket("ws://localhost:3001") // Выберем заранее все кнопочки и DOM элементы const getUserBtn = document.querySelector('#get-users') const getUserResult = document.querySelector('#get-users-response') const getPrivilegesBtn = document.querySelector('#get-privileges') const getPrivilegesResult = document.querySelector('#get-privileges-response') const getUsersAndPrivilegesBtn = document.querySelector('#get-all') const getUsersAndPrivilegesResult = document.querySelector('#get-all-response') const errorBtn = document.querySelector('#error') const errorResult = document.querySelector('#error-response') // Установим максимальное время ожидание ответа сервера в секундах const serverExceedTimeout = 2 // Напишем обработчик на нативное сообщение вебсокета ws.onmessage = e => { try { // Пытаемся распарсить JSON данные от сервера const { id, data, error } = JSON.parse(e.data) // Генерируем событие по присланному с серверу id emitter.emit(id, { data, error }) } catch (e) { console.warn('onmessage error: ', e) } } // Тут основная идея нашей статьи const WS = (method, params) => new Promise((resolve, reject) => { // получаем уникальный айди const id = getUniqId() // Посылаем на сервер id, метод и параметры // параметры никак не используются, просто я показал как это сделать. // На их основе вы будете описывать условия и логику. // По названию метода мы будем попадать в нужный case // и отдавать запрашиваемые даные, сейчас так это проще // но в будущем я бы сделал динамический вызов функций, но статья не об этом. // Держите в голове мысль о том что по названию метода мы роутим свою логику. ws.send(JSON.stringify({ id, method, params })) // С помощью event emiiter мы единожны подписываемся на событие // https://github.com/medikoo/event-emitter#usage // Проверяем что вернул сервер, если данные то вызываем resolve, // иначе вызываем reject с ошибкой emitter.once(id, ({ data, error}) => { data ? resolve(data) : reject(error || new Error('Unexpected server response.')) }) // Ждем определенный в константе serverExceedTimeout промежуток времени // Если сервер так и не ответил, значит сеть оборвалась или // что-то пошло не по плану поэтому генерируем ошибку // и отписываем наш event emiiter от сообщения чтобы не разбухла память setTimeout(() => { if (hasListeners(emitter, id)) { reject(new Error(`Timeout exceed, ${serverExceedTimeout} sec`)) emitter.off(id, resolve) } }, serverExceedTimeout * 1000) }) // обработчик кнопки getUserBtn handler, вызывает метод // на сервер "getUsers" и передаем тоде тестовые параметры getUserBtn.onclick = () => { // Перед выполнением функции очистим контейнер результата, так наглядней getUserResult.innerHTML = '' WS('getUsers', { someData: [1, 2, 3] }) .then(data => { console.log(data) getUserResult.innerHTML = JSON.stringify(data) }) .catch(console.error) } // обработчик кнопки getPrivilegesBtn handler, вызывает метод // на сервер "getPrivileges" и передаем тоде тестовые параметры getPrivilegesBtn.onclick = () => { // Перед выполнением функции очистим контейнер результата, так наглядней getPrivilegesResult.innerHTML = '' WS('getPrivileges', { anotherData: [3, 2, 1] }) .then(data => { console.log('getUsers received: ', data) getPrivilegesResult.innerHTML = JSON.stringify(data) }) .catch(console.error) } // Давайте убедимя что промисы работаю правильно // и мы можем использовать Promise.all // Вызовим Promise.all([]) с методами "getPrivileges" и "getUserBtn" getUsersAndPrivilegesBtn.onclick = () => { // Перед выполнением функции очистим контейнер результата, так наглядней getUsersAndPrivilegesResult.innerHTML = '' Promise.all([ WS('getUsers', { someData: [1, 2, 3] }), WS('getPrivileges', { someData: [5, 4, 1] }), ]) .then(([users, privileges]) => { console.log('users: ', users) console.log('privileges: ', privileges) getUsersAndPrivilegesResult.innerHTML = JSON.stringify({users, privileges}) }) .catch(console.error) } // А теперь попробуем вызвать метод которого на сервере нету // ожидается что сервер не найдет нужного кейса и вернет ошибку // а мы её обработает в блоке catch errorBtn.onclick = () => { // Перед выполнением функции очистим контейнер результата, так наглядней errorResult.innerHTML = '' WS('omgWrongMethod') .catch(err => { console.error(err) errorResult.innerHTML = JSON.stringify(err) }) }
Вот и вся реализация. Теперь вызов выглядит так, как мы и хотели:
WS('getUsers', { /* передаем какие-то данные на сервер, опционально */ }) .then(data => { // обрабатываем успешный ответ от вебсокета }) .catch(data => { // обрабатываем ошибку })
Эффект достигнут!
Весь исходный код можете увидеть тут.
Возможно кто-то заметил некое идейное сходство нашего примитивного “протокола” с JSON-RPC, в следующей статье я расскажу, как подружить вебсокеты с JSON-RPC и решить последний недостаток, в данной реализации нету “гарантии” доставки пакета.