В прошлом материале, мы рассмотрели неудобные моменты в системе типов GraphQL.
А теперь мы попробуем победить некоторые из них. Всех заинтересованных, прошу под кат.
Нумерация разделов соответствует тем проблемам, с которыми мне удалось справится.
1.2 NON_NULL INPUT
В этом пункте, мы рассмотрели неоднозначность, которую порождает особенность реализации nullable в GraphQL.
А проблема в том, что это не позволяет с наскока реализовать концепцию частичного обновления (partial update) — аналог HTTP-метода PATCH в архитектуре REST. В комментариях к прошлому материалу меня сильно критиковали за "REST"-мышление. Я же скажу лишь то, что к этому меня обязывает CRUD архитектура. И я не был готов отказываться от преимуществ REST, просто потому, что "не делай так". Да и решение данной проблемы нашлось.
И так, вернемся к проблеме. Как мы все знаем, сценарий работы CRUD, при обновлении записи выглядит так:
- Получили запись с бэка.
- Отредактировали поля записи.
- Отправили запись на бэк.
Концепция partial update, в этом случае, должна позволять нам отправлять назад только те поля, которые были изменены.
Итак, если мы определим модель ввода таким образом
input ExampleInput {
foo: String!
bar: String
} то при маппинге переменной типа ExampleInput с таким значением
{
"foo": "bla-bla-bla"
}на DTO с такой структурой:
ExampleDTO {
foo: String # обязательное поле
bar: ?String # необязательное поле
}мы получим объект DTO c таким значением:
{
foo: "bla-bla-bla",
bar: null
}а при маппинге переменной с таким значением
{
"foo": "bla-bla-bla",
"bar": null
}мы получим объект DTO c таким же значением, как в прошлый раз:
{
foo: "bla-bla-bla",
bar: null
}То есть, происходит энтропия — мы теряем информацию, о том было передано поле от клиента, или нет.
В этом случае не понятно, что нужно сделать с полем конечного объекта: не трогать его потому, что клиент не передал поле, или установить ему значение null, потому что клиент передал null.
Строго говоря, GraphQL — это RPC протокол. И я стал размышлять о том, как я делаю такие вещи на бэке и какие процедуры я должен вызывать, чтобы сделать именно так, как мне хочется. А на бэкенде я делаю частичное обновление полей так:
$repository->find(42)->setFoo('bla-bla-lba');То есть, я буквально не трогаю сеттер свойства сущности, если мне не нужно изменять значение этого свойства. Если переложить это на схему GraphQL, то получится вот такой результат:
type Mutation {
entityRepository: EntityManager!
}
type EntityManager {
update(id: ID!): PersitedEntity
}
type PersitedEntity {
setFoo(foo: String!): String!
setBar(foo: String): String
}теперь, если захотим, мы можем вызвать метод setBar, и установить его значение в null, или не трогать этот метод, и тогда значение не будет изменено. Таким образом, выходит недурная реализация partial update. Не хуже, чем PATCH из пресловутого REST.
В комментариях к прошлому материалу, summerwind спрашивал: зачем нужен partial update? Отвечаю: бывают ОЧЕНЬ большие поля.3. Полиморфизм
Часто бывает, что нужно подавать на ввод сущности, которые вроде "одно и то же" но не совсем. Я воспользуюсь примером с созданием аккаунта из прошлого материала.
# аккаунт организации
AccountInput {
login: "Acme",
password: "***",
subject: OrganiationInput {
title: "Acme Inc"
}
}# аккаунт частного лица
AccountInput {
login: "Acme",
password: "***",
subject: PersonInput {
firstName: "Vasya",
lastName: "Pupkin",
}
}Очевидно, что мы не можем подать данные с такой структурой на один аргумент — GraphQL просто не разрешит нам это сделать. Значит, нужно как-то решить эту проблему.
Способ 0 — в лоб
Первое, что приходит в голову — это разделение вариативной части ввода:
input AccountInput {
login: String!
password: Password!
subjectOrganization: OrganiationInput
subjectPerson: PersonInput
}Мда… когда я вижу такой код, я часто вспоминаю Жозефину Павловну. Мне это не подходит.
Способ 1 — не в лоб, а по лбу
Тут мне на помощь пришел тот факт, что для идентификации сущностей, я использую я использую UUID (вообще всем рекомендую — не один раз выручит). А это значит, что я могу создавать валидные сущности прямо на клиенте, связывать их между собой по идентификатору, и отправлять на бэк, по отдельности.
Тогда мы можем сделать что-то в духе:
input AccountInput {
login: String!
password: Password!
subject: SubjectSelectInput!
}
input SubjectSelectInput {
id: ID!
}
type Mutation {
createAccount(
organization: OrganizationInput,
person: PersonInput,
account: AccountInput!
): Account!
}или, что оказалось еще удобнее (почему это удобнее, я расскажу, когда мы доберемся до генерации пользовательских интерфейсов), разделить это на разные методы:
type Mutation {
createAccount(account: AccountInput!): Account!
createOrganization(organization: OrganizationInput!): Organization!
createPerson(person: PersonInput!) : Person!
}Тогда, нам нужно будет отправить запрос на createAccount и createOrganization/createPerson
одним батчем. Стоит отметить, что тогда обработку батча нужно обязательно обернуть в транзакцию.
Способ 2 — волшебный скаляр
Фишка в том, что скаляр в GraphQL, это не только Int, Sting, Float и т.д. Это вообще всё что угодно (ну, пока с этим может справится JSON, конечно).
Тогда мы можем просто объявить скаляр:
scalar SubjectInputПотом, написать на него свой обработчик, и не парится. Тогда мы сможем без проблем подсовывать вариативные поля на ввод.
Какой из способов выбрать? Я использую оба, и выработал для себя такое правило:
Если родительская сущность является Aggregate Root для дочерней, то я выбираю второй способ, иначе — первый.
4. Дженерики.
Тут всё банально и ничего лучше генерации кода я не придумал. И без Рельсы (пакет railt/sdl) я бы не справился (точнее, сделал бы тоже самое но с костылями). Фишка в том, что Рельса позволяет определять директивы уровня документа (в спеке нет такой позиции для директив).
directive @example on DOCUMENTТо есть, директивы непривязанные, к чему либо, кроме документа, в котором они вызваны.
Я ввел такие директивы:
directive @defineMacro(name: String!, template: String!) on DOCUMENT
directive @macro(name: String!, arguments: [String]) on DOCUMENTДумаю, что объяснять суть макросов никому не нужно...
На этом пока всё. Не думаю, что этот материал вызовет столько же шума, как прошлый. Всё таки заголовок там был довольно "желтым" )
В комментариях к прошлому материалу хабровчане топили за разделение доступа… значит следующий материал будет об авторизации.
Комментарии (22)

