Наверное, не существует идеальных технологий. Не является исключением и graphql. Если у Вас еще не было опыта работы с этой технологией, то нужно хорошо представлять, какие проблемы могут у Вас возникнуть и заблаговременно к ним подготовиться.

Для начала скажу, что я скорее сторонник, чем противник, применения graphql кругом, где это только возможно. И совершенно не собираюсь разубеждать кого-нибудь в целесообразности применения этой технологии. И именно поэтому поднимаю вопросы, которые относятся к нерешенным вопросам в рамках технологии graphql.

Например, для кого-то может быть неожиданным, что каждый объект в graphql придется описывать минимум дважды: один раз в качестве возвращаемого типа объекта, и еще один раз в качестве input типа объекта (см. graphql.org/graphql-js/mutations-and-input-types). Впрочем, это я рассказал для начала и даже не считаю существенным недостатком. Сегодня речь пойдет о таких вопросах, которые, как правило, приходится решать, разрабатывая приложение с применением graphql технологии:

  1. Разделение доступа для пользователей и групп пользователей.
  2. Обработка ошибок.
  3. Проблема SELECT N + 1

Разделение доступа для пользователей и групп пользователей


graphql вообще ничего не знает о разделении доступа для пользователей и групп. Таким образом, вся работа по разделению доступа на ответственности разработчика приложения. В функцию-резольвер третьим параметром передается объект контекста приложения. Поэтому, если Вы, например, работаете с реализацией graphql JavaScript+express, то в параметре контекста Вы будете возможность получить текущего пользователя из объекта request express.js. Но дальнейшая работа по разграничению доступа должна проводиться непосредственно в каждом резольвере:

function(root, {id}, ctx) {
   return DB.Lists.get(id)
     .then( list => {
       if(list.owner_id && list.owner_id != ctx.userId){
         throw new Error("Not authorized to see this list");
       } else {
         return list;
       }
    });
}

Естественно, такой подход усложняет контроль прав доступа, т.к. нет возможности задавать права доступа в декларативной манере и контроль прав рассредоточен по десяткам (для некоторых больших систем по тысячам) функциям-резольверам. Поэтому существует целый ряд библиотек, которые решают эту проблему. Некоторые из них достаточно популярны (судя по количеству звезд на github.com), например github.com/maticzav/graphql-shield.

Обработка ошибок


Если Ваш фронтенд требует валидацию ввода и формирование подробных сообщений для каждого поля, не прошедшего валидацию, то обработка ошибок в graphql, скорее всего, покажется Вам недостаточно гибкой. Например, если входной параметр был описан как строка, а пришло числовое значение, то сообщение об ошибке будет мало подходящим для этого:

{
  "errors": [
    {
      "message": "Expected type String, found 1.",
      "locations": [
        {
          "line": 2,
          "column": 15
        }
      ]
    }
  ]
}

Если есть грубая ошибка в типе входного параметра, то сообщение об ошибке будет генерироваться автоматически и контролировать это процесс нет возможности. Если валидация по типу входного параметра прошла успешно, то есть возможность отправить клиенту кастомное сообщение об ошибке, выбросив объект new Error ('custom message ...'). Добавить кастомные поля к объекту ошибки не получится (кастомизация ошибки реализована в библиотеках apollo-server-express и apollo-errors при совместном их использовании). Разумеется, всегда есть возможность сериализовать объект в строку message на сервере и десериализовать на клиенте. Но нужно ли так поступать?

Проблема SELECT N + 1


Эта проблема была подробно рассмотрена в сообщении.

graphql построен на функциях-резольверах. Это означает, что выборка данных из базы данных может порождать проблему, которая называется SELECT N+1. Предположим что в функции-резольвере был получен список объектов, в котором связанные с этим объектом данные представлены идентификаторами (внешними ключами). Для каждого такого идентификатора будет вызвана своя функиця-резольвер, в которой (в каждой) будет дополнительно сделан запрос к базе данных. Таким образом, вместо одного запроса к базе данных (с SQL JOIN) будет выполнено много запросов, что перегружает базу данных запросами.

