Что не так с GraphQL

в 15:53, , рубрики: graphql, graphql-client, graphql-server, Разработка веб-сайтов, разработка мобильных приложений

В последнее время GraphQL набирает всё большую популярность. Изящный синтаксис запросов, типизация и подписки.
Кажется: "вот оно — мы нашли идеальный язык обмена данными!"...

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

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

Думаю, что стоит сделать небольшую ремарку относительно того, где я применяю данный язык. Это довольно сложная SPA-админка, большая часть операций в которой — это довольно нетривиальный CRUD (сложновложенные сущности). Значительная часть аргументации в данном материале связана именно с характером приложения и характером обрабатываемых данных. В приложениях другого типа (или с другим характером данных) таких проблем может и не возникнуть в принципе.

1. NON_NULL

Это не то, чтобы серьезная проблема. Скорее это целая серия неудобств связанных c тем как организована работа с nullable в GraphQL.

Есть в функциональных (и не только) языках программирования, такая парадигма — монады. Так вот, есть там такая штука, как монада Maybe (Haskel) или Option(Scala), Суть в том, что содержащееся внутри такой монады значение, может существовать, а может и не существовать (то есть быть null'ом). Ну или это может быть реализовано через enum, как в Rust'е.

Так или иначе, а в большинстве языков это значение, которое "оборачивает" исходное, делает null дополнительным вариантом к основному. Да и синтаксически — это всегда дополнение к основному типу. Это не всегда именно отдельный класс типа — в некоторых языках это просто дополнение в виде суффикса или префикса ?.

В GraqhQL всё наоборот. Все типы по умолчанию nullable — и это не просто пометка типа как nullable, это именно монада Maybe наоборот.
И если мы рассмотрим участок интроспекции поля name для вот такой схемы:

# в примерах далее я буду опускать schema - будем считать, что это очевидно
schema {
  query: Query
}

type Query {
   # здесь восклицательный знак как раз обозначает NonNull
   name: String! 
}

то обнаружим:

image

Тип String обернут в NON_NULL

1.1. OUTPUT

Почему именно так? Если коротко — это связано, с "толерантным" по умолчанию дизайном языка (в числе прочего — дружелюбным к микросервисной архитектуре).
Чтобы понять суть этой "толерантности", рассмотрим чуть более сложный пример, в котором все возвращаемые значения строго обернуты в NON_NULL:

type User {
   name: String!
   # Обащаем внимание: это ненулевое поле содержащее колекцию ненулевых пользователей.
   friends: [User!]! 
}

type Query {
   # Обащаем внимание: это ненулевое поле содержащее колекцию ненулевых пользователей.
   users(ids: [ID!]!): [User!]!
}

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

type User {
   name: String!
   # Убрали восклицательный знак - допускаем null вместо списка друзей.
   # Теперь если сервис "дружбы" упадет - мы всё равно сможем вернуть пользователя, хотябы и без друзей.
   friends: [User!] 
}

Вот это и есть толерантность к внутренним ошибкам. Пример, конечно, надуманный. Но надеюсь, что суть вы ухватили.

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

type Query {
   # Допускаем null в списке пользователей. 
   # Теперь мы сможем сопоставить коллекцию идентификаторов с коллекцией пользователей по индексам и понять какие айдишники устарели.
   users(ids: [ID!]!): [User]!
}

Всё ок. А в чем проблема-то?
В общем, не очень большая проблема — так вкусовщина. Но если у вас монолитное приложение с реляционной бд, то скорее всего ошибки — это действительно ошибки, а апи должно быть максимально строгим. Здравствуйте, восклицательные знаки! Везде, где можно.
Я бы хотел иметь возможность "инвертировать" это поведение, и расставлять вопросительные знаки, вместо восклицательных ) Привычнее было бы как-то.

1.2. INPUT

А вот при вводе, nullable — это вообще отдельная история. Это косяк уровня checkbox в HTML (думаю, что все помнят эту неочевидность, когда поле неотмеченного чекбокса просто не отправляется на бэк).

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

type Post {
  id: ID!
  title: String!
  # Обращаем внимание: поле описания может содержать null
  description: String
  content: String!
}

input PostInput {
  title: String!
  # Обращаем внимание: поле описания не является обязательным, для ввода
  description: String
  content: String!
}

type Mutation {
  createPost(post: PostInput!): Post!
}

Пока всё нормально. Добавим update:

type Mutation {
  createPost(post: PostInput!): Post!
  updatePost(id: ID!, post: PostInput!): Post!
}

А теперь вопрос: что нам ожидать от поля description при апдейте поста? Поле может быть null, а может вообще отсутствовать.
Если поле отсутсвует, то что нужно сделать? Не обновлять его? Или уставновить его в null? Суть в том, что разрешить значение null и разрешить отсутсвие поля — это разные вещи. Тем не менее в GraphQL — это одно и тоже.

2. Разделение ввода и вывода

Это просто боль. В модели работы CRUD, ты получаешь объект с бэка "подкручиваешь" его, и отправляешь назад. Грубо говоря, это один и тот же объект. Но тебе просто придется описать его дважды — на ввод и на вывод. И с этим ничего нельзя сделать, кроме как написать генератор кода под это дело. Я бы предпочел разделять на "вводимы и выводимые" не сами объекты, а поля объекта. Например модификаторами:

type Post {
  input output text: String!
  output updatedAt(format: DateFormat = W3C): Date!
}

или используя директивы:

type Post {
  text: String!
  @input @output

  updatedAt(format: DateFormat = W3C): Date!
  @output
}

3. Полиморфизм

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

interface Commentable {
  comments: [Comment!]!
}

type Post implements Commentable {
  text: String!
  comments: [Comment!]!
}

type Photo implements Commentable {
  src: URL!
  comments: [Comment!]!
}

или юнионы

type Person {
  firstName: String,
  lastName: String,
}

type Organiation  {
  title: String
}

union Subject = Organiation | Person

type Account {
  login: String
  subject: Subject
}

Сделать тоже самое для вводимых типов нельзя. Для этого есть ряд предпосылок, но отчасти это связано и с тем, что в качестве формата данных при транспорте используется json. Тем не менее, при выводе, для конкретизации типа используется поле __typename. Почему нельзя было сделать тоже самое при вводе — не очень понятно. Мне кажется, что эту проблему можно было бы решить немного изящнее, отказавшись от json при транспорте и введя свой формат. Что-то в духе:

union Subject = OrganiationInput | PersonInput

input AccountInput {
  login: String!
  password: String!
  subject: Subject!
}

# Создание акаунта для организации

{
  account: AccountInput {
    login: "Acme",
    password: "***",
    subject: OrganiationInput {
        title: "Acme Inc"
    }
  }
}

# Создание акаунта для частного лица
{
  account: AccountInput {
    login: "Acme",
    password: "***",
    subject: PersonInput {
        firstName: "Vasya",
        lastName: "Pupkin",
    }
  }
}

Но это породило бы необходимость написания дополнительных парсеров под это дело.

4. Дженерики

А что не так в GraphQL c дженериками? А всё просто — их нет. Возьмем до банального обычный для CRUD индексный запрос с пагинацией или курсором — не важно. Я приведу пример с пагинацией.

input Pagination {
  page: UInt,
  perPage: UInt,
}

type Query {
  users(pagination: Pagination): PageOfUsers!
}

type PageOfUsers {
  total: UInt
  items: [User!]!
}

а теперь для огранизаций

type Query {
  organizations(pagination: Pagination): PageOfOrganizations!
}

type PageOfOrganizations {
  total: UInt
  items: [Organization!]!
}

и так далее… как бы я хотел иметь для этого дела дженерики

type PageOf<T> {
  total: UInt
  items: [T!]!
}

тогда бы я просто писал

type Query {
  users(page: UInt, perPage: UInt): PageOf<User>!
}

Да тонны применений! Мне ли вам рассказывать о дженериках?

5. Неймспейсы

Их тоже нет. Когда количество типов в системе перваливает за полторы сотни, вероятность коллизий имен стремится к ста процентам.
И появляются всякие Service_GuideNDriving_Standard_Model_Input. Я уж не говорю о полноценных неймспейсах на разных эндпоинтах, как в SOAP (да-да — он ужасен, но неймспейсы там сделаны прекрасно). А хотябы несколько схем на одном эндпоинте с возможностью "шарить" типы между схемами.

Итого

GraphQL — хороший инструмент. Он прекрасно ложится на толерантную, микросервисную архитектуру, которая ориентирована, в первую очредь, на вывод информации, и несложный, детерминированный ввод.

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

Graphql — это не совсем (а бывает и совсем не) про CRUD.
Но это не значит, что его нельзя есть :)

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

Автор: greabock

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js