Концепции, идеи, стратегии
Концепции, идеи, стратегииЭволюция схемы посредством версионирования полей

Эволюция схемы посредством версионирования полей

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

Ломающими называются изменения, которые удаляют тип, поле или директиву либо изменяют сигнатуру уже существующего поля (или директивы), например:

  • Переименование поля
  • Изменение типа существующего аргумента поля или перевод его в обязательный
  • Добавление нового обязательного аргумента к полю
  • Добавление non-nullable к типу ответа поля

Для работы с ломающими изменениями существуют две основные стратегии: версионирование и эволюция — реализованные в REST и GraphQL соответственно.

REST API указывают версию используемого API либо в URL эндпоинта (например, https://api.mycompany.com/v1 или https://api-v1.mycompany.com), либо через заголовок (например, Accept-version: v1). При версионировании ломающие изменения добавляются в новую версию API, и поскольку клиентам необходимо явно указывать на новую версию API, они будут осведомлены об изменениях.

GraphQL не отвергает версионирование, но поощряет использование эволюции. Как указано на странице GraphQL best practices:

While there's nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.

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

5. Use an Agile Approach to Schema Development: The schema should be built incrementally based on actual requirements and evolve smoothly over time

Эволюция схемы

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

  1. Повторно реализовать поле, используя другое имя.
  2. Устаревшее поле пометить как deprecated, предложив клиентам использовать вместо него новое поле.
  3. Когда поле перестанет использоваться кем-либо, удалить его из схемы.

Рассмотрим пример. Предположим, у нас есть тип Account, моделирующий учётную запись физического лица с именем и фамилией посредством следующей схемы (с использованием SDL GraphQL — Schema Definition Language):

type Account {
  id: Int
  name: String!
  surname: String!
}

В этой схеме оба поля — name и surname — являются обязательными (это символ !, добавленный после типа String), поскольку предполагается, что у каждого человека есть и имя, и фамилия.

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

type Account {
  id: Int
  name: String!
  surname: String # Это изменилось
}

Это ломающее изменение, поскольку приложение не ожидает, что поле surname вернёт null, и может не проверять это условие — например, при выполнении следующего кода на JavaScript:

// Это завершится ошибкой, когда account.surname равен null
const upperCaseSurname = account.surname.toUpperCase();

Потенциальных ошибок, возникающих из-за ломающих изменений, можно избежать, используя эволюцию схемы:

  • Мы не изменяем сигнатуру поля surname; вместо этого помечаем его как deprecated, добавляя понятное сообщение с указанием имени заменяющего поля
  • Мы вводим в схему новое поле personSurname (или accountSurname)

Теперь наш тип Account выглядит следующим образом:

type Account {
  id: Int
  name: String!
  surname: String! @deprecated(reason: "Use `personSurname`")
  personSurname: String
}

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

type Account {
  id: Int
  name: String!
  personSurname: String
}

Проблемы эволюции

Описанный выше пример весьма прост, однако он уже демонстрирует ряд потенциальных проблем эволюции схемы:

ПроблемаОписание
Имена полей становятся менее удачнымиПри первом именовании поля мы, вероятно, найдём для него оптимальное имя, например surname. Когда же нам нужно его заменить, придётся создать другое имя, которое может оказаться неоптимальным (оптимальное уже занято!). Все возможные варианты замены в примере выше имеют недостатки:

- personName явно указывает на то, что учётная запись принадлежит физическому лицу, поэтому если впоследствии нам придётся открыть учётную запись для нефизического лица с фамилией (не знаю... марсианина?), нам снова придётся эволюционировать схему, чтобы сохранить согласованность имён
- Фрагмент «account» в accountName совершенно избыточен, поскольку тип уже называется Account
- Что ещё использовать? surname1? surnameNew? Или ещё хуже — surnameV2?

Как следствие, обновлённая схема будет менее понятной и более многословной.
Схема может накапливать устаревшие поляПометка полей как deprecated наиболее разумна в качестве временной меры; в конечном счёте мы хотели бы удалить их из схемы, чтобы очистить её, не позволяя им накапливаться.

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

Рассмотрим, как решить эти проблемы.

Версионирование полей

Мы можем создать поле с аргументом version, через который указываем, какую версию поля использовать.

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

Обратите внимание, что данная концепция версионирования отличается от версионирования в REST:

  • REST устанавливает ситуацию «всё или ничего», при которой весь запрашиваемый API имеет одну и ту же версию, поскольку используемая версия является частью эндпоинта
  • В данном подходе каждое поле версионируется независимо

Таким образом, мы можем обращаться к разным версиям разных полей:

query GetPosts {
  posts(version: "1.0.0") {
    id
    title(version: "2.1.1")
    url
    author {
      id
      name(version: "1.5.3")
    }
  }
}

Более того, опираясь на semantic versioning, мы можем использовать ограничения версий для выбора нужной версии, следуя тем же правилам, которые использует Composer для объявления зависимостей пакетов. Затем переименуем аргумент поля version в versionConstraint и обновим запрос:

query GetPosts {
  posts(versionConstraint: "^1.0") {
    id
    title(versionConstraint: ">=2.1")
    url
    author {
      id
      name(versionConstraint: "~1.5.3")
    }
  }
}

Применяя эту стратегию к нашему устаревшему полю surname, мы можем пометить устаревшую реализацию как версию "1.0.0", а новую — как версию "2.0.0" и обращаться к обеим даже в рамках одного запроса:

query GetSurname {
  account(id: 1) {
    oldVersion: surname(versionConstraint: "^1.0")
    newVersion: surname(versionConstraint: "^2.0")
  }
}

Эта возможность доступна в Gato GraphQL:

Запрос полей через ограничения версий

Версионирование директив

Поскольку директивы также принимают аргументы, мы можем применить ровно ту же методологию для версионирования директив!

Например, при выполнении следующего запроса:

query {
  post(by: { id: 1 }) {
    oldVersion: title @strTitleCase(versionConstraint: "^0.1")
    newVersion: title @strTitleCase(versionConstraint: "^0.2")
  }
}

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

Запрос версионированной директивы