Почему я отказался от GraphQL

Kate

Administrator
Команда форума
GraphQL — невероятная технология, привлёкшая много внимания с тех пор, когда я начал в 2018 году использовать её в продакшене. Вам не придётся долго листать мой блог, чтобы увидеть, как я раньше продвигал её. После создания множества React SPA поверх путаницы нетипизированных JSON REST API технология GraphQL показалась мне глотком свежего воздуха. Я искренне поддерживал хайп вокруг GraphQL.

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

В статье для примеров я буду использовать код на Ruby с превосходной библиотекой graphql-ruby, но я уверен, что многие из перечисленных проблем не зависят от выбора языка/библиотеки GraphQL.

Если вы знаете более качественные решения или способы, напишите мне комментарий.

Поверхность атаки​

С самого начала разработки GraphQL было очевидно, что предоставление доступа к языку запросов ненадёжным клиентам увеличивает поверхность атаки приложения. Тем не менее, разнообразие возможных атак гораздо больше, чем я представлял, и защита от них оказывается довольно тяжкой ношей. Вот худшие примеры того, с чем я сталкивался за эти годы…

Авторизация​

Наверно, это самое широко признанная угроза GraphQL, так что не буду вдаваться в подробности. TLDR: если вы раскрываете полностью самодокументируемый API запросов всем клиентам, то нужно быть чертовски уверенным в том, что каждое поле авторизуется для текущего пользователя согласно контексту, в котором запрашивается это поле. Изначально казалось, что достаточно авторизации объектов, но этого быстро перестало хватать. Допустим, у нас есть Twitter X API:

query {
user(id: 321) {
handle # ✅ мне разрешено просматривать публичную информацию пользователей
email # 🛑 я не должен иметь возможности просматривать их личную информацию только потому, что могу просматривать пользователя
}
user(id: 123) {
blockedUsers {
# 🛑 а иногда я даже не должен иметь возможности просматривать их публичную информацию,
# потому что важен контекст!
handle
}
}
}
Можно только догадываться, насколько GraphQL ответственен за то, что Broken Access Control взбирается на вершину OWASP Top 10. Устранить проблему можно, сделав ваш API защищённым по умолчанию при помощи интеграции с фреймворком авторизации вашей библиотеки GraphQL. При возврате каждого объекта и/или ресолвинге каждого поля вызывается ваша система авторизации, чтобы подтвердить наличие у текущего пользователя доступа.

Сравните это с миром REST, где в общем случае авторизуется каждая конечная точка — гораздо более мелкая задача.

Ограничение частоты​

При работе с GraphQL мы не можем предполагать, что все запросы создают одинаковую нагрузку на сервер. Не существует никакого ограничения на размер запроса. Даже в совершенно пустой схеме типы, раскрытые для интроспекции, цикличны, поэтому вполне возможно создать валидный запрос, возвращающий мегабайты JSON:

query {
__schema{
types{
__typename
interfaces {
possibleTypes {
interfaces {
possibleTypes {
name
}
}
}
}
}
}
}

Я только что протестировал на эту атаку GraphQL API explorer очень популярного веб-сайта, и получил спустя 10 секунд ответ 500. Вот так легко я потратил 10 секунд времени чьего-то CPU на выполнение этого (с удалённым пробелом) 128-байтного запроса, а от меня даже не потребовали залогиниться.

Стандартная защита1 от этой атаки такова:

  1. Оценить сложность ресолвинга каждого отдельного поля в схеме и отказывать запросам, превосходящим какое-то максимальное значение сложности
  2. Фиксировать настоящую сложность исполняемого запроса и выкидывать его из бакета бюджета, который через какой-то интервал обнуляется.
[1] Защитой от этой и многих других атак могут стать хранимые запросы (persisted queries), но если вы хотите раскрыть предназначенный для пользователей GraphQL API, то вариант с persisted queries использовать невозможно.

