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.

Комментарии (18)


  1. danial72
    19.01.2022 13:29

    Может не стоит изобретать велосипед и просто использовать grpc ?


    1. lesivOlexandr Автор
      19.01.2022 13:53
      +2

      gRPC для браузера работает за счёт fetch и xhr, а не websocket. Безусловно, в gRPC есть свое область применения, но это не то, что я имел в виду под выполнением каждого действия и с помощью http-запросов, и websocket


      1. t38c3j
        19.01.2022 15:09
        +2

        Есть jsonrpc, можно в http и ws


    1. kaegoorn48
      20.01.2022 12:17

      Вебсокет поддерживается, наверное, всем чем можно, а если что-то не поддерживает, то реализовать поддержку можно без проблем, есть RFC 6455 и 8441 сел и написал.
      GRPC это сильно монструозная штука, имеющая ряд недостатков:

      • если вы разрабатываете публичный API, то gRPC не для вас, т.к. не gRPC не поддерживается рядом клиентов или же эта поддержка

      • если вы пишите асинхронный однопоточный микросервис, то встраивание gRPC окажется большой проблемой. Нужно будет решать проблему с очередью сообщений, кроме того однопоточным ваш сервис уже не станет

      • нужно решать проблему балансировки. Конечно современные прокси-сервисы поддерживают grpc, но в любом случае это добавляет геморроя

      • если вам нужен нестандартный сериализатор, то это тоже большая проблема

      • преимущество GRPC в части снижения оверхеда, основанное на предположении, что бинарная сериализация быстрее чем, например, jsonrpc, к сожалению, в данном случае не работает.

      Но если ваш API не является публичным, работает в закрытом контуре, и используются многопоточные сервисы, то gRPC весьма хорош.

      Почти все вышеперечисленные недостатки касаются также Apache Thrift


  1. fzn7
    19.01.2022 13:32

    Пользуясь случаем передаю привет от GraphQL


    1. lesivOlexandr Автор
      19.01.2022 14:04
      +2

      GraphQL в браузере работает поверх обычных HTTP-запросов, хотя есть так же библиотеки GraphQL Over Websocket, которые делают по сути то же самое, что описано статье, но для GraphQL


  1. niyaho8778
    19.01.2022 13:45

    если я открываю несколько соединений к одно и тому же серверу через

    var x = new websocker (подчеркиваю через new).

    Мне в прошлый раз скзали что браузер автоматически объединяет (берет соседнее) соединение или это вранье ?


    1. lesivOlexandr Автор
      19.01.2022 14:15
      +1

      Только что проверил, и при открытии соединений с помощью new WebSocket(url) в браузере в вкладке Networks их все показывает, как разные, поэтому вряд ли браузер их переиспользует


  1. return
    19.01.2022 13:58

    Держать постоянно открытый коннект — далеко не всегда хорошая и дешевая идея.

    Ну и опять же, у обычного http тоже есть плюшки, например, браузерное кеширование


  1. diomas
    19.01.2022 15:50
    +3

    Вроде основной смысл вебсокета в том, что вторая сторона (сервер) может так же пушить сообщения и не нужен постоянный пуллинг со стороны клиента (или хаки типа long-polling)

    REST тут получается не пришей кобыле хвост


    1. lesivOlexandr Автор
      19.01.2022 17:17

      UDP так-то тоже создавался для однонаправленных сообщений, но сейчас на нем базируется HTTP/3, который уже стандарт, который поддерживают все современные браузеры Google. К тому же для пересылки сообщений только от сервера к клиенту есть Server Sent Events, а WebSocket создавался для двунаправленного обмена сообщениями между клиентом и сервером


  1. splix
    19.01.2022 19:52

    Скажите, что вы думаете про HTTP2 в данном контексте?


    1. kaegoorn48
      20.01.2022 12:21

      RFC 8441


      1. splix
        20.01.2022 20:39

        Я имею ввиду что описанные проблемы судя по всему решены в HTTP/2. И соответсвенно вопрос про то насколько это все сейчас имеет смысл, или что мешает начать использовать HTTP/2.


  1. marsdenden
    19.01.2022 21:45
    +1

    Всему свое место. Ресту рестово, сокету сокетово. Отладка сокетов - тот еще квест


  1. feverqwe
    20.01.2022 07:58

    А почему вы считаете что keep alive не работает с обычными rest запросами?


    1. lesivOlexandr Автор
      20.01.2022 15:42

      Потому что он не работает с HTTP/2, хотя не уверен насчет HTTP/3 и в первой версии таки работает


      1. feverqwe
        20.01.2022 15:55
        +1

        Оно не поддерживает keep-alive, но http2 переиспользует TCP соединение из коробки и поэтому не требуется заголовок.