[Что не так с GraphQL]… И как с этим бороться

в 5:31, , рубрики: api, graphql, graphql query, graphql-client, graphql-server, Разработка веб-сайтов, разработка мобильных приложений

В прошлом материале, мы рассмотрели неудобные моменты в системе типов GraphQL.
А теперь мы попробуем победить некоторые из них. Всех заинтересованных, прошу под кат.

Нумерация разделов соответствует тем проблемам, с которыми мне удалось справится.

1.2 NON_NULL INPUT

В этом пункте, мы рассмотрели неоднозначность, которую порождает особенность реализации nullable в GraphQL.

А проблема в том, что это не позволяет с наскока реализовать концепцию частичного обновления (partial update) — аналог HTTP-метода PATCH в архитектуре REST. В комментариях к прошлому материалу меня сильно критиковали за "REST"-мышление. Я же скажу лишь то, что к этому меня обязывает CRUD архитектура. И я не был готов отказываться от преимуществ REST, просто потому, что "не делай так". Да и решение данной проблемы нашлось.

И так, вернемся к проблеме. Как мы все знаем, сценарий работы CRUD, при обновлении записи выглядит так:

  1. Получили запись с бэка.
  2. Отредактировали поля записи.
  3. Отправили запись на бэк.

Концепция 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

Думаю, что объяснять суть макросов никому не нужно...

На этом пока всё. Не думаю, что этот материал вызовет столько же шума, как прошлый. Всё таки заголовок там был довольно "желтым" )

В комментариях к прошлому материалуе топили за разделение доступа… значит следующий материал будет об авторизации.

Автор: greabock

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js