Эти вычисления очень сложно реализовать правильно. Особенные трудности возникают, когда вы возвращаете поля списков, длина которых неизвестна до исполнения. Можно сделать предположение об их сложности, но если вы ошибётесь, то можете ограничить по частоте валидные запросы или не ограничить невалидные.

Хуже того, часто граф, составляющий схему, содержит циклы. Допустим, у вас есть блог со статьями (Article), у каждой из которых множество тегов (Tag), по которым можно просмотреть связанные статьи.

type Article {
title: String
tags: [Tag]
}
type Tag {
name: String
relatedTags: [Tag]
}
При оценке сложности Tag.relatedTags можно предположить, что у статьи не может быть больше пяти тегов, поэтому вы присваиваете этим полям сложность 5 (или 5 * сложность их дочерних элементов). Проблема здесь в том, что Article.relatedTags может быть собственным дочерним элементом, так что неточность вашей оценки может накапливаться экспоненциально по формуле N^5 * 1. Поэтому при таком запросе:

query {
tag(name: "security") {
relatedTags {
relatedTags {
relatedTags {
relatedTags {
relatedTags { name }
}
}
}
}
}
}

Следует ожидать сложности 5^5 = 3125. Если нападающий сможет найти статью с десятью тегами, то будет способен отправить запрос с «истинной» сложностью 10^5 = 100000, в 32 раза больше нашей приблизительной оценки.

Частично эту проблему можно решить, предотвращая запросы с глубокой вложенностью. Однако показанный выше пример показывает, что это не особо защищает, потому что мы взяли не какой-то необычно глубокий запрос. По умолчанию максимальная глубина в GraphQL Ruby равна 13, а здесь всего лишь 7.

Сравните это с ограничением частоты конечной точки REST, обычно имеющей примерно такое же время ответа. В этом случае достаточно ограничителя частоты на основе бакета, позволяющего пользователю, допустим, сделать не более 200 запросов в минуту суммарно ко всем конечным точкам. Если у вас есть более медленные конечные точки (допустим CSV-отчёт или генератор PDF), то можно задать для них более агрессивные ограничения частоты. Это элементарно делается при помощи какого-нибудь HTTP middleware:

Rack::Attack.throttle('API v1', limit: 200, period: 60) do |req|
if req.path =~ '/api/v1/'
req.env['rack.session']['session_id']
end
end

Парсинг запросов​

Перед исполнением запроса он парсится. Однажды мы получили отчёт пентеста, гласящий, что можно создать невалидную строку запроса, приводящую сервер к OOM (Out of memory). Например:

query {
__typename @a @b @c @d @e ... # представьте, что здесь ещё тысяча таких директив
}
Это синтаксически валидный запрос, но невалидный для нашей схемы. Соответствующий спецификации сервер распарсит его и начнёт создавать ответ с ошибками, содержащий тысячи ошибок; мы выяснили, что он занимает в две тысячи раз больше памяти, чем сама строка запроса. Из-за такого увеличения объёма задействуемой памяти недостаточно просто ограничить размер полезной нагрузки, потому что будут существовать валидные запросы, которые больше наименьшего опасного зловредного запроса.

Если у вашего сервера доступна концепция максимального количества ошибок, после которого парсинг отменяется, то эту проблему можно решить. Если нет, вам придётся реализовать собственное решение. В REST нет эквивалента атаки такого же уровня серьёзности.

Производительность​

Когда заходит разговор о производительности, пользователи GraphQL часто говорят о его несовместимости с кэшированием HTTP. Лично для меня это не стало проблемой. В SaaS-приложениях данные обычно сильно индивидуальны для конкретных пользователей, а отправка устаревших данных неприемлема, так что у меня не возникало проблемы с отсутствующими кэшами ответов (и с вызываемыми ими багами недействительности кэша…).

Самыми серьёзными проблемами производительности, с которыми столкнулся я, были…

Получение данных и проблема N+1​

Мне кажется, сегодня эта проблема осознаётся многими. TLDR: если ресолвер полей обращается к внешнему источнику данных, например, к базе данных или к HTTP API, и они вложены в список, содержащий N элементов, то ресолвер выполнит эти вызовы N раз.

