[Перевод] GraphQL: от восторга до разочарования

shzdm7ayzoujclvasooptyqh8xs.jpeg


Задаётесь вопросом, стоит ли использовать GraphQL в своём проекте? Ваши разработчики спорят, выдвигая аргументы типа «GraphQL — это будущее» и «REST проще»? Мы с моей командой обсуждали эту тему бесконечно. В статье я приведу краткие выводы.

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


При оценке новой технологии я первым делом задаю такой вопрос: «Зачем её нужно использовать?»

В случае GraphQL для ответа на этот вопрос лучше всего вернуться к исходной проблеме, с которой столкнулся Facebook*. Пост от сентября 2015 года прекрасно описывает вопрос.

«В 2012 году мы начали проект по перестройке нативных мобильных приложений Facebook*. В то время наши приложения для iOS и Android были тонкими обёртками поверх визуализации нашего мобильного веб-сайта. Хотя это приблизило нас к платоновскому идеалу мобильного приложения «напиши один раз, используй везде», на практике это заставляло приложения mobile-webview работать на пределе своих возможностей. В процессе усложнения мобильных приложений Facebook* они начинали страдать от плохой производительности и часто вылетали. Когда мы перешли к нативно реализованным моделям и представлениям, то обнаружили, что нам впервые нужна API-версия данных News Feed, которая до этого момента передавалась только как HTML.

Мы рассмотрели варианты передачи данных News Feed на наши мобильные приложения, в том числе ресурсы сервера RESTful и таблицы FQL (API Facebook* в стиле SQL). Нас напрягали различия между данными, которые нужно было использовать в приложениях, и запросами к серверу, которых они требовали. Мы не рассуждали о данных с точки зрения URL ресурсов, вторичных ключей или join-таблиц; мы думали о них с точки зрения графа объектов».

Facebook* столкнулся со специфической проблемой и создал собственное решение: GraphQL. Для представления данных в виде графа компания спроектировала язык иерархических запросов. Иными словами, GraphQL естественным образом следует взаимосвязям между объектами. Теперь можно получать вложенные объекты и возвращать их все за один сетевой вызов.

Однако компания создала протокол, а не базу данных. У Facebook* уже было хранилище. Каждое поле GraphQL на сервере опиралось на произвольную функцию, изолировавшую бизнес-логику от хранилища.

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

Очень легко понять, как GraphQL решает проблемы Facebook*. Остаётся вопрос: «Решает ли он вашу?»


Несмотря на то, что он решает очень нишевую проблему, GraphQL убедил большую часть сообщества разработчиков использовать его благодаря своим преимуществам:

  • Один запрос, много ресурсов: по сравнению с REST, при котором необходимо выполнять множественные сетевые запросы к каждой конечной точке, с помощью GraphQL можно запрашивать все ресурсы одним вызовом.
  • Получение точных данных: GraphQL минимизирует объём передаваемых по проводам данных, селективно подбирая данные на основании потребностей клиентского приложения. Таким образом, мобильный клиент с маленьким экраном может получать меньше информации.
  • Сильная типизация: каждый запрос, ввод и объекты ответа имеют тип. В веб-браузерах отсутствие типов в JavaScript стало слабостью, которую пытаются компенсировать различные инструменты (Dart компании Google, TypeScript компании Microsoft). GraphQL позволяет обмениваться типами между бэкендом и фронтендом.
  • Более качественный инструментарий и удобство для разработчиков: интроспективному серверу можно отправлять запросы о поддерживаемых им типах, что позволяет применять API explorer, автодополнение и предупреждения редактора. Больше не нужно полагаться на бэкенд-разработчиков в документировании их API. Достаточно просто исследовать конечные точки и получить нужные данные.
  • Независимость от версий: вид возвращаемых данных определяется исключительно запросом клиента, поэтому серверы становятся проще. При добавлении в продукт новых фич на стороне сервера можно добавить новые поля, не влияя на существующие клиенты.


