Блог

💬 Предлагая новый подход к «Gutenberg и несвязанным приложениям»

Leonardo Losoviz
Автор: Leonardo Losoviz ·

Несколько дней назад создатель WPGraphQL Джейсон Бал опубликовал статью Gutenberg and Decoupled Applications, в которой анализирует преимущества и недостатки трёх подходов к интеграции GraphQL с Gutenberg.

Неделей ранее он также написал в Twitter, что подход Gato GraphQL к моделированию Gutenberg является неуместным:

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

Возврат wildcard-типа «Object» без предсказуемой формы означает, что клиентские приложения могут сломаться в любой момент, поскольку между сервером и клиентом больше нет контракта. Сервер лишил клиента контроля.

В этой статье я присоединяюсь к дискуссии. Я отвечу на критику Джейсона и попутно опишу подход своего плагина, а также покажу, почему, на мой взгляд, он вполне подходит для Gutenberg.

Использование COPE для извлечения метаданных Gutenberg

Моё решение можно считать 4-м подходом, и оно заключается в следующем:

Чтобы получить данные Gutenberg для GraphQL, не нужно создавать дополнительную схему на стороне PHP или дублировать существующие данные. Вместо этого данные извлекаются из сохранённого содержимого блоков с помощью стратегии COPE («Create Once, Publish Everywhere» — «Создай однажды, публикуй везде»).

(COPE — это стратегия, позволяющая иметь единый источник истины для контента и предоставлять его различным приложениям. В нашем случае единым источником истины являются данные блоков Gutenberg в том виде, в котором они хранятся в базе данных. Я описал COPE и его реализацию для WordPress в этой статье.)

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

Эта стратегия — компромисс, а не окончательное решение

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

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

  • Сохранённое содержимое не указывает тип поля
  • Сохранённое содержимое не указывает, какие ограничения есть у поля (является ли оно nullable? это положительное целое число? строка для email или URL?)
  • Nullable-поля могут иметь значение по умолчанию, которое не будет присутствовать в сохранённом содержимом

Тем не менее, используя стратегию COPE и единый тип Block для представления всех блоков, Gato GraphQL может обеспечить весьма достойную интеграцию с Gutenberg, преодолевающую существующие ограничения.

Я расскажу об этом на протяжении всей статьи.

Интеграция Gato GraphQL с Gutenberg

Это решение находится в стадии разработки, но я уже могу объяснить, как оно будет работать.

Вместо того чтобы использовать отдельный тип для каждого блока (как это делает WPGraphQL, опираясь на плагин WPGraphQL for Gutenberg), Gato GraphQL предоставит единый тип Block для представления всех блоков.

В этом запросе поле Post.blockDataItems получает список элементов Block из записи (для различных блоков Gutenberg, включая абзацы, изображения, списки и другие):

{
  post(by: { id: 1499 }) {
    title
    blockDataItems
  }
}

Если мы хотим получить данные для конкретного блока, можно выполнить фильтрацию по имени блока (core/paragraph, core/quote и т.д.).

В этом запросе мы получаем только блоки изображений:

{
  post(by: { id: 1177 }) {
    title
    blockDataItems(
      filterBy: { include: "core/image" }
    )
  }
}

Изучение единственного типа Block

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

Каждый элемент Block содержит два свойства:

  • name: Имя блока (core/paragraph, core/quote и т.д.)
  • meta: Метаданные, содержащиеся в блоке

Каждый блок Gutenberg отличается от других и содержит разные данные (текст абзаца, видео с YouTube, URL источника изображения и его размеры и т.д.). Следовательно, данные в ответе для поля meta также будут разными.

Поэтому поле meta было отображено просто как объект JSON (который может содержать «сырые» данные) через соответствующий тип JSONObject в схеме GraphQL.

Это даёт следующий ответ:

{
  "data": {
    "post": {
      "title": "COPE with WordPress: Post demo containing plenty of blocks",
      "blockDataItems": [
        {
          "name": "core/paragraph",
          "attributes": {
            "content": "Lorem ipsum dolor sit amet"
          }
        },
        {
          "name": "core/image",
          "attributes": {
            "src": "https://ps.w.org/gutenberg/assets/banner-1544x500.jpg"
          }
        },
        {
          "name": "core/quote",
          "attributes": {
            "quote": "Etiam tempor orci eu lobortis elementum nibh tellus molestie",
            "cite": "Aristoteles"
          }
        },
        {
          "name": "core/heading",
          "attributes": {
            "size": "xl",
            "heading": "Welcome to my site"
          }
        },
        {
          "name": "core/list",
          "attributes": {
            "items": [
              "First element",
              "Second element",
              "Third element"
            ]
          }
        },
      ]
    }
  }
}

Как видим, разные блоки возвращают разные свойства:

  • core/paragraph имеет свойство content
  • core/image имеет свойство src, а также опционально свойства width, height и caption (не присутствующие в ответе выше)
  • core/quote имеет свойства quote и cite (для цитируемого лица)
  • core/heading имеет свойства header и size (значение xl соответствует <h2>, потому что COPE отделяет значение от целевого приложения — в данном случае веб-сайта)
  • core/list имеет свойство items, которое является списком элементов

Почему тип JSONObject не входит в спецификацию

Описанный выше тип JSONObject позволяет GraphQL получать «динамические» поля (такие как поля, которые нам неизвестны заранее) или поля, которые могут иметь несколько конфигураций (как это может быть с блоками Gutenberg).

Между тем спецификация GraphQL в настоящее время не поддерживает типы JSONObject или Map. Поддержка была запрошена по причинам, таким как:

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

Это приводит к необходимости реализовывать пользовательские резолверы на сервере, а затем пользовательские преобразования на клиенте, чтобы справиться с ситуациями, когда мой сервер отправляет Map, мой клиент хочет Map, а GraphQL находится посередине без поддержки Maps. Да, это возможно, и я это делал, но это немало шаблонного кода и абстракций, которые, кажется, сводят на нет смысл написания спецификации API на GraphQL.

Эта функция не поддерживается спецификацией, потому что работа с динамическими полями идёт вразрез со строгой типизацией GraphQL, что нарушает контракт между сервером и клиентом.

Тем не менее этот тип может оказаться полезным для Gutenberg, как я покажу далее.

Проблемы при использовании отдельного типа для каждого блока и реестра на стороне сервера

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

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

Это может произойти, когда GraphQL взаимодействует с внешним облачным сервисом, который применяет какую-либо функцию ко всем блокам в записи (перевод, исправление грамматики, SEO-предложения, аналитика и т.д.).

Рассмотрим пример.

Поскольку многоязыковые возможности будут добавлены в Gutenberg на 4-м этапе, давайте смоделируем, как перевести все блоки в плагине с помощью вызова Google Translate API, выполненного через директиву @strTranslate.

(После этого первоначального перевода на основе API пользователь может продолжать редактировать запись в блоге на переведённом языке, всегда оставаясь в редакторе WordPress.)

Разные блоки содержат разные фрагменты информации, которые необходимо перевести:

  • core/paragraph: текст
  • core/image: подпись
  • core/quote: цитата и цитируемое лицо (поскольку это может быть должность человека, например «The school headmaster»)
  • core/heading: заголовок
  • core/list: все элементы списка

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

{
  post(by: { id: 1 }) {
    blocks {
      ... on CoreParagraphBlock {
        content @strTranslate
      }
      ... on CoreImageBlock {
        caption @strTranslate
      }
      ... on CoreQuoteBlock {
        quote @strTranslate
        cite @strTranslate
      }
      ... on CoreHeadingBlock {
        heading @strTranslate
      }
      ... on CoreListBlock {
        items @strTranslateList
      }
      ... on EmbedTwitterBlock {
        caption @strTranslate
      }
      ... on EmbedYoutubeBlock {
        caption @strTranslate
      }
      ... on EmbedVimeoBlock {
        caption @strTranslate
      }
    }
  }
}

И так далее. Чем больше блоков у нас есть, тем длиннее будет этот запрос — легко превышая сотню строк и даже больше.

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

Кроме того, нам нужно вводить пользовательскую функциональность, чтобы это работало для каждого блока. Например, @strTranslate не работает с CoreListBlock.items, который возвращает список строк (то есть возвращает [String], тогда как директива ожидает String), поэтому нам нужно создавать @strTranslateList.