Эта проблема не уникальна для GraphQL, а строгий алгоритм ресолвинга GraphQL позволил большинству библиотек реализовать общее решение: шаблон Dataloader. Однако для GraphQL уникально то, что поскольку это язык запросов, подобное может стать проблемой при отсутствии изменений в бэкенде, когда клиент модифицирует запрос. В результате приходится заранее защищаться, добавляя везде абстракцию Dataloader просто на случай, если когда-то в будущем клиент запроси поле в контексте списка. При этом нужно писать и поддерживать большой объём бойлерплейта.

В REST же можно в общем случае поднять вложенные запросы N+1 к контроллеру; мне кажется, освоить такой шаблон гораздо легче:

class BlogsController < ApplicationController
def index
@latest_blogs = Blog.limit(25).includes:)author, :tags)
render json: BlogSerializer.render(@latest_blogs)
end

def show
# Предварительная выборка здесь не требуется, поскольку N=1
@blog = Blog.find(params[:id])
render json: BlogSerializer.render(@blog)
end
endclass BlogsController < ApplicationController
def index
@latest_blogs = Blog.limit(25).includes:)author, :tags)
render json: BlogSerializer.render(@latest_blogs)
end
def show
# Предварительная выборка здесь не требуется, поскольку N=1
@blog = Blog.find(params[:id])
render json: BlogSerializer.render(@blog)
end
end

Авторизация и проблема N+1​

Но постойте, есть и другие N+1! Если вы прислушались к приведённому выше совету об интеграции с фреймворком авторизации вашей библиотеки, то вам придётся справляться с совершенно новой категорией проблем N+1. Продолжим наш пример с X API:

class UserType < GraphQL::BaseObject
field :handle, String
field :birthday, authorize_with: :view_pii
end

class UserPolicy < ApplicationPolicy
def view_pii?
# О нет, я обратился к базе данных, чтобы получить друзей пользователя
user.friends_with?(record)
end
end
query {
me {
friends { # возвращает N пользователей
handle
birthday # выполняет UserPolicy#view_pii? N раз
}
}
}
С этим работать гораздо сложнее, чем с предыдущим примером, потому что код авторизации не всегда выполняется в контексте GraphQL. Например, он может выполняться в фоновой задаче или в конечной точке HTML. Это означает, что мы не можем просто наивно использовать Dataloader, ведь ожидается, что Dataloader выполняется из GraphQL (в частности, в реализации на Ruby).

По моему опыту, это самый важный источник проблем с производительностью. Мы регулярно обнаруживаем, что наши запросы тратят больше времени на авторизацию данных, чем на что-то ещё. И эта проблема тоже попросту не существует в мире REST.

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

Связывание​

По моему опыту, в достаточно зрелой кодовой базе GraphQL бизнес-логика выдавливается на транспортный слой. Это происходит при помощи множества механизмов; о некоторых из них я уже говорил:

  • Солвинг авторизации данных приводит к разнесению правил авторизации по типам GraphQL
  • Солвинг авторизации мутации/аргументов приводит к разнесению правил авторизации по аргументам GraphQL
  • Солвинг получения ресолвером данных N+1 приводит к перемещению этой логики в отдельные dataloader GraphQL
  • Использование удобного шаблона Relay Connection приводит к перемещению логики получения данных в специфические custom connection object GraphQL
В конечном итоге из-за этого для качественного тестирования приложения вы обязаны выполнять тщательное тестирование на слое интеграции, например, выполнением запросов GraphQL. Это очень мучительный опыт. Все возникающие проблемы перехватываются фреймворком, что заставляет выполнять «весёлую» задачу по чтению трассировок стека в сообщениях об ошибках JSON GraphQL. Так как многое, что связано с авторизацией и Dataloader, происходит внутри фреймворка, отладка часто оказывается намного сложнее, потому что нужная вам точка останова находится не в коде приложения.

