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



В прошлом материале, мы рассмотрели неудобные моменты в системе типов 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

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


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


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

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (22):

  1. apapacy
    /#19601468 / +1

    Спасибо. Интересный материал. Меня больше смущают такие вот моменты с graphql — это отсутствие возможности кастомизировать ответы при ошибке и отсутствие встроенных средств по разграничению доступа. Я так понимаю что проще всего с ошибками это сделать в ответе к поле error. Но это как то сразу все усложняет хотя и так уже все усложнено

    • kabelsea
      /#19601810

      Для разграничения доступа никто не мешает использовать те же самые директивы… @hasRole(...)

    • madMxg
      /#19601812 / +2

      Про ошибки отлично расписано вот тут
      Там же можно найти и ссылку на видео выступления по теме

  2. summerwind
    /#19602638

    Теперь давайте все-таки отложим в сторону REST с его различиями null\undefined и попробуем посмотреть на мутации как на функции в языках программирования. И, вместо вашего оригинального технического решения, которое меняет состояние БД в query-запросах (причем, каждое поле в отдельном запросе к БД), можно решить задачу, например, так:

    # Создаем input, где все поля - опциональные
    
    input ExampleOptionalInput {
      foo: String
      bar: String
    }
    
    # Создаем мутацию с доп. аргументом
    
    type Mutation {
      updateExampleOptionally(input: ExampleOptionalInput!, onlyFields: [String!]): Example
    }
    

    • greabock
      /#19602746

      причем, каждое поле в отдельном запросе к БД

      Для этого умные дядьки давным давно придумали unit of work — запрос к бд будет один. Я очень ответственно подхожу к вопросу лишних запросов.


      Я думал о предложенном вами варианте. Но это порождает запросы с неопределенной структурой — в вашем варианте в onlyFields можно передавать что угодно. Ну или как минимум, завести какой-то Enum с перечислением всех полей доступных в данной сущности.


       onlyFields: [ExampleInputField!]

      В общем, этот вариант будет работать, но он мне субъективно не нравится.
      Потому что у нас получается два источника истины: один — это список полей самой сущности, второй — это Enum перечисляющий поля.

      • summerwind
        /#19602842

        Для этого умные дядьки давным давно придумали unit of work — запрос к бд будет один.

        Будет одна транзакция. Запросов UPDATE будет несколько.

        Ну или как минимум, завести какой-то Enum с перечислением всех полей доступных в данной сущности.

        Естественно, можно много как организовать валидацию. Основной смысл не меняется.

        … но он мне субъективно не нравится.

        С таким аргументом и не поспоришь :)

        • greabock
          /#19603126 / +1

          Будет одна транзакция. Запросов UPDATE будет несколько.

          Вам нужно немного поработать с нормальной ORM(Datamapper) уровня Hibernate или Doctrine, чтобы понять, что это не так. У меня нет сил вам это доказывать.
          Люди поработавшие с Doctrine в комментариях непременно меня опровергнут, если я не прав.


          С таким аргументом и не поспоришь :)

          Это было мнение. Аргументация была в следующем предложении

          • summerwind
            /#19603276

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

            • SerafimArts
              /#19603424 / +1

              Там просто у greabock, насколько я могу припомнить, админка вся строится автоматически на уровне интроспекции сервера, по-этому всё так и сложно. Т.е. нужные унифицированные методы, которые позволяют автоматически строить интерфейс.


              По-этому, подозреваю, и потребовалось уйти от красивой доменной модели и реализовать RESTful-подобную CRUD API.

              • rraderio
                /#19603532

                админка вся строится автоматически на уровне интроспекции сервера
                Можно об этом по подробнее?

                • greabock
                  /#19603558

                  Обязательно напишу, но после разбора авторизации.
                  А вообще, вот эта штука работает схожим образом, на сколько я могу понимать.

  3. rraderio
    /#19602688

    А что вы думаете о там решеиние для partial update?

    medium.com/workflowgen/graphql-mutations-partial-updates-implementation-bff586bda989

    • greabock
      /#19602768

      А там собственно таже проблема, которую я описал здесь и в прошлой статье. Она допускает значение null при апдейте. Это конечно можно проверить ручками внутри резолвера, но я искал решения, управлять этим поведением на уровне GraphQL.

  4. SerafimArts
    /#19603304

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

    А лучше бы объяснил. Я вообще даже представить не могу как оно там может решить проблему кривого полиморфизма в gql.


    Лично у себя в проекте я добавил директиву на поля:


    type Paginator {
        of: [Any!]!
    }
    
    type Example {
        users: Paginator @generic(field: "of" type: "User")
    }

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

    • greabock
      /#19603542 / +1

      Шаблон макроса — просто handlepars-like строка или адрес шаблона в дот-нотации.
      А дальше, просто подставляем аргументы в шаблон. Полученные строки подмешиваются в исходник, и схема пересобирается еще раз. Так себе решение, но работает.

      • SerafimArts
        /#19603572

        О, прикольная идея. Можно даже придумать на эту тему что-нибудь, вроде событий пре/постпроцессинга с инъекциями зависимостей. Это будет проще и понятнее большинству, чем вариант с патчем грамматики, который я предлагал.

  5. Wriketeam
    /#19606566 / +2

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

  6. bookworm
    /#19608278 / +1

    Отличный материал, спасибо! Мои пробы с GraphQL меня озадачили несколько другими вещами. Было бы интересно услышать мнение практика.


    Сразу упомяну стек, на котором пробовал: nodejs + mongodb


    Проблема N+1. DataLoader, конечно, её решает почти полностью, особенно с кэшированием. Но в целом у меня сложилось впечатление, что GraphQL хорошо подойдет для нормализованной реляционной базы, подменяя собой join-ы (и уменьшая стоимость абстракций с помощью DataLoader). Но вот если в проекте, например, mongodb и документы набиты подколлекциями, возникает необходимость ограничивать выборку по полям. И, если, где-то в API всё-таки надо получить к ним доступ, то нужно делать другой тип в Query, резолвер которого уже не ограничен по полям при выборе из базы (например, не использует проекцию в mongo).


    Почему не заложена возможность получить набор запрошенных полей в самом резолвере? Есть способы вынимания их из аргумента info, но те что я видел, не позволяют просто понять, какие поля относятся к какой ветке и какому уровню. Или я проглядел?


    Я обратил внимание на GraphQL по нескольким причинам:


    • нафиг эти статусы, методы и кучу эндпоинтов
    • возможность собрать несколько запросов в один на фронте
    • схема и автодокументирование
    • возможность завернуть в другой транспорт вместо http

    Но вот реализация на бэке мне кажется не столько гибкой, как хотелось бы, если я хочу снизить стоимость абстракций.


    Как вариант, городить свою версию на JSON-RPC...

    • apapacy
      /#19608586

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

      • bookworm
        /#19608598

        Да-да, я в курсе про DataLoader, в том числе, кажется, и по вашему материалу. Но DataLoader заточен на выборку по id и кэширование его так же. А если мы начнём делать выборки из БД с разными наборами полей, то кэширование нужно выключать. Да и прокинуть доп.переменные (в том числе и graphql-ный info объект, который нужен graphql-list-fields) непросто, потому что DataLoader не даёт передавать документы в .load/.loadMany. И это логично, но неудобно.

        В сложных случаях весьма неудобно разбирать поля в graphql-list-fields.

        • apapacy
          /#19608686

          Существует ещё другой класс решений когда строится мост из graphql прямо в базу данных. То есть в этом случае graphql становится remotesql. У меня эти решения пока не вызывают сильного интереса и.к. и не очень безопасно это как мне кажется и нет гибкости то же по моему мнению со стороны.https://github.com/graphile/postgraphile