summerwind
11.01.2019 13:32Теперь давайте все-таки отложим в сторону REST с его различиями
null\undefinedи попробуем посмотреть на мутации как на функции в языках программирования. И, вместо вашего оригинального технического решения, которое меняет состояние БД в query-запросах (причем, каждое поле в отдельном запросе к БД), можно решить задачу, например, так:
# Создаем input, где все поля - опциональные input ExampleOptionalInput { foo: String bar: String } # Создаем мутацию с доп. аргументом type Mutation { updateExampleOptionally(input: ExampleOptionalInput!, onlyFields: [String!]): Example }
greabock Автор
11.01.2019 13:53причем, каждое поле в отдельном запросе к БД
Для этого умные дядьки давным давно придумали unit of work — запрос к бд будет один. Я очень ответственно подхожу к вопросу лишних запросов.
Я думал о предложенном вами варианте. Но это порождает запросы с неопределенной структурой — в вашем варианте в onlyFields можно передавать что угодно. Ну или как минимум, завести какой-то Enum с перечислением всех полей доступных в данной сущности.
onlyFields: [ExampleInputField!]
В общем, этот вариант будет работать, но он мне субъективно не нравится.
Потому что у нас получается два источника истины: один — это список полей самой сущности, второй — это Enum перечисляющий поля.
summerwind
11.01.2019 14:13Для этого умные дядьки давным давно придумали unit of work — запрос к бд будет один.
Будет одна транзакция. ЗапросовUPDATEбудет несколько.
Ну или как минимум, завести какой-то Enum с перечислением всех полей доступных в данной сущности.
Естественно, можно много как организовать валидацию. Основной смысл не меняется.
… но он мне субъективно не нравится.
С таким аргументом и не поспоришь :)
greabock Автор
11.01.2019 15:01+1Будет одна транзакция. Запросов UPDATE будет несколько.
Вам нужно немного поработать с нормальной ORM(Datamapper) уровня Hibernate или Doctrine, чтобы понять, что это не так. У меня нет сил вам это доказывать.
Люди поработавшие с Doctrine в комментариях непременно меня опровергнут, если я не прав.
С таким аргументом и не поспоришь :)
Это было мнение. Аргументация была в следующем предложении

summerwind
11.01.2019 15:42В общем, ладно, я чувствую, что все советы не будут тут восприниматься, потому что вы заранее сконцентрировались на идее «сделать аналог метода PATH в REST», и только эта идея кажется вам логичной и красивой. Если вам нравится менять состояние БД через queries — на здоровье :)

SerafimArts
11.01.2019 16:09+1Там просто у greabock, насколько я могу припомнить, админка вся строится автоматически на уровне интроспекции сервера, по-этому всё так и сложно. Т.е. нужные унифицированные методы, которые позволяют автоматически строить интерфейс.
По-этому, подозреваю, и потребовалось уйти от красивой доменной модели и реализовать RESTful-подобную CRUD API.

