Блог

🕸 Как и где GraphQL может улучшить WordPress, дополняя REST API

Leonardo Losoviz
Автор: Leonardo Losoviz ·

Обновление 01/05/2024: Ознакомьтесь со сравнением Gato GraphQL vs WP REST API.

На прошлых выходных я опубликовал запись в блоге 🦸🏿‍♂️ Gato GraphQL теперь транспилируется с PHP 8.0 на 7.1.

После того как я поделился этим постом в Reddit /r/php, сообщество начало живое обсуждение о том, насколько полезно использовать GraphQL в WordPress, чем он отличается от WP REST API, и насколько оправданно добавлять ещё один API в WordPress.

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

Например, опыт работы с GraphQL в WordPress и в Laravel будет разным, как и опыт, предоставляемый разными серверами — WPGraphQL или Gato GraphQL.

Эта статья — моё мнение по данному вопросу, в которой я рассматриваю несколько комментариев из поста на Reddit.

GraphQL vs WP REST API

[Какая плохая идея] — иметь GraphQL API поверх WordPress, который уже использует собственный REST API. Просто используйте REST API. [Источник]

И REST API, и GraphQL служат одной цели: предоставить приложению необходимые данные. Однако они по-разному достигают этой цели: в то время как REST имеет предопределённые endpoints, предоставляющие определённый набор данных, GraphQL может предоставить именно те данные, которые нужны.

Это различное поведение может непосредственно влиять на производительность приложения. С REST, если нам нужно получить список записей плюс некоторые данные о каждом авторе записи, потребуется отправка дополнительных запросов. Возможно, 1 дополнительный запрос для всех данных автора или 1 дополнительный запрос на каждого автора. Тем временем посетитель сайта может ждать рендеринга страницы.

GraphQL улучшает эту ситуацию, поскольку мы можем получить все данные записи и автора в одном запросе, и рендеринг веб-страницы будет быстрее:

{
  posts {
    id
    title
    excerpt
    date
    url
    author {
      id
      name
      url
    }
  }
}

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

Сложная начальная настройка GraphQL + необходимость писать resolvers

Определённо есть аргумент в пользу того, что начальная настройка GraphQL в разы сложнее, чем для REST; вы правы, что ассоциации нужно настраивать. [Источник]

И...

То, что вы и почти все остальные в интернете упускают, — это то, что для работы этого формата API вам нужно написать парсер (resolvers + типы), что влечёт за собой множество проблем, которых нет при использовании REST. [Источник]

Эти комментарии не совсем точны, потому что и WPGraphQL, и Gato GraphQL уже сопоставили модель данных WordPress со схемой GraphQL (WPGraphQL — полностью, мой плагин — большую её часть).

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

Это правда, что для получения пользовательских данных из собственных сущностей приложения (например, из CPT) их необходимо сопоставить через resolvers, и вам придётся это сделать. Но это ничем не отличается от REST: если вам нужны пользовательские данные из вашего CPT, вам нужно создать REST endpoint для получения этих данных. Пользовательский endpoint — это тоже resolver.

Следовательно, что касается необходимости resolvers, REST и GraphQL API практически одинаковы.

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

Я думаю, что тому есть несколько причин. Во-первых, GraphQL включает (как минимум) две части:

  1. концепция того, что это такое и как это работает
  2. серверы, предоставляющие конкретную реализацию

При просмотре документации по GraphQL, например официального сайта graphql.org, она сосредоточена на концепциях GraphQL, подробно описывая resolvers, что они такое и зачем они нужны.

Это полезно, когда вы создаёте приложение с нуля, например, используя Laravel и Lighthouse. В этом случае вам действительно нужно кодировать свои resolvers (но точно так же вам нужно было бы создавать свои REST endpoints).

Однако WordPress уже является приложением, а WPGraphQL и Gato GraphQL — это решения. Эти два плагина уже создали resolvers за нас, поэтому нам не нужно о них беспокоиться (аналогично тому, как WP REST API также предоставляет начальный набор endpoints, о которых нам не нужно беспокоиться).

Кроме того, GraphQL больше ориентирован на разработчиков, и его документация, похоже, обращается напрямую к разработчикам. Разработчики создают resolvers на стороне сервера, и разработчики используют эти resolvers с пользовательскими queries на стороне клиента. Поскольку создание resolvers — это задача для разработчиков, это естественно и часто появляется в документации.

Для REST ожидание (я думаю) состоит в том, что endpoint, предоставляющий необходимые данные, уже существует (поставляемый WP REST API). Если его нет, только тогда нам нужно беспокоиться о настройке пользовательского endpoint. Следовательно, для REST уделяется меньше внимания созданию resolvers.

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

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

Уязвимости + Соображения безопасности в GraphQL

Однажды мы увидим огромную уязвимость GraphQL, потому что писать безопасные интерпретаторы действительно сложно. [Источник]

