Что не так с GraphQL
В последнее время 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!
}
то обнаружим:
Тип 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 {
total: UInt
items: [T!]!
}
тогда бы я просто писал
type Query {
users(page: UInt, perPage: UInt): PageOf!
}
Да тонны применений! Мне ли вам рассказывать о дженериках?
5. Неймспейсы
Их тоже нет. Когда количество типов в системе перваливает за полторы сотни, вероятность коллизий имен стремится к ста процентам.
И появляются всякие Service_GuideNDriving_Standard_Model_Input
. Я уж не говорю о полноценных неймспейсах на разных эндпоинтах, как в SOAP (да-да — он ужасен, но неймспейсы там сделаны прекрасно). А хотябы несколько схем на одном эндпоинте с возможностью «шарить» типы между схемами.
Итого
GraphQL — хороший инструмент. Он прекрасно ложится на толерантную, микросервисную архитектуру, которая ориентирована, в первую очредь, на вывод информации, и несложный, детерминированный ввод.
Если же у вас имеются полиморфные сущности на ввод — у вас могут возникнуть проблемы.
Разделение типов ввода и вывода, а также отсутствие дженериков — порождают кучу писанины на пустом месте.
Graphql — это не совсем (а бывает и совсем не) про CRUD.
Но это не значит, что его нельзя есть :)
В следующем материале, я хочу рассказать о том, как я сражаюсь (и иногда успешно) с некоторыми из описанных выше проблем.