rraderio
11.01.2019 13:43А что вы думаете о там решеиние для partial update?
medium.com/workflowgen/graphql-mutations-partial-updates-implementation-bff586bda989
greabock Автор
11.01.2019 14:01А там собственно таже проблема, которую я описал здесь и в прошлой статье. Она допускает значение null при апдейте. Это конечно можно проверить ручками внутри резолвера, но я искал решения, управлять этим поведением на уровне GraphQL.

SerafimArts
11.01.2019 15:46Думаю, что объяснять суть макросов никому не нужно...
А лучше бы объяснил. Я вообще даже представить не могу как оно там может решить проблему кривого полиморфизма в gql.
Лично у себя в проекте я добавил директиву на поля:
type Paginator { of: [Any!]! } type Example { users: Paginator @generic(field: "of" type: "User") }
Но работает оно через одно место, т.к. внедряется внутрь цикла компилятора, подменяя структуры, а значит всякие проверки и вывод типов может тут убиться об стену неконсистентными правилами LSP при имплементации интерфейсов.

greabock Автор
11.01.2019 16:35+1Шаблон макроса — просто handlepars-like строка или адрес шаблона в дот-нотации.
А дальше, просто подставляем аргументы в шаблон. Полученные строки подмешиваются в исходник, и схема пересобирается еще раз. Так себе решение, но работает.
SerafimArts
11.01.2019 16:40О, прикольная идея. Можно даже придумать на эту тему что-нибудь, вроде событий пре/постпроцессинга с инъекциями зависимостей. Это будет проще и понятнее большинству, чем вариант с патчем грамматики, который я предлагал.

Wriketeam
12.01.2019 19:24+2Подискутировать и поспорить можно в Питере 24 января на Piter GraphQL Meetup: habr.com/company/wrike/blog/435740

bookworm
13.01.2019 15:27+1Отличный материал, спасибо! Мои пробы с GraphQL меня озадачили несколько другими вещами. Было бы интересно услышать мнение практика.
Сразу упомяну стек, на котором пробовал: nodejs + mongodb
Проблема N+1. DataLoader, конечно, её решает почти полностью, особенно с кэшированием. Но в целом у меня сложилось впечатление, что GraphQL хорошо подойдет для нормализованной реляционной базы, подменяя собой join-ы (и уменьшая стоимость абстракций с помощью DataLoader). Но вот если в проекте, например, mongodb и документы набиты подколлекциями, возникает необходимость ограничивать выборку по полям. И, если, где-то в API всё-таки надо получить к ним доступ, то нужно делать другой тип в Query, резолвер которого уже не ограничен по полям при выборе из базы (например, не использует проекцию в mongo).
Почему не заложена возможность получить набор запрошенных полей в самом резолвере? Есть способы вынимания их из аргумента info, но те что я видел, не позволяют просто понять, какие поля относятся к какой ветке и какому уровню. Или я проглядел?
Я обратил внимание на GraphQL по нескольким причинам:
- нафиг эти статусы, методы и кучу эндпоинтов
- возможность собрать несколько запросов в один на фронте
- схема и автодокументирование
- возможность завернуть в другой транспорт вместо http
Но вот реализация на бэке мне кажется не столько гибкой, как хотелось бы, если я хочу снизить стоимость абстракций.
Как вариант, городить свою версию на JSON-RPC...

apapacy
13.01.2019 18:08Подсмотреть поля в запросе не предусмотрено но это можно сделать при помощи библиотеки graphql-list-fields Как это можно использовать для решения проблемы n+1 я сделал пример в посте habr.com/post/412847

bookworm
13.01.2019 18:20Да-да, я в курсе про DataLoader, в том числе, кажется, и по вашему материалу. Но DataLoader заточен на выборку по id и кэширование его так же. А если мы начнём делать выборки из БД с разными наборами полей, то кэширование нужно выключать. Да и прокинуть доп.переменные (в том числе и graphql-ный info объект, который нужен graphql-list-fields) непросто, потому что DataLoader не даёт передавать документы в .load/.loadMany. И это логично, но неудобно.
В сложных случаях весьма неудобно разбирать поля в graphql-list-fields.
apapacy
13.01.2019 18:48Существует ещё другой класс решений когда строится мост из graphql прямо в базу данных. То есть в этом случае graphql становится remotesql. У меня эти решения пока не вызывают сильного интереса и.к. и не очень безопасно это как мне кажется и нет гибкости то же по моему мнению со стороны.https://github.com/graphile/postgraphile
apapacy
Спасибо. Интересный материал. Меня больше смущают такие вот моменты с graphql — это отсутствие возможности кастомизировать ответы при ошибке и отсутствие встроенных средств по разграничению доступа. Я так понимаю что проще всего с ошибками это сделать в ответе к поле error. Но это как то сразу все усложняет хотя и так уже все усложнено
kabelsea
Для разграничения доступа никто не мешает использовать те же самые директивы… @hasRole(...)
madMxg
Про ошибки отлично расписано вот тут
Там же можно найти и ссылку на видео выступления по теме