И...

WordPress уже настолько масштабен, что у него уже есть огромная мишень на спине; добавление ЛЮБОГО плагина добавляет много риска, а плагин, предлагающий открыть буквально весь WordPress, включая множество образцов кода для обхода модели безопасности, — для меня большое «нет». Вывод, не управляемый темой, должен быть максимально ограничен (несуществующий, если я не прошу) сверх абсолютно необходимого для раскрытия. Надеюсь, это никогда не попадёт в core. [Источник]

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

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

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

  • авторизован ли пользователь?
  • является ли пользователь администратором?
  • имеет ли пользователь какую-либо роль или capability?
  • является ли пользователь автором записи?

Для удовлетворения этого требования Gato GraphQL предлагает Списки управления доступом, чтобы мы могли определить, кто может получить доступ к каждому полю и директиве, и всё это через конфигурацию.

Однако иногда использование только ACL недостаточно, и сервер GraphQL должен предоставлять дополнительные меры безопасности. Опишу, над чем я сейчас работаю для предстоящей версии v0.8 Gato GraphQL.

Поле posts (для получения данных записей) не требует авторизации — любой пользователь может получить к нему доступ, как авторизованный, так и нет. Следовательно, из соображений безопасности оно получает только опубликованные записи.

Но бывают ситуации, когда нам также нужно получить черновики/ожидающие/удалённые записи, например:

  • Для создания статического сайта, который выполняется администратором с доступом ко всем данным сайта
  • Для авторов записей, чтобы перечислить все черновики, которые они могут продолжить редактировать

Тогда я придумал следующую схему. Для получения записей будет 3 поля:

  • posts: открыто для всех, может получать только опубликованные записи
  • myPosts: открыто для всех, получает только записи авторизованного пользователя с любым статусом (опубликованные/черновик/ожидающие/удалённые)
  • postsForAdmin: доступ есть только у администратора, получает любую запись с любым статусом

И тогда postsForAdmin по умолчанию отключено, поэтому оно даже не появляется в схеме GraphQL, если только администратор явно не включит его (и, скорее всего, оно будет включено только для создания статических сайтов).

Другая ситуация возникает, когда некоторое поле может получать как публичные, так и приватные данные. Например, поле option получает данные из таблицы wp_options. Некоторые записи являются публичными (например, blogname), а другие — нет (например, admin_email).

Аналогичная ситуация складывается при получении мета-значений через поля Post.metaValue, User.metaValue и другие. Например, мета пользователя включает запись wp_capabilities, которая, безусловно, является приватной, тогда как description — публичная. А затем есть last_name, которое может быть публичным или приватным в зависимости от приложения.

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

Определение разрешённых/запрещённых записей для поля 'option'

Тогда запрос разрешённой опции будет работать, а запрещённая опция просто вернёт null:

{
  # This option is allowed
  siteName: optionValue(name: "blogname")
  # This optionValue is not allowed
  adminEmail: optionValue(name: "admin_email")
}

При наличии надлежащих мер безопасности, предоставляемых сервером GraphQL, и здравого смысла разработчика создание безопасного GraphQL API не должно быть сложным.

GraphQL и нагрузка на БД

GraphQL — это богатый синтаксис, позволяющий выражать глубокие реляционные запросы, поэтому для такой экосистемы, как WordPress, где расширяемость модели данных обеспечивается шаблоном entity-attribute-value, это означает невероятную нагрузку на базу данных, что может привести к неотзывчивости вашего сайта, если GraphQL-запрос является глубоким, сложным или рекурсивным. WordPress уже известен своей способностью поставить MySQL/MariaDB на колени, так что добавление GraphQL может сделать это намного хуже, если queries не написаны должным образом, не аутентифицированы и не ограничены по частоте. [Источник]

Чрезмерная нагрузка на БД — серьёзная проблема для серверов GraphQL. Опишу, как Gato GraphQL пытается избежать этого сценария.

Gato GraphQL предотвращает возникновение проблемы N+1 уже на архитектурном уровне. Это достигается за счёт того, что движок отвечает за загрузку сущностей из базы данных, а не разработчик.

При разрешении соединений в resolver возвращаемое значение — это ID (или список ID) объекта(ов), а не сам объект. Например, получение автора custom post делается так:

class CustomPostFieldResolver extends AbstractDBDataFieldResolver
{
  private CustomPostUserTypeAPIInterface $customPostUserTypeAPI;
 
  public function getClassesToAttachTo(): array
  {
    return [
      CustomPostFieldInterfaceResolver::class,
    ];
  }
 
  public function getSchemaFieldType(string $fieldName): ?string
  {
    return match($fieldName) {
      'author' => SchemaDefinition::TYPE_ID,
      default => null,
    };
  }
 
  public function resolveValue(
    TypeResolverInterface $typeResolver,
    object $customPost,
    string $fieldName,
    array $fieldArgs = []
  ): mixed {
    switch ($fieldName) {
      case 'author':
        return $this->customPostUserTypeAPI->getAuthorID($customPost);
    }
 
    return null;
  }
 
  public function resolveFieldTypeResolverClass(
    TypeResolverInterface $typeResolver,
    string $fieldName
  ): ?string {
    switch ($fieldName) {
      case 'author':
        return UserTypeResolver::class;
    }
 
    return null;
  }
}

Получив ID сущности БД из resolveValue и тип объекта из resolveFieldTypeResolverClass (представленный через класс UserTypeResolver), GraphQL-движок может затем загрузить данные для объекта.

Для загрузки данных движок использует крайне эффективный алгоритм: он имеет временну́ю сложность O(n), где n — количество типов в запросе, а не количество узлов.

Алгоритм достигает этой эффективности, потому что не обходит граф, а преобразует структуру данных в стек компонентов, что значительно проще для разрешения. («Граф» в GraphQL — это концепция, а не реальная реализация.)

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

{
  posts(pagination: { limit: 10 }) {
    excerpt
    title
    url
    author {
      name
      url
      posts(pagination: { limit: 10 }) {
        title
        tags(pagination: { limit: 10 }) {
          slug
          url
          posts(pagination: { limit: 10 }) {
            title
            comments(pagination: { limit: 10 }) {
              content
              date
              author {
                name
                posts(pagination: { limit: 10 }) {
                  title
                  url
                  comments(pagination: { limit: 10 }) {
                    content
                    date
                    author {
                      name
                      username
                      url
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Исключением из этой эффективности является получение мета-значений через Post.metaValue, User.metaValue, Comment.metaValue, PostTag.metaValue и PostCategory.metaValue (а также их поле metaValues). Это потому, что функции WordPress (get_post_meta, get_user_meta и т.д.) получают данные для 1 ID за раз, то есть каждой сущности потребуется вызов базы данных для получения её мета-значения. В результате разрешение мета-значений масштабируется на основе количества узлов, а не количества типов (комментарий автора оригинального поста попал в точку в этом отношении).

Чтобы предотвратить использование и злоупотребление мета-полями злоумышленниками, Gato GraphQL (в версии v0.8) поставляется с этими полями, отключёнными по умолчанию. Затем администратор должен явно их включить и, делая это, может поместить эти поля под какой-либо Access Control List, чтобы БД никогда не подвергалась риску атаки.

Ограничение частоты запросов (rate limiting) — тоже отличная идея; планирую поддержать его в одном из будущих релизов.

Также стоит анализировать и накладывать ограничения на сложность запроса (например, насколько глубоким он является). Сервер GraphQL разрешает запрос с временно́й сложностью O(n), поэтому с точки зрения зацикливания не так много вреда может быть нанесено. Однако один запрос всё равно может извлечь неограниченное количество данных из БД, и этого мы, возможно, захотим избежать.

Например, этот простой запрос принесёт огромное количество данных за один раз (на моём демо-сайте едва несколько сотен записей, поэтому я могу позволить себе продемонстрировать выполнение запроса):

{
  posts000: posts(pagination: { limit: 100 }) {
    ...PostFields
  }
  posts100: posts(pagination: { limit: 100, offset: 100 }) {
    ...PostFields
  }
  posts200: posts(pagination: { limit: 100, offset: 200 }) {
    ...PostFields
  }
  posts300: posts(pagination: { limit: 100, offset: 300 }) {
    ...PostFields
  }
  posts400: posts(pagination: { limit: 100, offset: 400 }) {
    ...PostFields
  }
  posts500: posts(pagination: { limit: 100, offset: 500 }) {
    ...PostFields
  }
  posts600: posts(pagination: { limit: 100, offset: 600 }) {
    ...PostFields
  }
  posts700: posts(pagination: { limit: 100, offset: 700 }) {
    ...PostFields
  }
  posts800: posts(pagination: { limit: 100, offset: 800 }) {
    ...PostFields
  }
  posts900: posts(pagination: { limit: 100, offset: 900 }) {
    ...PostFields
  }
}
 
fragment PostFields on Post {
  id
  title
  content
  date
}

Как можно видеть, запрос даже не должен быть вложенным, чтобы создать проблемы. Поэтому анализ сложности запроса — непростое дело, требующее тонкой настройки, чтобы быть полезным.

Я надеюсь поддержать анализ queries тоже, но это не в списке моих высоких приоритетов, потому что с комбинацией других функций (таких как Persisted queries или Custom Endpoints в сочетании со Списками управления доступом) мы уже можем держать злоумышленников вне системы, а мы сами не будем (не должны!) злоупотреблять собственным GraphQL-сервисом.


Подпишитесь на нашу рассылку

Будьте в курсе всех обновлений Gato GraphQL.