Подписки GraphQL: использование SSE/Fetch через веб-сокеты
27 апреля 2022 г.Что такое подписка GraphQL?
Подписки GraphQL позволяют клиенту подписываться на изменения. Вместо того, чтобы запрашивать изменения, клиент может получать обновления в режиме реального времени. Вот простой пример из нашей [демонстрации GraphQL Federation Demo] (https://github.com/wundergraph/wundergraph-demo):
```график
подписка {
updatedPrice: federated_updatedPrice {
УПЦ
название
цена
отзывы {
я бы
тело
автор {
я бы
название
Это основано на Федерации Аполлона. Как только микросервис «продукт» получает обновление цены, WunderGraph объединяет данные с отзывами из микросервиса «обзор», выполняет дополнительное объединение с некоторой пользовательской информацией из микросервиса «пользователь» и отправляет данные обратно клиенту. Клиент получает это как поток данных. Таким образом, пользовательский интерфейс может обновляться в режиме реального времени.
Традиционные способы реализации подписок GraphQL
Наиболее широко распространенный способ реализации подписок GraphQL — использование WebSockets.
WebSocket API — это стандарт HTTP 1.1, который обычно поддерживается всеми современными браузерами. (Согласно caniuse.com, 94,22% всех браузеров поддерживают WebSockets API)
Сначала клиент отправляет HTTP-запрос на обновление, прося сервер обновить соединение до WebSocket. Как только сервер обновляет соединение, и клиент, и сервер могут отправлять и получать данные, передавая сообщения через WebSocket. Давайте теперь обсудим проблемы с WebSockets
API WebSocket является стандартом HTTP 1.1.
В настоящее время большинство веб-сайтов используют HTTP/2 или даже HTTP/3 для ускорения работы в Интернете.
HTTP/2 позволяет мультиплексировать несколько запросов по одному TCP-соединению.
Это означает, что клиент может отправлять несколько запросов одновременно.
HTTP/3 улучшает это еще больше, но не в этом суть этого поста.
Проблема заключается в том, что если ваш веб-сайт смешивает HTTP/1.1 и HTTP/2, клиенту придется открывать несколько TCP-соединений с сервером. Клиенты могут легко мультиплексировать до 100 запросов HTTP/2 по одному TCP-соединению, тогда как с WebSockets вы вынуждены открывать новое TCP-соединение для каждого WebSocket. Если пользователь открывает несколько вкладок на вашем веб-сайте, каждая вкладка открывает новое TCP-соединение с сервером. Используя HTTP/2, несколько вкладок могут использовать одно и то же соединение TCP. Итак, первая проблема с WebSockets заключается в том, что они используют устаревший и неподдерживаемый протокол, который вызывает дополнительные TCP-соединения.
WebSockets сохраняют состояние
Другая проблема с WebSockets заключается в том, что клиент и сервер должны отслеживать состояние соединения. Если мы посмотрим на принципы REST, один из них гласит, что [запросы должны быть без состояния] (https://restfulapi.net/stateless/). Без сохранения состояния в этом контексте означает, что каждый запрос должен содержать всю необходимую информацию, чтобы его можно было обработать. Давайте рассмотрим несколько сценариев использования подписок GraphQL с WebSockets:
1. Отправьте заголовок авторизации вместе с запросом на обновление
Как мы узнали выше, каждое соединение WebSocket начинается с HTTP-запроса на обновление. Что, если мы отправим заголовок авторизации вместе с запросом на обновление? Это возможно, но это также означает, что когда мы «подписываемся» с помощью сообщения WebSocket, эта «подписка» больше не является не имеющей состояния, поскольку она зависит от заголовка авторизации, который мы ранее отправили. Что, если пользователь тем временем вышел из системы, но мы забыли закрыть соединение WebSocket?
Другая проблема с этим подходом заключается в том, что API браузера WebSocket не позволяет нам устанавливать заголовки в запросе на обновление. Это возможно только при использовании пользовательских клиентов WebSocket.
Так что на самом деле такой способ реализации подписок GraphQL не очень практичен.
2. Отправьте токен аутентификации с сообщением WebSocket «connection_init»
Другой подход заключается в отправке токена аутентификации с сообщением WebSocket «connection_init». Так это делает Reddit. Если вы перейдете на reddit.com, откройте Chrome DevTools, щелкните вкладку сети и отфильтруйте по «ws». Вы увидите соединение WebSocket, когда клиент отправляет токен Bearer с сообщением «connection_init». Этот подход также является состоянием. Вы можете скопировать этот токен и использовать любой другой клиент WebSocket для подписки на подписку GraphQL. Затем вы можете выйти из системы на веб-сайте без закрытия соединения WebSocket. Последующие сообщения подписки также будут зависеть от контекста, который был установлен первоначальным сообщением «connection_init», просто чтобы подчеркнуть тот факт, что оно все еще сохраняет состояние. Тем не менее, есть гораздо большая проблема с этим подходом.
Как вы видели, клиент отправил токен Bearer с сообщением «connection_init». Это означает, что в какой-то момент времени у клиента был доступ к указанному токену. Таким образом, JavaScript, работающий в браузере, имеет доступ к токену. В прошлом у нас было множество проблем, когда широко используемые пакеты npm были заражены вредоносным кодом. Предоставление части JavaScript вашего веб-приложения доступа к токену Bearer может привести к проблеме безопасности. Лучшее решение — всегда хранить такие токены в безопасном месте, мы вернемся к этому позже.
3. Отправьте токен аутентификации с сообщением WebSocket «подписаться»
Другим подходом может быть отправка токена аутентификации с сообщением WebSocket «подписаться». Это снова сделает нашу подписку GraphQL без состояния, поскольку вся информация для обработки запроса содержится в сообщении «подписаться». Однако такой подход создает кучу других проблем.
Во-первых, это будет означать, что мы должны позволить клиентам анонимно открывать соединения WebSocket, не проверяя, кто они. Поскольку мы хотим, чтобы наша подписка GraphQL не сохраняла состояние, первый раз, когда мы отправляем токен авторизации, мы отправляем сообщение «подписаться».
Что произойдет, если миллионы клиентов откроют соединения WebSocket с вашим сервером GraphQL, даже не отправив сообщение «подписаться»?
Обновление соединений WebSocket может быть довольно дорогим, и вам также необходимо иметь ЦП и память, чтобы поддерживать соединения. Когда вы должны отключить «вредоносное» соединение WebSocket? Что делать, если у вас есть ложные срабатывания? Другая проблема с этим подходом заключается в том, что вы более или менее заново изобретаете HTTP через WebSockets. Если вы отправляете «Метаданные авторизации» с сообщением «подписаться», вы, по сути, повторно реализуете заголовки HTTP. Почему бы просто не использовать вместо этого HTTP? Мы обсудим лучший подход (SSE/Fetch) позже.
WebSockets обеспечивают двустороннюю связь
Следующая проблема с WebSockets заключается в том, что они обеспечивают двустороннюю связь. Клиенты могут отправлять произвольные сообщения на сервер. Если мы вернемся к спецификации GraphQL, то увидим, что для реализации подписок не требуется двунаправленная связь. Клиенты подписываются один раз.
После этого только сервер отправляет сообщения клиенту. Если вы используете протокол (WebSockets), который позволяет клиентам отправлять произвольные сообщения на сервер, вам нужно каким-то образом ограничить количество сообщений, которые может отправить клиент. Что делать, если вредоносный клиент отправляет на сервер много сообщений? Сервер обычно тратит процессорное время и память на анализ и отклонение сообщений. Не лучше ли использовать протокол, запрещающий клиентам отправлять произвольные сообщения на сервер?
WebSockets не идеальны для SSR (рендеринга на стороне сервера)
Еще одна проблема, с которой мы столкнулись, — это удобство использования WebSockets при выполнении SSR (рендеринга на стороне сервера).
Одна из проблем, которую мы недавно решили, — разрешить «Универсальный рендеринг» (SSR) с подписками GraphQL. Мы искали удобный способ отображать подписку GraphQL как на сервере, так и в браузере. Почему вы хотите это сделать? Представьте, вы создаете веб-сайт, который всегда должен показывать последнюю цену акции или товара. Вы определенно хотите, чтобы веб-сайт работал (почти) в режиме реального времени, но вы также хотите отображать контент на сервере из соображений SEO и удобства использования.
Вот пример из нашей [демонстрации GraphQL Federation] (https://github.com/wundergraph/wundergraph-demo):
```tsx
const UniversalSubscriptions = () => {
const priceUpdate = useSubscription.PriceUpdates();
возврат (
<дел>
Обновление цен
<ул>
{priceUpdate.map(цена => (
<ключ li={price.id}>
{цена.продукт} - {цена.цена}
экспорт по умолчанию с помощью WunderGraph (UniversalSubscriptions);
Эта (NextJS) страница сначала отображается на сервере, а затем повторно гидратируется на клиенте, что продолжается с подпиской. Мы поговорим об этом более подробно чуть позже, давайте сначала сосредоточимся на проблеме с WebSockets. Если бы сервер должен был отображать эту страницу, он должен был бы сначала запустить соединение WebSocket с сервером подписки GraphQL. Затем ему придется ждать, пока первое сообщение не будет получено с сервера. Только тогда он сможет продолжить рендеринг страницы.
Хотя это технически возможно, для решения этой проблемы не существует простого API с асинхронным ожиданием.
следовательно, никто на самом деле этого не делает, поскольку это слишком дорого, ненадежно и сложно в реализации.
Сводка проблем с подписками GraphQL через WebSockets
- WebSockets делает ваши подписки GraphQL стабильными
- WebSockets заставляют браузер возвращаться к HTTP/1.1.
- Веб-сокеты вызывают проблемы с безопасностью, предоставляя клиенту токены аутентификации.
- WebSockets обеспечивают двустороннюю связь
- WebSockets не идеальны для SSR (рендеринг на стороне сервера)
Подводя итог предыдущему разделу, можно сказать, что подписки GraphQL через WebSockets вызывают несколько проблем с производительностью, безопасностью и удобством использования. Если мы создаем инструменты для современной сети, мы должны рассмотреть лучшие решения.
Почему мы выбрали SSE (Server-Sent Events) / Fetch для реализации подписок GraphQL
Давайте рассмотрим проблемы одну за другой и обсудим, как мы их решили.
Имейте в виду, что выбранный нами подход возможен только в том случае, если вы используете «Компилятор операций GraphQL». По умолчанию клиенты GraphQL должны отправлять всю информацию на сервер, чтобы иметь возможность инициировать подписку GraphQL.
Благодаря нашему компилятору операций GraphQL мы находимся в уникальном положении, которое позволяет нам отправлять на сервер только «Имя операции», а также «Переменные». Такой подход делает наш API GraphQL намного более безопасным, поскольку он скрывает его за API JSON-RPC. Вы можете [ознакомиться с примером здесь] (https://github.com/wundergraph/wundergraph-demo), и мы также скоро предоставим решение с открытым исходным кодом. Итак, почему мы выбрали SSE (Server-Sent Events) / Fetch для реализации подписок GraphQL?
SSE (события, отправленные сервером) / Fetch не имеет состояния
И SSE, и Fetch являются API без сохранения состояния и очень просты в использовании. Просто сделайте запрос GET с именем операции и переменными в качестве параметров запроса. Каждый запрос содержит всю информацию, необходимую для инициирования Подписки. Когда браузер общается с сервером, он может использовать SSE API или вернуться к Fetch API, если браузер не поддерживает SSE.
Вот пример запроса (выборка):
завиток http://localhost:9991/api/main/operations/PriceUpdates
Ответ выглядит так:
{"data":{"updatedPrice":{"upc":"1","name":"Table","price":916,"reviews":[{"id":"1","body" :"Очень нравится!","author":{"id":"1","name":"Ада Лавлейс"}},{"id":"4","body":"Предпочтите что-нибудь другое." ,"автор":{"id":"2","имя":"Алан Тьюринг"}}]}}}
{"data":{"updatedPrice":{"upc":"1","name":"Table","price":423,"reviews":[{"id":"1","body" :"Очень нравится!","author":{"id":"1","name":"Ада Лавлейс"}},{"id":"4","body":"Предпочтите что-нибудь другое." ,"автор":{"id":"2","имя":"Алан Тьюринг"}}]}}}
Это поток объектов JSON, разделенных двумя символами новой строки.
В качестве альтернативы мы могли бы также использовать SSE API:
завиток http://localhost:9991/api/main/operations/PriceUpdates?wg_sse=true
Ответ очень похож на ответ Fetch, только с префиксом «данные»:
данные: {"data":{"updatedPrice":{"upc":"2","name":"Couch","price":1000,"reviews":[{"id":"2"," body":"Слишком дорого.","author":{"id":"1","name":"Ада Лавлейс"}}]}}}
данные: {"данные":{"updatedPrice":{"upc":"1","имя":"Таблица","цена":351,"отзывы":[{"id":"1"," body":"Очень нравится!","author":{"id":"1","name":"Ада Лавлейс"}},{"id":"4","body":"Предпочтите что-нибудь другое .","автор":{"id":"2","имя":"Алан Тьюринг"}}]}}}
SSE (события, отправленные сервером) / Fetch может использовать HTTP/2
И SSE, и Fetch могут использовать HTTP/2. На самом деле вам следует избегать использования SSE/Fetch для подписок GraphQL, когда HTTP/2 недоступен, так как его использование с HTTP 1.1 приведет к тому, что браузер создаст множество TCP-соединений, быстро исчерпав максимальное количество одновременных TCP-соединений, которые может использовать браузер. может открываться к тому же происхождению. Использование SSE/Fetch с HTTP/2 означает, что вы получаете современный, простой в использовании API, который к тому же очень быстрый. В редких случаях, когда вам нужно вернуться к HTTP 1.1, вы все равно можете использовать SSE/Fetch.
SSE (события, отправленные сервером) / Fetch можно легко защитить
Мы внедрили «шаблон обработчика токенов», чтобы сделать наш API безопасным. Шаблон обработчика токенов — это способ обработки токенов аутентификации на сервере, а не на клиенте.
Во-первых, вы перенаправляете пользователя к поставщику удостоверений, например. Брелок. После завершения входа пользователь перенаправляется обратно на «WunderGraph Server» с кодом аутентификации. Этот код авторизации затем обменивается на токен. Обмен кода авторизации на токен происходит по обратному каналу, браузер никак не может об этом узнать. После успешного обмена кодом мы создаем безопасный зашифрованный файл cookie только для http.
Это означает, что содержимое файла cookie может быть прочитано только сервером (в зашифрованном виде). Доступ к файлу cookie или его изменение невозможно с помощью кода JavaScript браузера (только http). Этот файл cookie доступен только из собственных доменов (защищенных), поэтому доступ к нему можно получить только на api.example.com или example.com. .com), но не на foobar.com. После установки этого файла cookie каждый запрос SSE/Fetch автоматически аутентифицируется. Если пользователь выходит из системы, файл cookie удаляется, и дальнейшая подписка становится невозможной. Каждый запрос на подписку всегда содержит всю информацию, необходимую для инициирования подписки (без сохранения состояния). В отличие от подхода Reddit, токен аутентификации недоступен для кода JavaScript браузера.
SSE (Server-Sent Events)/Fetch запрещает клиенту отправлять произвольные данные
События, отправленные сервером (SSE), как следует из названия, представляют собой API для отправки событий с сервера клиенту. После запуска клиент может получать события с сервера, но этот канал нельзя использовать для обратной связи. В сочетании с «шаблоном обработчика токенов» это означает, что мы можем закрывать запросы сразу после прочтения заголовков HTTP. То же самое касается Fetch API, так как он очень похож на SSE.
Fetch можно легко использовать для реализации SSR (рендеринга на стороне сервера) для подписок GraphQL.
Основной частью нашей реализации подписок через SSE/Fetch является «HTTP Flusher». После того, как каждое событие записано в буфер ответа, мы должны «сбросить» соединение, чтобы отправить данные клиенту. Для поддержки рендеринга на стороне сервера (SSR) мы добавили очень простой трюк.
При использовании API «PriceUpdates» на сервере мы добавляем параметр запроса к URL-адресу:
завиток http://localhost:9991/api/main/operations/PriceUpdates?wg_sse=true&wg_subscribe_once=true
Флаг «wg_subscribe_once» указывает серверу отправить клиенту только одно событие, а затем закрыть соединение. Таким образом, вместо того, чтобы сбрасывать соединение и ждать следующего события, мы просто его закрываем.
Кроме того, мы отправляем следующие заголовки, только если флаг не установлен:
Content-Type: текст/поток событий
Кэш-контроль: без кеша
Соединение: Keep-alive
В случае «wg_subscribe_once» мы просто опускаем эти заголовки и устанавливаем тип контента «application/json». Таким образом, node-fetch может легко работать с этим API при выполнении рендеринга на стороне сервера.
Резюме
Внедрение подписок GraphQL через SSE/Fetch дает нам современный, простой в использовании API с большим удобством использования. Он производительный, безопасный и позволяет нам также реализовать SSR (рендеринг на стороне сервера) для подписок GraphQL. Это настолько просто, что вы можете использовать его даже с помощью curl.
С другой стороны, WebSockets имеют множество проблем с безопасностью и производительностью.
По данным caniuse.com, 93,76% всех браузеров поддерживают HTTP/2. 94,65% всех браузеров поддерживают EventSource API (SSE). [93,62% поддерживают Fetch] (https://caniuse.com/fetch).
Я думаю, что пришло время перейти с WebSocket на SSE/Fetch для подписок GraphQL.
Если вы хотите получить вдохновение, вот демонстрация, которую вы можете запустить локально:
https://github.com/wundergraph/wundergraph-demo
Мы также собираемся открыть исходный код нашей реализации очень скоро. Зарегистрируйтесь со своей электронной почтой, если хотите получать уведомления, когда она будет готова. Что вы думаете об этом подходе? Как вы сами реализуете подписки GraphQL? Присоединяйтесь к нам в Discord и делитесь своими мыслями!
Также опубликовано [Здесь] (https://wundergraph.com/blog/deprecate_graphql_subscriptions_over_websockets)
Оригинал