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