Websocket

Уже не единожды на просторах интернетов обсуждались плюсы вебсокетов над 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 и решить последний недостаток, в данной реализации нету “гарантии” доставки пакета.