И, разумеется, поскольку это язык запросов, вам придётся писать гораздо больше тестов, чтобы убедиться в правильности работы всех вышеупомянутых поведений на уровне аргументов и полей.

Сложность​

В целом различные меры решения проблем с безопасностью и производительностью добавляют в кодовую базу существенную сложность. Нельзя сказать, что в REST совершенно нет таких проблем (хотя их определённо меньше), просто REST-решения бэкенд-разработчику в общем случае гораздо проще реализовать и понять.

И другое…​

Вот основные причины, по которым я по большей мере закончил с GraphQL. Для меня в нём есть ещё несколько раздражающих моментов, но чтобы статья не оказалась слишком большой, вкратце перечислю их здесь.

  • GraphQL стимулирует избегать критических изменений, и у него нет инструментов для работы с ними. Это добавляет ненужную сложность в случаях, когда разработчики контролируют всех своих клиентов, им приходится искать обходные пути.
  • В инструментарии постоянно используются коды ответов HTTP, поэтому сильно раздражает, когда 200 может означать что угодно, от «всё в порядке» до «всё лежит».
  • Получение всех данных в одном запросе в эпоху HTTP 2 и старше часто плохо влияет на время ответа; ситуация становится ещё хуже, если сервер не распараллелен.

Альтернативы​

Ну, закончим с нытьём. Что я могу порекомендовать вместо GraphQL? Откровенно говоря, я определённо нахожусь на раннем этапе цикла хайпа, но пока считаю, что можно сделать следующее:

  1. Контролировать всех своих клиентов
  2. Иметь трёх клиентов или меньше
  3. Написать клиент на языке со статической типизацией
  4. Использовать больше одного языка на сервере и в клиентах2
[2] В противном случае может больше подойти решение для конкретного языка, например, tRPC.

Вероятно, лучше раскрыть JSON REST API, совместимый с OpenAPI 3.0+. Если, как в моём случае, больше всего вашим фронтенд-разработчикам нравится в GraphQL его самодокументируемая типобезопасная природа, то я считаю, что вам подойдёт это решение. Инструментарий сильно улучшился с момента появления GraphQL; существует много способов генерации типизированного клиентского кода, вплоть до библиотек получения данных под конкретные фреймворки. Пока мой опыт можно описать так: лучшие части того, для чего я использовал GraphQL, без сложности, требующейся Facebook.

Что касается GraphQL, то существует пара подходов к реализации…

Инструментарий с упором на реализацию генерирует спецификации OpenAPI из типизированного сервера/сервера с подсказками типов. Хорошими примерами такого подхода являются FastAPI в Python и tsoa в TypeScript3. С этим подходом я работал больше всего и мне кажется, он вполне рабочий.

[3] В Ruby нет эквивалентного подхода, вероятно, из-за непопулярности подсказок типов. Вместо него у нас есть rswag, генерирующий спецификации OpenAPI из спецификаций запросов. Было бы здорово, если бы мы могли собирать спецификации OpenAPI из типизированных конечных точек Sorbet/RBS!

Инструментарий с упором на спецификацию эквивалентен подходу «главное — это схема» в GraphQL. Такой инструментарий генерирует код из написанной вручную спецификации. Не могу сказать, что когда-то смотрел на файл YAML-файл OpenAPI и думал «хотелось бы мне написать его самому», но недавний релиз TypeSpec всё изменил. Благодаря нему можно создать достаточно изящный подход на основании схемы:

  1. Написать краткую человекочитаемую схему TypeSpec
  2. Сгенерировать из неё YAML-спецификацию OpenAPI
  3. Сгенерировать клиент API со статической типизацией для выбранного вами фронтенд-языка (например, для TypeScript)
  4. Сгенерировать серверные обработчики со статической типизацией для бэкенд-языка и серверного фреймворка (например, TypeScript + Express, Python + FastAPI, Go + Echo)
  5. Написать компилируемую реализацию для этого обработчика, точно зная, что он будет типобезопасен
Этот подход ещё не проверен опытом, но я считаю, что он перспективен.

 
Сверху