А затем core/table потребует собственной пользовательской директивы (@strTranslateTable?).

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

И тогда я вижу ещё несколько проблем.

Всё или ничего

Запись в блоге может содержать любой блок, установленный в редакторе WordPress. И мы заранее не знаем (при написании запроса), какие блоки использует запись.

Следовательно, при одном типе на блок количество типов для обработки в запросе не будет равно количеству блоков в записи. Вместо этого оно будет равно количеству блоков, установленных в редакторе WordPress.

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

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

Запрос нужно обновлять каждый раз при установке нового блока

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

Это не просто лишняя бюрократия: мы не сможем установить блок на рабочем сайте без опасения сломать приложение (пока все запросы не будут обновлены).

GraphQL должен служить WordPress, а не наоборот

Вспоминая снова, почему JSONObject не был добавлен в спецификацию GraphQL, — потому что он не вписывается в подход GraphQL.

Однако нас здесь на самом деле не заботит GraphQL. Нас заботит только WordPress и, более конкретно в данном случае, Gutenberg.

При интеграции GraphQL с Gutenberg GraphQL будет работать в контексте WordPress. Это означает, что WordPress должен удовлетворять требованиям GraphQL. Но, что важнее, именно GraphQL должен удовлетворять требованиям WordPress.

И в случае конфликта WordPress имеет приоритет.

Если какая-то функция не подходит GraphQL, но тем не менее подходит Gutenberg, стоит ли её рассматривать?

Думаю, да.

Давайте посмотрим, как единственный тип Block может лучше служить Gutenberg.

Решение предыдущих проблем с помощью единственного типа Block

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

{
  post(by: { id: 1 }) {
    blocks {
      name
      meta
        @advancePointersInArray(paths: "{{ translatablePaths }}")
          @underEachArrayItem
            @strTranslate(from: "en", to: "fr")
    }
  }
}

Это всё? Весь запрос? Для перевода всех блоков? Да.

Будет ли это работать для всех блоков — как из ядра, так и из плагинов, уже существующих или ещё не созданных? Да.

Этот запрос кажется вам немного странным? Если да, то потому что он использует нестандартные функции GraphQL, поддерживаемые только Gato GraphQL:

  • {{ translatablePaths }} — это встраиваемое поле для передачи значения поля в качестве аргумента другому полю или директиве (в данном случае тип Block будет иметь поле translatableFields, значение которого вставляется в директиву @advancePointersInArray)
  • директивы могут компоноваться другими директивами

Если функция удовлетворяет именно тому, что нужно CMS, но при этом является нестандартной, следует ли нам её использовать? Думаю, да.

Я также подал запросы на добавление этих функций в спецификацию GraphQL (хотя они не будут приняты):

Как работает единственный тип Block

Внимание: впереди технический раздел.

Тип Block будет иметь поле translatablePaths, возвращающее массив свойств из JSONObject, которые необходимо перевести:

  • core/paragraph возвращает ["content"]
  • core/image возвращает ["caption"]
  • core/quote возвращает ["quote", "cite"]
  • core/heading возвращает ["header"]
  • core/list возвращает ["items.0", "items.1", "items.2", ...]

@advancePointersInArray — это мета-директива: она изменяет контекст для последующей директивы. Она заставляет следующую директиву получать подэлемент из запрошенного JSONObject, например свойство content блока абзаца. Список путей получается через поле translatablePaths, вычисляемое на том же запрашиваемом объекте.

Затем @underEachArrayItem — ещё одна мета-директива, которая итерирует список элементов запрошенной сущности и передаёт ссылку на итерируемый элемент следующей директиве. В данном случае она получает весь список свойств для перевода для всех сущностей, каждое из которых имеет тип String, и передаёт отдельные элементы String дальше по цепочке.

Наконец, директива @strTranslate получает элемент типа String, содержащийся внутри JSONObject, и переводит его прямо там, внутри самого JSONObject.

Обратите внимание, насколько гибко это решение. Достаточно указать путь к строке внутри JSONObject, чтобы получить доступ к значению, изменить его с помощью @strTranslate (или любой другой директивы) и, возможно, даже сохранить значение обратно в БД (работа по реализации этого в настоящее время ведётся).

