Websocket или REST? А зачем выбирать?

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

TD;DR

В этой статье я описываю, как создать систему, в которой абсолютно каждое действие можно выполнять, как с помощью Websocket, так и с помощью обычных запросов на входные точки REST. Ссылка на код TODO-приложения в конце статьи.

Вступление

Я предполагаю, что все читающие статью ознакомлены с концепцией того, что такое веб-сокеты и HTTP, а также чем отличаются между собой запросы по HTTP и соединение по WS, но на всякий случай уточню этот момент. Когда браузер обменивается данными с сервером с помощью обычных HTTP-запросов, то при каждом запросе браузер устанавливает соединение, получает данные с сервера и потом разрывает соединение. Дела обстоят немного по-другому с Websocket: браузер единоразово устанавливает соединение с сервером, и по этому соединению можно передавать данные в обе стороны от сервера к клиенту, и от клиента к серверу, без задержек на установку соединения.

Подробнее о REST

REST - это довольно простой и самый распространенный способ к созданию API серверных приложений (по крайней мере для веба). Этот API представляет собою множество входных точек, отправляя запрос на которые, клиент может получить ответ сервера. К его плюсам можно отнести то, что он простой в реализации и понимании. Большинство пользователей интернета интуитивно понимают, как им пользоваться, даже не зная, что это такое, ведь когда пользователь вводит URL в строку браузера и нажимаем кнопку подтверждения, он инициирует отправку запроса на REST API сервера. С точки же зрения разработчиков, огромным плюсом этого подхода является простота в документировании, так как для REST существует много инструментов, как скажем Swagger, которые позволяют сделать приятный графический интерфейс, который предоставляет возможность посмотреть, какие сущности есть в системе, какие данные на сервер нужно отправлять, и какие можно получить. Это не настолько круто, как скажем у GraphQL(основной соперник за территорию REST), который задокументирован по умолчанию, но гораздо лучше, чем у соединения по WebSocket. Перед написанием статьи, я посмотрел, и оказывается инструменты для документирования Websocket’а есть, но мне ни разу не приходилось самостоятельно наблюдать, чтоб их кто-то использовал.

Подробнее о WebSocket

Как я уже написал выше, клиент может инициализировать соединение по WebSocket с сервером один раз и в будущем использовать это соединение для передачи данных на сервер и получения необходимых данных с сервера, без необходимости снова подключаться при каждой новой передачи данных. Это оптимально сказывается на скорости доставки информации. Следует упомянуть так же, что каждое новое соединения по Websocket нагружает сервер, так как серверу необходимо удерживать это соединение в памяти, в отличии от обычных запросов по HTTP.

Предыстория

Думаю, что теории достаточно – перейдем к предыстории. Websocket уже давно стал стандартом в вебе для реализации чатов и нотификаций, но это далеко не его единственное применение. Примерно полгода назад я разбирался с фреймворком для создания изоморфных приложений Meteor и встретил весьма любопытную вещь – буквально каждое действие между клиентом и сервером инициируется с помощью Websocket. Эта идея мне понравилась, так как такой подход позволяет буквально моментально передавать данные с клиента на сервер. При этом Websocket - это не серебряная пуля. Для мобильных приложений соединения по Websocket’у вещь гораздо более сложно реализуемая, чем для веба с его вездесущим js. К тому же отсутствие понимая, как это работает в Meteor тоже не предавало уверенности в этом решении. Поэтому я начал размышлять, над тем как сделать так, чтоб можно было получить преимущество обеих подходов: скорость Websocket и простоту REST. Дополнительной нагрузкой при этом пренебрегая, так как многие системы уже используют Weboscket для тех же нотификаций. Postman к примеру обрабатывает миллионы соединений с помощью Websocket. Так вот, раздумывая над проблемой “Что лучше выбрать: оставить REST или Websocket?”, я пришел к следующему выводу “А зачем выбирать?”. Зачем выбирать, если можно использовать оба подхода и даже без увеличения кодовой базы вдвое.

REST и REST-Over-Websocket. Реализация на сервере

Почему бы не взять соединение между сервером и клиентом по WebSocket и не сделать над ним надстройку в виде REST API. Как это должно работать? Обо все по порядку.

В качестве языка программирования в примерах я буду использовать javascript.