Я полностью понимаю привлекательность этих особенностей и с большим энтузиазмом воспринял саму технологию.
Наша команда мобильной разработки активно продвигала в компании GraphQL. Нашей команде десктопного фронтенда тоже нравилась идея типов. У нас уже был REST API, однако мы внедрили эту технологию в 2019 году. Команда потратила время на создание новых конечных точек для GraphQL. Мы выбрали библиотеку Apollo, предоставляющую клиенты на React.js, Kotlin и Swift.

Первую конечную точку настроить было очень легко. Сервер Apollo хорошо сработался с express.js в бэкенде, а две конечные точки API Rest и GraphQL могут сосуществовать в одном приложении.

Благодаря принципу «один запрос, много ресурсов» код фронтенда стал намного проще с GraphQL. Представьте ситуацию, когда пользователь хочет получить подробности о конкретном исполнителе, например, (имя, id, композиции и так далее). При традиционном интуитивно понятном паттерне REST это бы потребовало множество перекрёстных запросов между двумя конечными точками /artists и /tracks, которые фронтенд должен был бы потом объединить. Однако благодаря GraphQL мы можем определить все необходимые данные в запросе, как показано ниже:

artists(id: "1") {
  id
  name
  avatarUrl
  tracks(limit: 2) {
    name
    urlSlug
  }
}


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

4.1. Один запрос, много ресурсов


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

Не думаю, что такая оптимизация допустима:

  • По большей части это проблема для мобильных приложений. Если вы работаете с десктопным приложением или с API «машина-машина», это не приносит никакой дополнительной ценности с точки зрения производительности.
  • Вы не делаете код быстрее; вы просто переносите сложность на сторону бэкенда, имеющую бОльшую вычислительную мощь. Однако метрики нашего проекта показывали, что REST API оставался более быстрым, чем GraphQL.


4.2. Получение точных данных


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

Не думаю, что этот аргумент важен.

  • Не у всех есть мобильное и десктопное приложения. Это преимущество может быть даже неприменим к вашему проекту. И я проигнорирую аргумент «А как насчёт умных часов?»
  • Теоретически, это интересно, но на практике десктопные и мобильные приложения схожи, и это не экономит особо много данных. Давайте рассмотрим два примера: Spotify и Amazon.


lv-nddxozk4bl6kofdxwi5eof8g.jpeg
yujqlnca6tk2cp5gzun2hvxliyk.jpeg


Десктопное и мобильное приложения (Spotify)

jyge_wgpdsfcablclbkrrmdc3py.jpeg


sjt2sjuwu_maofp7t0crfpuffks.jpeg
mknadrxxv4flac0jjxd65lygf4i.jpeg


Десктопное и мобильное приложения (Amazon)

На экране альбома десктопной версии Spotify есть только три дополнительных поля (количество прослушиваний, длительность трека и длительность альбома, то есть на каждый JSON экономится примерно 30 байтов). На десктопном экране Amazon есть только два дополнительных поля данных. Наше приложение Spendesk имеет одинаковый размер и никак не выигрывает от этой возможности оптимизации размера полезной нагрузки.

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

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

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

5.1. Головоломка со строгой типизацией


Как и WSDL годами ранее, GraphQL определяет все типы, команды и запросы API в файле graphql.schema. Я огромный фанат типизации. Однако я обнаружил, что типизация при помощи GraphQL может быть запутанной.

Во-первых, здесь много дублирования. GraphQL определяет тип в схеме, однако нам нужно переопределять типы для нашего бэкенда (TypeScript с node.js) и для мобильных приложений (на Swift и Kotlin).

Для устранения этой проблемы возникли два решения:

а. Типизация сначала в коде

Первое решение (как в nexus, typegraphql) позволяет определять типы на TypeScript, а затем на их основании генерировать схему GraphQL.

jfctlhurrkozv6qpldukdv_zeig.jpeg


Наша команда пробовала nexus в 2020 году и спустя месяц отказалась от него. Код был запутанным и плохо сочетался с zod.js, который мы также использовали для типизации. Он не поддерживал фичи наподобие образования федераций, а значения null работали некорректно. Отладка сгенерированной схемы GraphQL была непростым процессом. Это оказался ужасный опыт, и я не рекомендую его никому.