Это уже работает для core/list, поскольку все элементы списка доступны по собственным путям (items.0 — это 1-й элемент в массиве и т.д.). Затем можно получить значение String каждого из них и передать его в @strTranslate, поэтому нет необходимости создавать @strTranslateList.

Аналогично, это будет работать и с core/table. Нам просто нужно предоставить данные через свойство cells, которое будет двухмерным массивом (одно измерение для строк, содержащее другое для столбцов). Затем translatablePaths сможет обращаться ко всем элементам как ["cells.0.0", "cells.0.1", "cells.1.0", ...].

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

Единственный тип Block требует настройки на основе PHP-кода

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

В Gutenberg есть два места, где может храниться свойство блока: как атрибут или внутри отрендеренного контента.

Например, вот как хранится блок core/image:

<!-- wp:image {"id":1670,"sizeSlug":"large","linkDestination":"none"} -->
<figure class="wp-block-image size-large">
<img src="https://newapi.getpop.org/wp/wp-content/uploads/2021/01/dynamic-include-first-query.webp" alt="" class="wp-image-1670"/>
</figure>
<!-- /wp:image -->

В данном случае:

  1. Свойства id, sizeSlug и linkDestination хранятся как атрибуты
  2. Свойство src хранится внутри отрендеренного контента

При запросе к API ответ для блока core/image будет следующим:

{
  "data": {
    "blocks": [
      {
        "name": "core/image",
        "meta": {
          "id": 1670,
          "sizeSlug": "large",
          "linkDestination": "none",
          "src": "https://newapi.getpop.org/wp/wp-content/uploads/2021/01/dynamic-include-first-query.webp"
        }
      }
    ]
  }
}

API знает, как извлекать свойства, разбирая сохранённый блок в Gutenberg (это и есть стратегия COPE). Этот процесс можно автоматизировать до определённой степени, а затем выполнять некоторый ручной ввод через хуки или через пользовательский интерфейс.

Получение свойств, напрямую отображённых как атрибуты, является тривиальной задачей. GraphQL-сервер уже может получить все атрибуты блока и сделать их доступными как свойства. Или, если мы хотим явно определить, какие из них открыть, можно сделать это через filter hooks:

$attrs = apply_filters("blockPropsAsAttr:core/image", []);
 
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
  return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})

Свойства, хранящиеся в контенте, можно извлечь с помощью регулярных выражений:

$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
 
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
  $propRegexes['src'] = '/<img src="(.*?)"/';
  return $propRegexes;
})

Наконец, мы указываем, какие свойства блока являются переводимыми, чтобы @strTranslate работал с ними:

$propRegexes = apply_filters("translatableProperties:core/image", []);
 
add_filter("translatableProperties:core/image", function ($properties) {
  $properties[] = 'caption';
  return $properties;
})

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

Но что, если сообщество WordPress не захочет добавлять предложенный реестр на стороне сервера? Что ж, эта стратегия легко адаптируется, потому что отображение можно выполнить через PHP-код, как только что было показано.

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

Кроме того, мы можем сделать так, чтобы GraphQL оповещал пользователя, когда есть блок, который не был отображён (и потому не может быть переведён). Мы можем сделать это, добавив мета-директиву @if, которая, если условие выполняется, выполняет директиву @sendEmail:

{
  post(by: { id: 1 }) {
    blocks {
      name
      meta
        @advancePointersInArray(paths: "{{ translatablePaths }}")
          @underEachArrayItem
            @strTranslate(from: "en", to: "fr")
        @if(condition: "{{ isTranslatablePathsUnmapped }}")
          @sendEmail(
            to: "{{ root.adminEmail }}",
            subject: "Block with name {{ name }} has 'translatablePaths' unmapped"
          )
    }
  }
}

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

Заключение

Размышляя о том, как может выглядеть потенциальная интеграция GraphQL и Gutenberg (с возможным включением в ядро WordPress), мы должны убедиться, что GraphQL сможет справиться со всеми будущими требованиями Gutenberg, включая полную поддержку:

  • многоязычных блоков
  • Full Site Editing
  • совместного редактирования
  • взаимодействия со сторонними сервисами на рабочем сайте

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

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


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

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