Для решения этой проблемы facebook разработал библиотеку github.com/graphql/dataloader, которая использует стратегию отложенного запроса. Вместо выполнения запроса непосредственно в фунции-резольвере, предлагается накапливать идентификаторы (вторичные ключи) в массиве, после чего получать их сразу одним запросом.

apapacy@gmail.com
13 мая 2019 года

Комментарии (33)


  1. GHostly_FOX
    13.05.2019 05:26

    Ожидал что-то прям глобальное.
    1. Это решается на уровне приклада, тут можно придумать множество или своих реализаций по распределению доступа, или использовать готовые реализации.

    2. С обработкой ошибок и правда немного сложновато… но это издержки жесткой типизации данных. Для этого GraphQL умеет отдавать свою текущую структуру говоря какое поле какого типа чтобы программист не допускал подобных ошибок.

    3. Для решения данной проблемы не обязательно использовать библиотеку от Facebook. Тут опять же все зависит от фантазии программиста. Тут даже есть хорошая статья на эту тему — habr.com/ru/post/329408


    1. apapacy Автор
      13.05.2019 07:22

      не обязательно использовать библиотеку facebook

      В указанной Вами статье и пользуется библиотека graphql-php в которой реализован функционлал со ссылкой на аналогичное решение dataloader от facebook. webonyx.github.io/graphql-php/data-fetching/#solving-n1-problem Ничего принципиально другого там не реализовано.

      В обсуждениях возникала ссылка на эликсировскую библиотеку github.com/absinthe-graphql/absinthe которая якобы откладывает запрос более кардинальным образом и генерирует единый запрос с SQL JOIN. Но подтвердить это не могу.


  1. apapacy Автор
    13.05.2019 07:21

    deleted


  1. kolkoni
    13.05.2019 07:59

    GraphQL придуман как замена REST-у, и то, не для всего, а только там, где нужно удобно запросить кучу разных данных без over и under фетчинга (обычно для построения навороченного интерфейса).
    1. Он не должен решать проблему авторизации. Хотите авторизацию, пишите свои или используйте готовые механизмы.
    2. Так же как и в REST-е можно вернуть нужный текст ошибки, только вместо кодов ошибки есть типы ошибок — ошибка GraphQL или Network ошибка. Что ещё нужно?
    3. Ну используй DataLoader-ы.
    По поводу JOIN-ов — ну извините, одна дата модель-один запрос, по другому будут костыли.
    К тому же у 99% пользователей никаких проблем с этим не будет.
    Оставшийся 1% — не используйте графкл или переходите с sql на графовую базу, как у фейсбука.


    1. apapacy Автор
      13.05.2019 08:16

      вместо кодов ошибок есть типы ошибок

      Ничего подобного в спецификации graphql нет см graphql.github.io/graphql-spec/June2018/#sec-Errors
      Кроме message ещё можно узнать путь и номер строки. Тип ошибки это уже в реализациях сервера и клиента. Если использовать apollo-server то можно ещё много чего добавить в ошибку. Но только если дело дошло до резольвера. Если ошибка была на этапе валидации ее содержание будет стандартное.


      1. kolkoni
        13.05.2019 08:37

        Тоже верно…
        Но это не меняет того факта, что их не сложнее обрабатывать, чем REST-овые.


  1. apapacy Автор
    13.05.2019 08:15

    deleted


  1. alexesDev
    13.05.2019 09:31
    +1

    Разделение доступа запросто решается через роли в базах типа postgres/oracle, у которых есть row level security. Это старый проверенный механизм, который лучше большинства поделок. Главное перестать бояться пользоваться базой по полной.

    Проблема SELECT N + 1 решена в таких штуках как join-monster, postgraphile, prisma и тп. Они даже полей лишних не выбирают. Но dataloader тоже не плох.


    1. RidgeA
      13.05.2019 11:44

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


      1. alexesDev
        13.05.2019 14:28

        Смотря что нужно. Для in-house софта можно и так, но никогда так не делал.

        А так завожу пользователя под каждую роль, условно admin/user/guest + current_setting('user_id')::int (set role, set user_id перед запросом). Это подход не лично мой, я узнал его из доков postgraphile/postgREST и начал использовать везде — удобно.

        Т.е роли для грубой настройки и where/policies для тонкой

        create view my_posts as
          select *
            from posts
           where author_id = current_setting('user_id');
        
        grant select on my_posts to my_app_user;
        grant delete on my_posts to my_app_admin;

        С row level security чуть сложнее, но еще точнее можно. И еще раз — смысл в том, что это написанный инструмент, который не зависит от языка, а сейчас пишут на чем удобно и крайне больно из условного монолита вытаскивать настройки безопасности, а так они на уровне базы и все просто.


        1. RidgeA
          13.05.2019 14:41

          Спасибо за ответ.

          > Для in-house софта можно и так, но никогда так не делал.
          это понятно, для 3.5 пользователей можно так, а для более-менее приличного портала, уже накладно

          > А так завожу пользователя под каждую роль, условно admin/user/guest + current_setting('user_id')::int (set role, set user_id перед запросом).

          т.е. перед каждым запросом создается роль? Я не силен в PG, хочу для себя понять

          > И еще раз — смысл в том, что это написанный инструмент, который не зависит от языка, а сейчас пишут на чем удобно и крайне больно из условного монолита вытаскивать настройки безопасности, а так они на уровне базы и все просто.

          Ну тут зависимость все равно остается — просто это зависимость от БД. Свои плюсы и минусы.
          К.т. такой подход, как по мне, немного ограничивает в возможностях, какую-то сложную логику, завязанную на условное полнолуние (как, бывает, хотят) уже не получится сделать


          1. alexesDev
            13.05.2019 15:00

            У pg user и role синоним. Роли могут наследоваться.

            > это понятно, для 3.5 пользователей можно так, а для более-менее приличного портала, уже накладно
            Это имеет смысл, если нет четких классических ролей (к примеру менеджеру из одного отдела разрешили смотреть данные другого). Бываю ситуации, тут на месте смотреть стоит.

            > т.е. перед каждым запросом создается роль? Я не силен в PG, хочу для себя понять
            У пользователя в таблице хранится поле role и перед каждым запросом пользователя делается

            -- какие-то "переменные" на которые опирается логика ограничений
            set role to 'my_app_user';
            select set_config('app.user_id', '1000');
            
            -- сам запрос
            select * from my_posts;


            > немного ограничивает в возможностях, какую-то сложную логику
            Ограничивает, но достаточные сложные кейсы все равно возможно. В таком месте в последнюю очередь хочется много ограничений.

            Без проблем делаются ограничения вида «этот пользователь может удалять любые коментарии в своих постах, если они не от админа».


            1. RidgeA
              13.05.2019 15:32

              Спасибо за разъяснение


  1. theasmoth
    13.05.2019 10:12
    +1

    использовал graphql с mongodb и graphql-compose (автор nod)
    1ый пункт решается хуками, 3ий wrapResolve'ом и небольшой реализацией жадной загрузки
    вцелом все очень гибко и удобно


  1. Raspy
    13.05.2019 14:22

    Ещё забыли упомянуть, что при переезде на граф куэль вы получаете проблемы с кешированием, иерархическими структурами и EAV моделями, что очень часто встречается в энтерпрайзе. По факту граф куэль подходит для небольших сайтиков/приложений с очень простой и плоской моделью данных (как бы смешно не звучало, но тот же фейсбук имеет очень простую датамодель, кто работал с их графАпи подтвердит).


    1. GHostly_FOX
      13.05.2019 18:11

      Ну не соглашусь.
      Мы сделали на GraphQL сервисы для получения данных о гос.закупках.
      Миллионы записей, высокая нагрузка, большое количество запросов на чтение/поиск данных.


      1. Raspy
        13.05.2019 19:29
        +1

        Быстрые и мощные сервера не решают проблем с отсутствием кеширования (кеширование на уровне http-сервера это насмешка), иерархичности и EAV моделей.


    1. rraderio
      14.05.2019 00:16

      Какие проблемы с кешированием?


    1. kolkoni
      14.05.2019 07:44

      Кешированием чего именно? Опишите конкретную ситуацию.
      GraphQL возвращает актуальную выборку данных из базы (или из кеша, если он есть, тут как напишите).

      Пример проблемы с иерархической структурой, пожалуйста?

      При чем тут EAV? Не путайте модель данных и инструмент для получения данных. Модель пилите как хотите, а GraphQL помогает просто удобно выдернуть из неё данные.

      Вот ссылка, посмотрите схему того же GitHub-а.
      Дата модель под капотом 100% простецкая, а вот GraphQL схема совсем другое дело.
      graphql-voyager

      Вы кажется совсем не понимаете что такое GraphQL. Он не должен дублировать Ваши дата модели.
      GraphQL помогает крутить, вертеть, перемешивать дата модели, запрашивать связанные поля и смежные данные или наоборот убирать ненужные поля и всё это в одном запросе.


      1. apapacy Автор
        14.05.2019 08:25

        Автор предыдущего комментарии натолкнул на размышления.
        1. Иерархические структуры. Самый тривиальный случай для сайта. Категории продуктов и подкатегории с заранее неизвестными уровнем вложенности. Действительно получается что нельзя запросить рекурсивно. Только задавая заранее фиксированный уровень вложенности или же преобразовывать в массив. Вот Вам и GRAPHql
        2. Тоже тривиальный случай для любого сайта. Характеристики товаров. Фактически объекты с зараннее неизвестной структурой. Тут соглашусь что проблем больших не будет. Т.к. свойства можно хранить в массиве с постоянной структурой как собственно eav и хранится в реляционных базах данных например name, value,unit


        1. kolkoni
          14.05.2019 09:45

          Тип Category с полем Subcategory у которого тоже тип Category.
          В резолвере поля Subcategory берется параметр source(родитель) и по source.id выдергиваются из базы все подкатегории.
          Засовываем это всё в Dataloader и запрос вида:


          category {
              title
              subcategory {
                  title
                  subcategory {
                      title
                  }
              }
          }

          Превращается в 2 запроса в базу, который выдернет все категории с нужными id-шниками под каждый подзапрос.


          Что Вы собираетесь запрашивать рекурсивно и пример из реальной жизни, где это используется?
          Вот запрос сверху который выведет все категории, подкатегории и подподкатегории.


          Что значит заранее неизвестный уровень вложенности. Снаружи АПИ человек всегда знает что он запрашивает. Или Вы даете апи, где можно рекурсивно все данные запросить и положить бэк сотней запросов?


          1. apapacy Автор
            14.05.2019 10:17

            То чтобы была проверка скажем на отсутствие циклов это уже второй вопрос. И то как получить структуру в резольвера это уже третий вопрос и он тривиальный. Но в ответ будет возвращена только только та иерархические с тем уровнем вложенности которая запрашивалась. То что можно заранее сделать запрос с фиксированной вложенности разумной для данной задачи или же отдавать структуру так как она хранится в реляционной базе данных парами родитель-потомок это уже направлено на преодоление того что GRAPHql не работает с иерархическими данными.


            1. kolkoni
              14.05.2019 10:20

              То есть конкретных примеров и ответа на свой вопрос Я не получу?


            1. kolkoni
              14.05.2019 10:27

              К тому же никто не мешает сделать запрос вида:
              Categories { id, title, parentId }
              и получить всю структуру иерархии.


              1. apapacy Автор
                14.05.2019 12:06

                По Вашему примеру я в предыдущем комменте это озвучил

                или же отдавать структуру так как она хранится в реляционной базе данных парами родитель-потомок
                — то есть ничего не мешает.

                По конкретным ответам на все вопрсоы постараюсь сейчас сделать это:

                Что Вы собираетесь запрашивать рекурсивно и пример из реальной жизни, где это используется?


                В реальной жизни, иерархические структуры с заранее неопределенным количеством уровней встречаются повсеместно. Например:
                1. Организационная структура предприятия, организации, учреждения.
                2. Состав промышленного изделия
                3. Декомпозиция задач
                4. Граф социальных связей
                5. Граф комментариев Хабра-Хабра

                Что значит заранее неизвестный уровень вложенности.


                Означает то что при разработке системы нет способа определить максимальное количество уровней вложенности. Например в организации на момент был максимальный уровень иерархии 7. После создания филиала — стал 8.
                В текущий номенклатуры были промышленного изделия с максимальной глубиной иерархии 12. Через неделю пришла конструкторская документация с глубиной иерархии 34.

                Или Вы даете апи, где можно рекурсивно все данные запросить и положить бэк сотней запросов?


                Структура с уровнем вложенности например 100 (предположим это промышленное изделие) может занимать меньше памяти и проходить быстрее чем выборка данных с описаниями и характеристиками по одному товару. На фронтенде разработчик знает что структура рекурсивная и для этого использует рекурсивные компоненты для реализации интерфейса.


                1. kolkoni
                  14.05.2019 12:48

                  1. Организационная структура предприятия, организации, учреждения.
                  2. Состав промышленного изделия
                  3. Декомпозиция задач
                  4. Граф социальных связей
                  5. Граф комментариев Хабра-Хабра

                  И в чем проблема?
                  Хочешь вывести всё, запрашивай ключ родитель и строй иерархию на фронте.
                  Запроси данные из базы, сгруппируй на бэке и отдай иерархичный JSON на фронт.


                  Вы видите какое то другое решение?
                  И при чем тут вообще GraphQL? Проблему иерархии REST-ом решается?


                  Означает то что при разработке системы нет способа определить максимальное количество уровней вложенности. Например в организации на момент был максимальный уровень иерархии 7. После создания филиала — стал 8.
                  В текущий номенклатуры были промышленного изделия с максимальной глубиной иерархии 12. Через неделю пришла конструкторская документация с глубиной иерархии 34.

                  Не согласен, наоборот, при разработке системы ЗАКЛАДЫВАЕТСЯ возможность неограниченной вложенности. И с такими данными работают через поле parentId. Ну или кучей подзапросов, если нужны не все данные.


                  Структура с уровнем вложенности например 100 (предположим это промышленное изделие).
                  На фронтенде разработчик знает что структура рекурсивная и для этого использует рекурсивные компоненты для реализации интерфейса.

                  Вы хотите сказать на сервер улетит 100 запросов и затем 100 запросов улетит в БД? Это же бред.


                  Не лучше ли сделать 1 запрос на сервер, а уже на сервере или 100 запросов улетит в БД или из БД возмёт все записи и отфильтрует их.
                  Тут по другому никак.


                  1. apapacy Автор
                    14.05.2019 14:35
                    +1

                    И при чем тут вообще GraphQL? Проблему иерархии REST-ом решается?

                    Я не противопоставляю graphql REST-api. Но, к слову сказать, REST-api может отдавать произвольную в том числе и иерархическую структуру данных с произвольным количеством уровней.

                    Не согласен, наоборот, при разработке системы ЗАКЛАДЫВАЕТСЯ возможность неограниченной вложенности. И с такими данными работают через поле parentId. Ну или кучей подзапросов, если нужны не все данные.


                    Никто и не утверждает иного. Говорилось о том что graphql не может сформировать объект в явном виде с неограниченной вложенностью. Т.к. graphql возвращает вложенность согласно вложенности в запросе. То что это ограничение можно преодолеть возвращая плоскую структуру данных из которой строить дерево на клиенте не делает graphql заточенным под обработку иерархических структур данных.

                    Вы хотите сказать на сервер улетит 100 запросов и затем 100 запросов улетит в БД? Это же бред.
                    Не лучше ли сделать 1 запрос на сервер, а уже на сервере или 100 запросов улетит в БД или из БД возмёт все записи и отфильтрует их.
                    Тут по другому никак.

                    Кто говорил о ста запросах в базу данных. Сто это количество уровней иерархии. Один запрос с клиента с идентификатором рута. По этому запрос один запрос в базу данных, если база данных поддерживает функционал рекурсивных запросов (это или граф-ориентированные базы или тот же postgres). Формирует ответ который кстати можно передать резольверу. Проблема в том что резольвер все уровни иерархии, которые не пришли в запросе просто не возьмет во внимание.


                    1. kolkoni
                      14.05.2019 14:58
                      +1

                      Я не противопоставляю graphql REST-api.

                      Так GraphQL это технология для замены REST-а. Это не фреймворк какой то, это не база данных, это удобный инструмент получения данных на фронте с бека.


                      Но, к слову сказать, REST-api может отдавать произвольную в том числе и иерархическую структуру данных с произвольным количеством уровней.

                      Так и GraphQL позволяет. Только если это произвольная вложенность, то тут статической типизации не сделаешь и по факту будет отдаваться скалярный тип JSON.


                      Говорилось о том что graphql не может сформировать объект в явном виде с неограниченной вложенностью. Т.к. graphql возвращает вложенность согласно вложенности в запросе.

                      И снова, можно вернуть JSON со сколь угодно глубокой вложенностью, только теряется одна из основных фишек — строгая статическая типизация ответа.


                      Вот пример, если Вы хотите получить какой то объект, не типизированный, с бесконечной вложенностью, без parentId.


                      Articles { Title String, Body String, Comments JSON }


                      Формирует ответ который кстати можно передать резольверу.

                      Делаем из него JSON и возвращаем клиенту.
                      Вот только нафига это надо.


                      Хотите на фронте построить какую то иерархию, делайте запрос с получением parentId и выстраиваете иерархию.


                      Например файловые менеджеры на фронте так и делают, только вместо parent-а у них путь.
                      Сложная менюшка или примеры приведенные выше, всё делается также.


                      1. apapacy Автор
                        14.05.2019 16:10

                        А это кстати тоже вариант с json


    1. apapacy Автор
      14.05.2019 08:39

      По иерархические структурам согласен. Рекурсивно запросить не получится. Но вот по eav тут я не заметил проблему. Можно отдать в типизированном массиве name, value, unit


  1. constb
    14.05.2019 06:54
    +1

    Добавить кастомные поля к объекту ошибки не получится

    кидать GraphQLError не пробовали? в сочетании с formatError вполне работает. пакет – самый обычный ванильный graphql из npm.


    1. apapacy Автор
      14.05.2019 08:06

      Спасибо. Не знал об этом. Добавлю в текст сообщения пример. Все равно проблема не решается на все 100% тк если ошибка слишком грубая по типу значения число вместо строки или отсутствует обязательное значение то до резольвера дело не доходит. Или есть возможность и в этом случае кастомизируктся ответ?


      1. constb
        14.05.2019 11:28
        +1

        у конструктора GraphQLError используется параметр extensions. в одном проекте используем такую обёртку:


        class GqlError extends GraphQLError {
            constructor(params) {
                if (!params) {
                    throw new Error('Cant construct GqlError without params')
                }
                const { message, code, context = {} } = params
                super(message, null, null, null, null, null, { code, context, errorId: uuid() })
            }
        }

        Остальные классы ошибок наследуются от этого, и там уже конструкторы принимают свои параметры и заворачивают всё как нужно в контекст… вроде до клиента всё долетает…