б. Типизация сначала в схеме

Другое решение противоположно первому; оно используется в Apollo (JS), в Ariadne (Python) и в CodeGen. Сначала мы создаём схему, а затем скрипт генерирует типы из файла schema.graphql.

dn-fgopr1udlfu73c52uqqeccjo.jpeg


Сейчас мы используем Codegen, который автоматически генерирует TypeScript. Этот опыт очевидно лучше, но он всё равно неидеален. Нам нужен способ передавать схему между фронтендом и бэкендом. Фронтенд должен пересобираться при каждом изменении схемы, в противном случае он не получит последних изменений. Для получения последних обновлений мы подтягиваем схему при помощи интроспекции.

Всё равно возникают проблемы с не допускающими значений null типами, которые TypeScript не распознаёт как опциональные, с enum в GraphQL, которое не существует в TypeScript, и с ресолверами, возвращающими свёрнутые типы.

Enum в GraphQL преобразовали в два типа TypeScript, которые выглядят так:

enum FourEyesProcedureStatus {
  VALIDATION_PENDING
  CANCELLED
  VALIDATED
  REJECTED
export type FourEyesProcedureStatus =
  | 'CANCELLED'
  | 'REJECTED'
  | 'VALIDATED'
  | 'VALIDATION_PENDING';

export type FourEyesStatusEnum =
  | 'CANCELLED'
  | 'REJECTED'
  | 'VALIDATED'
  | 'VALIDATION_PENDING';


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

Языки типизации не поддерживают этот паттерн в готовом виде. В TypeScript Codegen добавляет в код any и создаёт очень сложные типы.

blockAccount: async (_, args, context): Promise => {}
export type BlockAccountResultResolvers<
  ContextType = any,
  ParentType extends ResolversParentTypes['BlockAccountResult'] = ResolversParentTypes['BlockAccountResult'],
> = ResolversObject<{
  account?: Resolver;
  __isTypeOf?: IsTypeOfResolverFn;
}>;


Для нашего REST API мы просто определяем внутренние типы и внешние DTO. Это всё равно добавляет дублирование, но остаётся гораздо проще, чем GraphQL.

5.2. Более качественный инструментарий, за исключением Google Chrome


Я со скепсисом относился к инструментарию, поскольку когда мы начинали использовать GraphQL, продукты наподобие insomnia или Postman не поддерживали GraphQL. Сейчас ситуация лучше, они все поддерживают его по умолчанию.

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

czhcfg2salmwujj88hd94rl7-3y.jpeg


hio6uel5lc4i6fwiqay6u93xiay.jpeg


На верхнем сайте используется только GraphQL, и невозможно понять, какой запрос получает пользователей

5.3. Нет поддержки кодов ошибок


REST позволяет использовать коды ошибок HTTP наподобие »404 not found»,»400 bad request» и так далее, но у GraphQL этого нет. GraphQL вынуждает возвращать код 200 с сообщением об ошибке в полезной нагрузке ответа. Чтобы понять, в какой конечной точке произошёл сбой, нужно проверить каждую полезную нагрузку.

Кроме того, некоторые объекты могут быть пустыми, потому что их нельзя найти или потому что возникла ошибка. И понять разницу бывает сложно.

5.4. Мониторинг


Эта проблема связана с предыдущей: выполнять мониторинг ошибок HTTP очень просто по сравнению с GraphQL, потому что все они имеют собственный код ошибки. А исправить ситуацию с GraphQL нелегко. Apollo работает над этим, и я уверен, что скоро разработчики найдут решение.
Предыдущие проблемы — это просто неудобства. Однако спустя три года работы с GraphQL я обнаружил и более серьёзные недостатки.

6.1. Независимость от версий


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

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

6.2. Пагинация


На страницах GraphQL Best practices можно прочесть следующее:

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


Как удобно. В целом, как выяснилось, пагинация в GraphQL очень мучительна.

6.3. Кэширование


Смысл кэширования заключается в том, чтобы получать ответ сервера быстрее благодаря сохранению результатов предыдущих вычислений. Для REST уникальными идентификаторами ресурсов, доступ к которым пытаются получить пользователи, являются URL. Поэтому можно выполнять кэширование на уровне ресурсов. Кэширование встроено в спецификацию HTTP. Кроме того, браузер и мобильное устройство тоже могут использовать этот идентификатор URL и кэшировать ресурсы локально (как они делают это с изображениями и CSS).

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

6.4. Проблемы N+1


Эта проблема возникает, когда ваши данные не имеют форму графа. Представим, что вы хотите получить автора и все его книги. Хранилища наподобие SQL хранят книги и авторов в разных таблицах.

query {
  authors {
    name
    books {
      title
    }
  }
}


resolver будет выглядеть так:

resolvers = {
  Query: {
    authors: async () => {
      return ORM.getAllAuthors()
    }
  }
  Author: {
    books:  async (authorObj, args) => {
      return ORM.getBooksBy(authorObj.id)
    }
  },
}


Так как ресолвер книг вызывается для каждого id, запрос пример следующий вид:

SELECT * FROM authors; 

SELECT * FROM books WHERE author_id == 1;
SELECT * FROM books WHERE author_id == 2; 
SELECT * FROM books WHERE author_id == 3; 


Нам придётся выполнять вызов к базе данных для каждого автора, потеряв возможность использовать фичи SQL наподобие SELECT * FROM books WHERE author_id in (1,2,3), и делая N+1 запросов к базе данных вместо двух. Одно из решений заключается в использовании dataloader, однако оно вынуждает нас добавлять в код ещё один слой сложности, что усложняет отладку проблем производительности.

6.5. Типы медиа


У нас есть API для загрузки на сервер документов и их отображения. GraphQL не поддерживает загрузку документов на сервер, использующей по умолчанию multipart-form-data. Разработчики Apollo работали над решением file-uploads, но оно сложно в настройке. Кроме того, GraphQL не поддерживает при получении документа заголовок типов медиа, который позволяет браузеру отображать файл правильно.

6.6. Безопасность


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

Также он сможет получить доступ к полям, не предназначенным для открытого доступа. При использовании REST можно управлять разрешениями на уровне URL. Для GraphQL это должен быть уровень полей.

user {
  username <-- могут видеть все
  email <-- приватное
  post {
    title <-- некоторые посты приватны
  }
}


С ростом графа данных вы можете разделить данные на несколько сервисов. Stitching и Federation позволяют разделить схему GraphQL на несколько более мелких. Однако есть важная разница.

7.1. Federation


Federation предполагает, что схема компании будет распределённой ответственностью. Вот как это выглядит:

y7atc0dclwk8zpd6lqytsvgdoio.jpeg


При использовании Federation (на основе Apollo) каждый сервис отвечает за поддержку своей части схемы GraphQL. Сервер Federation подтягивает каждую схему и объединяет их в одну.

Federation помогает организациям с несколькими командами GraphQL обеспечить федеративное разделение глобальной схемы GraphQL.

Federation хорошо работает, если вы остаётесь в границах GraphQL. Всё типизировано, поэтому вы легко можете выявлять ошибки. Можно определять key для объединения объектов среди нескольких схем. Но по умолчанию она не поддерживает горячую перезагрузку. Это значит, что придётся или при каждом изменении подграфа перезагружать шлюз, или использовать проприетарное решение Apollo Managed Federation.

7.2 Stitching


Stitching предполагает, что схема компании будет централизованной ответственностью.

melqucrn9gquuhudsprvwrz3cvi.jpeg


Stitching (на основе mesh, hasura или stepzen) помогает объединять схемы между несколькими источниками данных, поддерживаемых центральной командой API данных. Мы определяем одну основную схему GraphQL в шлюзе и используем ресолвер для получения данных автоматически от каждого сервиса, предоставляющего данные (gRPC, REST, SQL).

Stitching гибче, чем Federation, потому что не вынуждает подсервисы использовать GraphQL. Можно потребить любой источник данных. Однако я обнаружил, что Stitching обычно направляет тебя по опасной дорожке, которая может включать в себя следующее:

  • Прямое раскрытие внутренней схемы базы данных через API немного опасно, так как создаёт тесную связь с хранилищем. ORM наподобие Prisma или Graphile имеют нативную интеграцию с GraphQL, ещё больше развивая эту идею.
  • Другие источники данных, в отличие от GraphQL, не типизированы. Так что нужно учитывать дополнительную сложность, когда некоторые объекты имеют одинаковое имя или когда вам нужно объединить данные по заданному ключу.
  • Можно смешивать stitching и federation, делая систему ещё более странной.


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

GraphQL был создан как внутренний продукт Facebook* в 2012 году и выложен в опенсорс в 2015 году. В 2019 году Facebook* создал GraphQL Foundation — нейтральную некоммерческую организацию, поддерживающую спецификации и базовую реализацию для Node.js (graphql.js).

С тех пор появилось много новых действующих лиц и экосистема стала более сложной. На май 2021 года существовало четыре других реализации для одного только Node.js (Apollo, Express, yoga, Helix), шесть на Go и четыре на Python.

Facebook* до сих пор активно развивает инструменты наподобие relay, однако не является основной стороной, принимающей решения. Теперь в экосистеме есть два главных игрока:

  • The Guild — группа опенсорс-разработчиков, работающая над yoda server, mesh или codegen.
  • Apollo — частная компания, выкладывающая в опенсорс свои серверы и клиенты (Kotlin, iOS и React), однако продающая обучение и SAAS-функции наподобие apollo studio и managed federation.
  • Более мелкие игроки наподобие hasura.io, stepzen, тоже предлагающие SaaS-решения для GraphQL.


Из-за множества предложений в экосистеме сложно ориентироваться. Кроме того, я с трудом находил чёткие руководства. Некоторые действующие лица не достигли согласия относительно будущего GraphQL. Одной из интересных тем для разногласий является выбор между Stitching и Federation.

С другой стороны, React, ещё один опенсорсный проект, полностью поддерживается Facebook*. Это упрощает получение чётких руководств. Когда Facebook* решил мигрировать с компонентов классов на хуки, он сделал чёткое заявление об этом. Я предпочитаю экосистему, стремящуюся к общему решению.


REST стал новым SOAP; теперь GraphQL стал новым REST. История повторяется. Сложно сказать, станет ли GraphQL просто новым популярным трендом, который постепенно забудется, или он действительно поменяет правила игры. Одно известно точно: он по-прежнему на ранних этапах развития и пока не убедил в своей надёжности нашу команду.

Наши команды мобильной разработки и фронтенда любят GraphQL. Потрясающий инструментарий, возможность с лёгкостью исследовать API и строгая типизация (особенно с Kotlin и Swift) упрощают жизнь разработчиков. Это логично, если вспомнить исходную проблему Facebook*. GraphQL разрабатывался для решения проблемы с мобильной платформой. Однако эти преимущества могут быть и не значимыми для вашего проекта.

Большинство проблем всплывает, когда ты начинаешь разговаривать с бэкенд-разработчиками. Отсутствие надлежащей пагинации, кэширования и типов MIME — серьёзные проблемы. Управление типами GraphQL и нативными типами вашего проекта становится сложной задачей, а ресолвер тяжело поддерживать. С ростом проекта для исправления больших схем нужно вкладываться в очень дорогостоящие инструменты наподобие Stitching или Federation. Наконец, я считаю, что экосистема ещё недостаточно созрела, а затраты на поддержку этого решения слишком высоки по сравнению с REST API.

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

* Meta Platforms, а также принадлежащие ей Facebook и Instagram: признана экстремистской организацией, её деятельность в России запрещена; запрещены в России

© Habrahabr.ru