Хабрахабр

Что не так с GraphQL

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

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

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

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

1. NON_NULL

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

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

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

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

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

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

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

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

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

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!
}

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

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.
Но это не значит, что его нельзя есть 🙂

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть