WunderGraph предоставляет подписки GraphQL через SSE (Server-Sent Events) или Fetch (в качестве резервного варианта). В этом посте объясняется, почему мы решили выбрать этот подход и считаем его лучше, чем использование WebSockets.

Что такое подписка GraphQL?

Подписки GraphQL позволяют клиенту подписываться на изменения. Вместо опроса на предмет изменений, клиент может получать обновления в реальном времени.
Вот простой пример из нашей демонстрации GraphQL Federation:

subscription {
    updatedPrice: federated_updatedPrice {
        upc
        name
        price
        reviews {
            id
            body
            author {
                id
                name
            }
        }
    }
}

Это основано на Apollo Federation. Как только у микросервиса "product" обновляется цена, WunderGraph объединяет данные с отзывами из микросервиса "review", делает дополнительное объединение с некоторой информацией о пользователе из микросервиса "user" и отправляет данные обратно клиенту.

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

Традиционные способы реализации подписок GraphQL

Наиболее широко принятый способ реализации подписок GraphQL - использование WebSockets. API WebSocket - это стандарт HTTP 1.1, который обычно поддерживается всеми современными браузерами. (Согласно caniuse.com, 97.02% всех браузеров поддерживают API WebSockets)

Сначала клиент отправляет запрос HTTP Upgrade, просит сервер обновить соединение до 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, один из них гласит, что запросы должны быть без сохранения состояния.

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

Рассмотрим несколько сценариев использования подписок GraphQL с WebSockets:

  1. Отправить заголовок авторизации с запросом на обновление

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

Что, если пользователь между тем вышел из системы, но мы забыли закрыть соединение WebSocket?

Еще одна проблема с этим подходом заключается в том, что API браузера WebSocket не позволяет нам устанавливать заголовки на запрос обновления. Это возможно только с использованием пользовательских клиентов WebSocket.

Так что, на самом деле, этот способ реализации подписок GraphQL не очень практичен.

  1. Отправить токен аутентификации с сообщением "connection_init" WebSocket

Другой подход - отправить токен аутентификации с сообщением "connection_init" WebSocket. Так делает Reddit. Если вы перейдете на reddit.com, откроете Chrome DevTools, нажмете на вкладку сети и отфильтруете по "ws", вы увидите соединение WebSocket, где клиент отправляет токен Bearer с сообщением "connection_init".

Этот подход также сохраняет состояние. Вы можете скопировать этот токен и использовать любой другой клиент WebSocket для подписки GraphQL. Затем вы можете выйти из системы на веб-сайте, не закрывая соединение WebSocket.

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

Тем не менее, у этого подхода есть гораздо большая проблема. Как вы видели, клиент отправил токен Bearer с сообщением "connection_init". Это означает, что в какой-то момент времени клиент имел доступ к этому токену.

Таким образом, JavaScript, который работает в браузере, имеет доступ к токену. В прошлом у нас было множество проблем, когда широко используемые пакеты npm были загрязнены вредоносным кодом. Предоставление JavaScript-части вашего веб-приложения доступа к токену Bearer может привести к проблемам с безопасностью.

Лучшее решение - всегда хранить такие токены в безопасном месте, мы вернемся к этому позже.

  1. Отправить токен аутентификации с сообщением "subscribe" WebSocket

Еще один подход - отправить токен аутентификации с сообщением "subscribe" WebSocket. Это снова сделает нашу подписку GraphQL без сохранения состояния, так как вся информация для обработки запроса содержится в сообщении "subscribe".

Однако этот подход создает целый ряд других проблем.

Во-первых, это означало бы, что мы должны позволить клиентам анонимно открывать соединения WebSocket, не проверяя, кто они. Поскольку мы хотим сохранить нашу подписку GraphQL без сохранения состояния, первый раз, когда мы отправим токен авторизации, будет тогда, когда мы отправим сообщение "subscribe".

Что произойдет, если миллионы клиентов откроют соединения WebSocket с вашим сервером GraphQL, никогда не отправляя сообщение "subscribe"? Обновление соединений WebSocket может быть довольно дорогостоящим, и вам также придется иметь CPU и память для поддержания соединений. Когда следует отключить "вредоносное" соединение WebSocket? Что, если у вас есть ложные срабатывания?

Еще одна проблема с этим подходом заключается в том, что вы в большей или меньшей степени заново изобретаете HTTP через WebSockets. Если вы отправляете "метаданные авторизации" с сообщением "subscribe", вы по сути повторно реализуете заголовки HTTP. Почему бы просто не использовать HTTP?

Позже мы обсудим лучший подход (SSE/Fetch).

WebSockets позволяют двунаправленную связь

Следующая проблема с WebSockets заключается в том, что они позволяют двунаправленную связь. Клиенты могут отправлять произвольные сообщения на сервер.

Если мы вернемся к спецификации GraphQL, мы увидим, что для реализации подписок не требуется двунаправленная связь. Клиенты подписываются один раз. После этого только сервер отправляет сообщения клиенту. Если вы используете протокол (WebSockets), который позволяет клиентам отправлять произвольные сообщения на сервер, вам придется как-то ограничить количество сообщений, которые клиент может отправить.

