В прошлом материале, мы рассмотрели неудобные моменты в системе типов 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-bff586bda989greabock Автор
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
Про ошибки отлично расписано вот тут
Там же можно найти и ссылку на видео выступления по теме