Как происходит реализация REST API на сервере? Так выглядит программное объявление входной точки REST в фреймворке express:

app.get(‘/tasks’, (req, reply) => {
  reply.send({ hello: ‘world’ })
})

Мы берем и регистрируем обработчик запроса в системе. Каждый раз, когда на входную точку /tasks будет приходить запрос, в теле которого сказано, что это get-запрос, мы будем вызывать функцию обработчик, которая ответственная за отправку данных обратно на клиент.

Хорошо, идем дальше. Когда идёт речь о общении между клиентом и сервером по Websocket, то это уже не о входных точках, а о сообщениях. Клиент и сервер могут отправлять друг другу сообщения без любых требований к формату этих сообщений, но библиотеки вроде socket.io предоставляют разработчикам надстройку над этим API, позволяя указывать тип событий передаваемых от клиента к серверу (и наоборот). На сервере регистрация обработчиков выглядит следующим образом:

socket.on('chat message', (msg) => {
  socket.emit(‘response’, { hello: ‘world’ })
});

И если посмотреть на код объявления регистрации обработчика входной точки для REST и код регистрации обработчика действия вебсокета, то между собой они в общем и не очень отличаются. Так почему бы не регистрировать обработку действий системы и в Websocket и в REST? Это сделать довольно просто и избежать дублирования кода можно с помощью абстракций.

Абстракция 1. Действие.

interface IAction {
  name: string; // ‘/tasks/add’, ‘/tasks/get’, ‘/tasks/update’, etc
handler: (reqData: ReqData) => ResponseData
// validators, serializators, etc go here
}

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

Абстракция 2: Регистр действий.

interface IActionRegistry {
  getAction(actionName: string): IAction | null
registerAction(action: IAction): null
}

Регистр действий – это новое промежуточное звено между приемом запроса каким-либо образом и его обработкой. Когда на сервер по HTTP REST или по Websocket приходит запрос, мы ожидаем, что в теле запроса каким-то образом специфичным для входных точек приложения (REST-запросы, Websocket-запросы, etc) указано имя действия, и вызываем обработчик этого действия. В коде для обработчика HTTP-запросов это должно выглядит примерно следующим образом:

httpLibrary.onRequest((req, reply) => {
    const { url: actionName, body, params, query } = req
    const actionsRegistry: IActionRegistry =- getActionsRegistry()
    const action: IAction | null = actionsRegistry.getAction(actionName)
if (action === null) {
        return reply.sendClientError()
    }
const { code, response } = action.handler({ body, params, query })
return reply.status(code).send(response)
})

При вызове обработчика через обычный REST-запрос, URL должен являть собою имя действия, а в теле запроса должны приходить данные необходимые для вызова обработчика действия.

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

wsLibrary.onMessage((message: RestOverWebsocketData) => {
   const { nonce, actionName, body, params, query } = message
   const actionRegistry: IActionRegistry = getActionRegistry()
   const action: IAction | null = actionRegistry.getAction(actionName)  
   if (action === null) {
     websocket.send({ nonce, error: ‘Not found’, code: 404 })
  }
const { code, response } = action.handler({ body, params, query })
   return websocket.send(‘response’, { nonce, code, response })
})

Здесь следует заметить, что при соединении между клиентом и сервером по websocket нет концепции запросы-ответы – есть просто сообщения, которыми обмениваются клиент и сервер, поэтому со стороны клиента нужно отправлять уникальное значение nonce(number that can only be used once) – идентификатор запроса по вебсокету, и когда сервер отправит сообщение с определенным значением nonce, то получив его на клиенте мы можем сделать вывод, что это ответ на отправленный запрос с таким же значением nonce.

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

REST и REST-Over-Websocket в браузере

Для того, чтоб каждое действие системы можно было выполнять через Websocket и через обычные HTTP-запросы, реализовать это на сервере недостаточно – нужно это реализовать ещё и на клиенте. Дублирование кода тоже вовсе необязательно, если немножечко абстрагироваться от отправки запросом с помощью fetch.