Что, если вредоносный клиент отправляет много сообщений на сервер? Обычно сервер тратит время процессора и память на анализ и отклонение сообщений.

Не было бы лучше использовать протокол, который запрещает клиентам отправлять произвольные сообщения на сервер?

WebSockets не идеальны для SSR (server-side-rendering)

Еще одной проблемой, с которой мы столкнулись, является удобство использования WebSockets при выполнении SSR (server-side-rendering).

Одной из проблем, которую мы недавно решили, является возможность "универсального рендеринга" (SSR) с подписками GraphQL. Мы искали хороший способ отрисовать подписку GraphQL на сервере, а также в браузере.

Зачем вам это нужно? Представьте, вы создаете веб-сайт, который всегда должен показывать последнюю цену акции или статьи. Вы определенно хотите, чтобы веб-сайт был (почти) в реальном времени, но вы также хотите отрисовать контент на сервере по причинам SEO и удобства использования.

Вот пример из нашей демонстрации GraphQL Federation:

const UniversalSubscriptions = () => {
    const priceUpdate = useSubscription.PriceUpdates();
    return (
        <div>
            <h1>Price Updates</h1>
            <ul>
                {priceUpdate.map(price => (
                    <li key={price.id}>
                        {price.product} - {price.price}
                    </li>
                ))}
            </ul>
        </div>
    )
}

export default withWunderGraph(UniversalSubscriptions);

Эта страница (NextJS) сначала отображается на сервере, а затем восстанавливается на клиенте, который продолжает подписку.

Мы подробнее обсудим это немного позже, давайте сначала сосредоточимся на проблеме с WebSockets.

Если сервер должен был отобразить эту страницу, ему сначала пришлось бы начать соединение WebSocket с сервером подписки GraphQL. Затем он должен будет ждать, пока от сервера не будет получено первое сообщение. Только тогда он сможет продолжить отображение страницы.

Хотя технически это возможно, нет простого API "async await" для решения этой проблемы, поэтому никто на самом деле этого не делает, так как это слишком дорого, не надежно и сложно в реализации.

Резюме проблем с подписками GraphQL через WebSockets

  • WebSockets делают ваши подписки GraphQL сохраняющими состояние

  • WebSockets заставляют браузер откатиться к HTTP/1.1

  • WebSockets вызывают проблемы безопасности, раскрывая токены аутентификации клиенту

  • WebSockets позволяют двунаправленную связь

  • WebSockets не идеальны для SSR (server-side-rendering)

Подводя итог предыдущему разделу, подписки GraphQL через WebSockets вызывают несколько проблем с производительностью, безопасностью и удобством использования. Если мы создаем инструменты для современного веба, мы должны рассмотреть лучшие решения.

Почему мы выбрали SSE (Server-Sent Events) / Fetch для реализации подписок GraphQL

Давайте обсудим каждую проблему по отдельности и рассмотрим, как мы их решили.

Учтите, что выбранный нами подход возможен только если вы используете "GraphQL Operation Compiler". По умолчанию клиенты GraphQL должны отправлять всю информацию на сервер, чтобы иметь возможность инициировать подписку GraphQL.

Благодаря нашему компилятору операций GraphQL, мы находимся в уникальном положении, которое позволяет нам отправлять на сервер только "Имя операции" и "Переменные". Этот подход делает наш API GraphQL гораздо безопаснее, так как он скрывает его за API JSON-RPC. Вы можете посмотреть пример здесь, и мы также открыли исходный код решения.

Итак, почему мы выбрали SSE (Server-Sent Events) / Fetch для реализации подписок GraphQL?

SSE (Server-Sent Events) / Fetch не сохраняют состояние

И SSE, и Fetch - это API без сохранения состояния, которые очень просты в использовании. Просто сделайте GET-запрос с именем операции и переменными в качестве параметров запроса.

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

Вот пример запроса (fetch):

curl http://localhost:9991/operations/PriceUpdates

Ответ выглядит так:

{"data":{"updatedPrice":{"upc":"1","name":"Table","price":916,"reviews":[{"id":"1","body":"Love it!","author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","body":"Prefer something else.","author":{"id":"2","name":"Alan Turing"}}]}}}

{"data":{"updatedPrice":{"upc":"1","name":"Table","price":423,"reviews":[{"id":"1","body":"Love it!","author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","body":"Prefer something else.","author":{"id":"2","name":"Alan Turing"}}]}}}

Это поток объектов JSON, разделенных двумя символами новой строки.

В качестве альтернативы мы также могли бы использовать API SSE:

curl 'http://localhost:9991/operations/PriceUpdates?wg_sse=true'

Ответ очень похож на ответ Fetch, только с префиксом "data":

data: {"data":{"updatedPrice":{"upc":"2","name":"Couch","price":1000,"reviews":[{"id":"2","body":"Too expensive.","author":{"id":"1","name":"Ada Lovelace"}}]}}}

data: {"data":{"updatedPrice":{"upc":"1","name":"Table","price":351,"reviews":[{"id":"1","body":"Love it!","author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","body":"Prefer something else.","author":{"id":"2","name":"Alan Turing"}}]}}}

SSE (Server-Sent Events) / 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 (Server-Sent Events) / Fetch можно легко защитить

Мы реализовали "Token Handler Pattern" для обеспечения безопасности нашего API. "Token Handler Pattern" - это способ обработки токенов аутентификации на сервере, а не на клиенте.

Сначала вы перенаправляете пользователя к поставщику идентификации, например, Keycloak. После завершения входа в систему пользователь перенаправляется обратно на "WunderGraph Server" с кодом аутентификации. Этот код аутентификации затем обменивается на токен.

Обмен кода аутентификации на токен происходит по обратному каналу, браузер не имеет возможности узнать об этом.

Как только код успешно обменяется, мы создаем безопасное, зашифрованное cookie, доступное только для чтения. Это означает, что содержимое cookie может быть прочитано только сервером (зашифровано). Cookie не может быть доступно или изменено JavaScript-кодом браузера (только для чтения). Это cookie доступно только с доменов первой стороны (безопасно), поэтому его можно получить только на api.example.com или example.com, но не на foobar.com.

После установки этого cookie каждый запрос SSE/Fetch автоматически аутентифицируется. Если пользователь выходит из системы, cookie удаляется, и дальнейшие подписки невозможны. Каждый запрос на подписку всегда содержит всю необходимую информацию для инициации подписки (без сохранения состояния).

В отличие от подхода Reddit, никакой токен аутентификации не доступен JavaScript-коду браузера.

SSE (Server-Sent Events) / Fetch запрещают клиенту отправлять произвольные данные

Server-Sent Events (SSE), как следует из названия, - это API для отправки событий от сервера к клиенту. После инициации клиент может получать события от сервера, но этот канал не может быть использован для обратной связи.

В сочетании с "Token Handler Pattern" это означает, что мы можем немедленно прекратить запросы после чтения заголовков HTTP.

То же самое относится к API Fetch, так как он очень похож на SSE.

Fetch можно легко использовать для реализации SSR (Server-Side Rendering) для подписок GraphQL

Основная часть нашей реализации подписок через SSE/Fetch - это "HTTP Flusher". После того, как каждое событие записано в буфер ответа, мы должны "сбросить" соединение, чтобы отправить данные клиенту.

Чтобы поддержать Server-Side Rendering (SSR), мы добавили очень простой трюк. При использовании API "PriceUpdates" на сервере мы добавляем параметр запроса к URL:

curl 'http://localhost:9991/operations/PriceUpdates?wg_sse=true&wg_subscribe_once=true'

Флаг "wg_subscribe_once" говорит серверу отправить клиенту только одно событие, а затем закрыть соединение. Таким образом, вместо того, чтобы сбрасывать соединение и затем ждать следующего события, мы просто закрываем его.

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

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

В случае "wg_subscribe_once" мы просто пропускаем эти заголовки и устанавливаем тип контента "application/json". Таким образом, node-fetch может легко работать с этим API при выполнении server-side rendering.

Резюме

Реализация подписок GraphQL через SSE/Fetch дает нам современный, простой в использовании API с отличной применимостью. Он производительный, безопасный и позволяет нам также реализовать SSR (Server-Side Rendering) для подписок GraphQL. Он настолько прост, что его можно даже использовать с помощью curl.

WebSockets, с другой стороны, приносят много проблем с безопасностью и производительностью.

Согласно caniuse.com, 98.15% всех браузеров поддерживают HTTP/2. 97.69% всех браузеров поддерживают API EventSource (SSE). 97.56% поддерживают Fetch.

Я думаю, что пришло время перейти от WebSocket к SSE/Fetch для подписок GraphQL. Если вы хотите получить немного вдохновения, вот демо, которое вы можете запустить локально: https://github.com/wundergraph/wundergraph-demo

Мы также открыли исходный код нашей реализации.

Что вы думаете об этом подходе? Как вы реализуете подписки GraphQL самостоятельно? Присоединяйтесь к нам в Discord и делитесь своими мыслями!

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


  1. shai_hulud
    07.04.2024 15:47
    +2

    WebSockets делают ваши подписки GraphQL сохраняющими состояние

    Если у вас заголовок аутентификации это состояние, то остальные запросы идут без него? А если другие запросы призадумаются и пользователь выйдет пока они выполняются, то они прервутся? Нет же? Значит у вас везде "состояние".

    WS в качестве потока событий вообще никак не отличается от SSE. Одни и те же преимущества, те же проблемы с аутентификацией (нет возможности задать заголовки).

    Но у WS есть per-message-deflate, а у SSE из коробки ничего.

    WebSockets заставляют браузер откатиться к HTTP/1.1

    RFC 8441 уже 6 лет как в node.js, да и в отстальных серверах оно есть

    WebSockets вызывают проблемы безопасности, раскрывая токены аутентификации клиенту

    Дак не раскрывайте. Кукисы, одноразовые токены итд.

    WebSockets позволяют двунаправленную связь

    Плюс же, а не минус. Выработайте протокол взаимодействия по вашему каналу, и закрывайте соеднинение при его нарушении.