При реализации на клиенте нужно подумать так же о следующих тонкостях:·

  • Вебсокеты не позволяют отслеживать процесс загрузки и прогресс отправки сообщений (разве что через чтение свойства buferredAmount, которое возвращает количество байтов данных, помещенных в очередь посредством вызовов WebSocket.send();

  • Возможно в системе должна быть возможность динамически импортировать код библиотеки для соединения по websocket;

  • Запросы по Websocket не кешируются браузером, и их нельзя промежуточно обрабатывать в Service Worker'ах.

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

Абстракция для клиента 1 – действия. Не думаю, что ее нужно отдельно пояснять.

interface IAction {
name: string
onlyHttp?: boolean
// other frontend stuff such as should we include credentials
}


Абстракция для клиента 2 RequestResolvers. 

Interface IRequestResolve {
  request(action: IAction, actionPayload: IActionData): IServerResponse
  shouldRun(action: IAction): boolean
}

У сущностей, которые имплементируют интерфейс IRequestResolver, должно быть два метода. Первый принимает действие и данные, которые нужно передать на сервер, для выполнения этого действия, и возвращает данные полученные от сервера. А второй метод принимает действие и возвращает булево значение, которое указывает должно ли это действие выполняться этим ресолвером. К примеру, если библиотека для работы с сервером по веб-сокету подгружается динамически или соединение по веб-сокету с сервером ещё не установлено, или если у пользователя выключен интернет, то этот метод возвращает false, и тогда действий нужно выполнять посредством обычного HTTP-запроса. В целом для фронт-енда нужно реализовать 3 ресолвера: для веб-сокетов, для обычных http-запросов и третий, который из предыдущих 2 выбирал нужный для конкретного действия. Я думаю объяснять, как это реализовывать не нужно. Пример реализации есть в репозитории ниже.

Что нужно вынести

Суть статьи и идеи REST-Over-Websocket немного больше, чем просто способ реализации вызова всех действий по Websocket и обычными HTTP-запросами. Суть идеи в том, что мы не должны больше хардкодить действия системы вот таким образом:

app.get(‘/tasks’, (res, req) => {})

Все действия системы должны быть абстракцией, и если мы захотим добавить возможность выполнять все эти действия скажем ещё через телеграмм бота или по-другому протоколу, то все что нам нужно будет сделать - это лишь написать один адаптер для этого протокола, который принимает запрос, получает название действия и доступные данные из этого запроса специфичным для протокола образом, получает действие по его названию из регистра действий системы, и потом отправляет данные обратно специфичным для протокола образом в специфичном для протокола формате. Ещё одним неочевидным преимуществом отсутствия захардкоженных действий является, то что мы могли сделать одно действие, которое бы принимало массив названий действий, которые нужно выполнить, и массив данных, с которыми эти действия нужно выполнить, что позволило бы избежать множества юз-кейсов backend-for-frontend паттернов.

Words are chip, show me the code!

Обещанная ссылка на репозиторий с таск-лист приложением, в котором все действия можно выполнять и через HTTP, и через Websocket.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Стоит ли писать ещё статьи об оригинальных подходах к оптимизации приложений?
0% Да, хочу почитать о продвинутом тризоморфном рендеринге. (рендеринг на клиенте, на сервере, и в сервис воркере) 0
0% Да, хочу почитать об имплементации мемоизированного DOM 0
0% Нет, я не хочу читать читать статьи об оригинальных подходах к оптимизации приложений 0
100% Нет, не хочу читать 1
Проголосовал 1 пользователь. Воздержался 1 пользователь.
Источник: https://habr.com/ru/post/646401/


Интересные статьи

Интересные статьи

Мы совместно с коллегами из Aitarget Tech, которые уже восемь лет ведут разработку в сфере рекламных технологий, обучили трансформационную ML-модель с целью генерации изображений для рекламных кампани...
23 июня DINS проводит бесплатную онлайн-конференцию Java Meeting Point. Наша цель — объединить инженеров из разных городов на одной площадке, дать возможность обсудить но...
Решения для больших компаний обычно должны выдерживать высокие нагрузки. Когда в штате много десятков тысяч человек, и значительная доля из них ежедневно пользуются ...
Хочу поделиться опытом автоматизации экспорта заказов из Aliexpress в несколько CRM. Приведенные примеры написаны на PHP, но библиотеки для работы с Aliexpress есть и для...
В этой статье я опишу наш опыт миграции Preply в Kubernetes, как и почему мы это сделали, с какими трудностями столкнулись и какие преимущества приобрели.