Как мы приготовили массу блюд c помощью одного ингредиента: GraphQL

Всем привет! Не говорите, что вам нечего приготовить, если у вас дома одна картошка. При смекалке и достаточном количестве специй — пюре, драники, запеканка, чипсы фри… Конечно, мы обсуждаем не кулинарию. Мне хотелось бы поговорить о GraphQL и некоторых фичах, которые мы внедрили у себя в компании, и тиражируем на различные проекты, где используется этот язык запросов.

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

3299f428498dddb295d9f1a7017dd3ce.png

GraphQL: внедрение и советы

GraphQL — это язык запросов с открытым исходным кодом.

Хотелось бы выделить три основные характеристики языка:

  1. Позволяет клиенту точно указать, какие данные ему нужны;

  2. Облегчает агрегацию данных из нескольких источников;

  3. Использует систему типов для описания данных.

91afef13fded9b75d98ad6dc2cf6d34b.png

На картинке выше указан граф, состоящий из четырех типов: users, rubrics, news, comment. Но между ними множество взаимосвязей, более 10-и. Таким образом, описав четыре типа данных, мы получаем большую вариативность.

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

Процесс установки зависимостей достаточно хорошо описан в документации, поэтому сильно заострять на этом внимание не будем.

За время использования этой технологии у нас появились свои must have правила/методы, которые успешно тиражируются и применяются на различных проектах и информационных системах. Давайте поговорим о них.

Хотелось бы отметить, что код, который будет представлен ниже:

  • написан на чистом PHP без каких-либо обраток данных, входных/выходных параметров и т.д.;

  • носит информативный характер и показывает принцип внедрения/использования какого-либо функционала;

  • данные примеры нельзя использовать в информационных системах в чистом виде.

Базовая структура

В GraphQL есть три корневых типа данных:

  • MutationType — используется для изменения информации

  • QueryType — используется для получения информации

  • SubscriptionType — используется для подписки на события, но он реализован только для «реактивных» библиотек PHP или PHP > 8.1 (актуально для библиотеки). В данной статье мы его рассматривать не будем. У себя в компании мы используем другие механизмы подписок, реализованные через различные шины данных, брокеры сообщений и т.д.

Мы используем структуру ниже:

5901dec76ad5670a74c73e8601981634.png

Входной точкой является файл /graphql/index.php с приблизительным содержанием:

 Types::query(),
        'mutation' => Types::mutation()
    ]);
    
    //Обрабатываем запрос
    $result = GraphQL::executeQuery($schema, $query);
    $arException = $result->toArray()['errors'] ?: [];

//Обрабатываем ошибки
} catch (Throwable $e) {
    $result = ['error' => ['message' => $e->getMessage()]];
} finally {
    //Производим логирование
    Log::push($query, $arException);
}

//Выводим ответ
header('Content-Type: application/json; charset=UTF-8');
echo json_encode($result);

Для создания своего типа, как правило, достаточно:

  • описать запрос;

  • зарегистрировать его;

  • описать возвращаемые поля;

  • реализовать взаимодействие с хранилищем.

Давайте опишем свой тип, в дальнейшем мы будем ориентироваться, в основном на новости (news) и рубрики (rubrics). Начнем с новостей.

Опишем запрос news:

 function () {
                return [
                    'news' => [ //Добавляем query запрос news
                        'type' => Types::news(), // Создаем новый тип данных
                        'args' => [
                            'id' => Types::int(), // Добавляем аргумент, по которому мы можем получить новость
                        ],
                        'resolve' => function ($root, $args, $context, $info) {
                            return News::get($args); // Добавляем метод get для получения результата запроса
                        }
                    ]
                ];
            }
        ];
        parent::__construct($config);
    }
}

Конструктор класса «QueryType» содержит в себе все подтипы «query» запросов, и он должен быть наследован от «ObjectType», что позволяет создавать свои составные типы данных.

 Зарегистрируем тип news:

Опишем возвращаемые поля для типа news:

 function () {
                return [ //описали поля таблицы и указали их типы
                    'id' => [
                        'type' => Types::int(),//id новости
                    ],
                    'name' => [
                        'type' => Types::string(),//имя новости
                    ],
                    'text' => [
                        'type' => Types::string(),//текст новости
                    ],
                ];
            }
        ];
        parent::__construct($config);
    }
}

Реализуем взаимодействие с хранилищем:

query("SELECT * from news WHERE id = {$args['id']}")[0];
    }
}

Проделав операции выше, мы можем проверить работоспособность, послав запрос на сервер и указав необходимую сущность и набор требуемых полей:

67f87f9253b6315c974ca54f512eeb64.png

Так как GraphQL — это умный endpoint, то давайте проверим его работоспособность на другом наборе полей. Например, получим только id и name новости. Сразу скажу, это работает!) Скриншот ниже.

d63dfd7d6795bf26f5c032b12b239c09.png

Таким образом, мы убедились, что GraphQL действительно позволяет указать и возвращать только те данные, которые необходимы «потребителю».

Производительность: составной запрос (One step)

Как и говорили ранее, новость имеет различные взаимосвязи, в том и числе и с рубрикой.

Давайте опишем запрос для типа rubric, он аналогичен описанию типа news:

 function () {
                return [
                    'news' => [/*...*/],
                    'rubric' => [ //Добавили еще один запрос
                        'type' => Types::rubric(), // Создаем новый тип данных - рубрика
                        'args' => [
                            'id' => Types::int(), // Добавили аргумент, по которому мы можем получить рубрику
                        ],
                        'resolve' => function ($root, $args, $context, $info) {
                            return Rubric::get($args); // Добавляем метод get для получения результата запроса
                        }
                    ]
                ];
            }
        ];
        parent::__construct($config);
    }
}

Зарегистрируем его:

Опишем возвращаемые поля:

 function () {
                return [ //описали поля таблицы и указали их типы
                    'id' => [//id рубрики
                        'type' => Types::int(),
                    ],
                    'name' => [//наименование рубрики
                        'type' => Types::string(),
                    ],
                    'code' => [//символьный код рубрики
                        'type' => Types::string(),
                    ],
                ];
            }
        ];
        parent::__construct($config);
    }
}

Реализуем взаимодействие с хранилищем:

query("SELECT * from rubrics WHERE id = {$args['id']}")[0];
    }
}

Для того, чтобы нам сопоставить новость с рубрикой, нам необходимо добавить в список возвращаемых полей идентификатор рубрики:

 function () {
                return [
                    'id' => [
                        'type' => Types::int(),
                    ],
                    'name' => [
                        'type' => Types::string(),
                    ],
                    'text' => [
                        'type' => Types::string(),
                    ],
                    'rubric' => [
                        'type' => Types::Rubric(), //добавляем тип для возврата id рубрик
                    ],
                ];
            }
        ];
        parent::__construct($config);
    }
}

Давайте попробуем сделать запрос. Добавляем в наш query запрос слово «rubric» и видим, что нам возвращается id рубрики:

276f642ee01f5877b583d5ba8842f2bf.png

Но, чтобы получить информацию по самой рубрике, нам необходимо сделать дополнительный запрос, т.е. получить информацию step-by-step.

Делаем запрос по рубрике:

c1121539cd2eb2ce5b16865641f8cbe9.png

Мы получили требуемую информацию, но в два запроса:

  • news ~ 159 ms

  • rubric ~121 ms

Итого: ~280 ms

Давайте попробуем сделать составной запрос, чтобы сократить накладные расходы (сеть/транспорт, анализ запросов, формирование ответов и т.д.).

На самом деле делается это достаточно просто. Переходим в класс с новостью с возвращаемыми полями и добавляем буквально 2–3 строчки в resolve функцию:

 function () {
                return [
                    'id' => ['type' => Types::int()],
                    'name' => ['type' => Types::string()],
                    'text' => ['type' => Types::string()],
                    'rubric' => [  //описали вложенный запрос на получение рубрики новости
                        'type' => Types::rubric(),
                        //передаем в метод параметры для отбора рубрик по родительским возвращаемым полям
                        'resolve' => function ($root, $args, $context, $info) {
                            return Rubric::get(['id' => $root['rubric']]);
                        }
                    ],
                ];
            }
        ];
        parent::__construct($config);
    }
}

Теперь мы можем послать запрос, указав в аргументах всю необходимую информацию по новости и рубрике, которую мы хотели бы получить:

6a363e1b9e2c45486efc298aea792d9e.png

Нам вернулся корректный ответ по новости и рубрике за один шаг (One step) за~161 ms. Как вы заметили, поле rubric превратилось в некий объект со своей структурой и набором данных. 

Сравнивания две технологии по производительности на кейсах выше, мы видим выигрыш у технологии One step, которая позволяет получать информацию за один шаг.

Step by step

One step

Производительность

~280 ms

~161 ms

— 57,5%

Стоит учитывать, что замеры «синтетические», и возможны кейсы, где лучше использовать step-by-step. Например, запросы с большим набором данных из различных объектов при котором может nginx или БД отдать ошибку по таймауту или памяти, но, как правило, такие сложные запросы единичны и проектируются отдельно.

В целом, технология one step более выигрышна из-за отсутствия накладных расходов.

Безопасность: Разграничение прав доступа (White list)

Плавно переходим к теме безопасности, которая была актуальна во все времена, а в современных условиях, информационная безопасность выходит практически на первый план для организации непрерывной работы бизнеса. GraphQL не является исключением, особенно учитывая, что безопасность легитимного доступа к типам/объектам и их полям не регламентируется, по крайней мере в нашей библиотеке.

Безопасность в GraphQL необходимо рассматривать с самого низкого/атомарного уровня — полей. Недостаточно проверить доступ к объекту, необходимо проверять доступ и на уровне полей. В противном случае, недекларированный доступ к информации может привести к ее утечке и реализации каких-либо угроз.

Проблема усугубляется тем, что необходимо найти единую входную точку, которая позволила бы централизовано управлять правами доступа для всех объектов и их полей.

Не буду описывать историю поиска такой точки, а сразу перейду к ней.

Первым делом мы вынесли все наши query запросы в отдельный массив $arConfig, а в переменной $config реализуем анонимную функцию, которая будет возвращать требуемую структуру с проверкой доступности объектов и полей.

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

 [
                'type' => Types::listOf(Types::news()),
                'args' => [
                    'id' => Types::int(),
                ],
                'resolve' => function ($root, $args, $context, $info) {
                    return News::get($args);
                }
            ],
            'rubric' => [],
        ];
        $config = [
            'fields' => function () use ($arConfig) {
                //проверка доступов к методам и его полям
                return Permission::processConfigFields($arConfig);
            }
        ];
        parent::__construct($config);
    }
}

Теперь опишем структуру класса для проверки доступа:

 "TEST",
            "PASSWORD" => "123456",
            "WHITELIST" => [
                "news" => [ // разрешенный метод news для текущего пользователя и его разрешенные возвращаемые поля
                    "id",
                    "name",
                    "text"
                ]
            ]
        ],
        [
            "LOGIN" => "RUBRIC_USER",
            "PASSWORD" => "654321",
            "WHITELIST" => [
                "rubric" => []
            ]
        ]
    ];

    /**
     * @param $fields
     * @return mixed
     */
    public static function processConfigFields($fields)
    {
        foreach ($fields as $code => $arField) {
            $fields[$code]['resolve'] = function ($root, $args, $context, $info) use ($code, $arField) {
                $result = $arField['resolve']($root, $args, $context, $info);
                self::checkAccess($code, array_keys(current($result)));
                return $result;
            };
        }

        return $fields;
    }

    /**
     * Метод проверки доступа к переданному типу запроса и его аргументам
     *
     * @param string $methodName
     * @throws RequestError
     */
    private static function checkAccess(string $methodName, array $args)
    {
        if (!empty($_SERVER['PHP_AUTH_USER'])) {
            foreach (self::$envConfig as $arUser) {
                if ($_SERVER['PHP_AUTH_USER'] == $arUser['LOGIN'] && $_SERVER['PHP_AUTH_PW'] == $arUser['PASSWORD']) {  //Проверим, разрешен ли запрос для пользователя
                    if (!in_array($methodName, array_keys($arUser['WHITELIST']))) {
                        throw new RequestError('Method ' . $methodName . ' is forbidden for user ' . $arUser['LOGIN'], 401);
                    }
                    foreach ($args as $field) { //Проверим, разрешены ли возвращаемые поля запроса для текущего пользователя
                        if ($arUser['WHITELIST'][$methodName] && !in_array($field, $arUser['WHITELIST'][$methodName])) {
                            throw new RequestError('Args ' . $field . ' is forbidden for user ' . $arUser['LOGIN'], 401);
                        }
                    }
                }
            }
        }
    }
}

Основное содержание этого класса:

  • свойство $envConfig — массив описывающий набор данных, доступных конкретному пользователю, т.е.:

    • логин/пароль для базовой проверки доступа к методам;

    • белый список объектов и полей объекта, которые доступны пользователю.

      Как и писал ранее, код приводится в качестве примера. На практике не рекомендуется хранить логин/пароль в директории сайта и тем более в какой-либо системе хранения версий. Рекомендуется выносить этот параметр за пределы корневой директории сайта SERVER[«DOCUMENT_ROOT»].

  • Метод processConfigFields отвечает за разбор входных данных и передачу в более низкоуровневую функцию checkAccess;

  • Метод checkAccess проверят доступ на основе переданных данных: объект и поля объекта, к которым был запрошен доступ. В случае неудачи метод выкидывает исключение, и останавливается работа скрипта.

Теперь можно проверить работоспособность, сделав запрос к новости из-под нелегитимного пользователя:

6dc7db874db217030ca18ea4624a870b.png

Гибкость: Реализация сложных фильтров с логикой OR/AND

Для реализации сложных фильтров необходимо:

  • автоматической генерации классов со сложной логикой и добавлением в Query-запросы налету;

  • зарегистрировать новый тип данных;

  • добавить логику обработки в тип Query запросов.

Класс должен автоматически генерировать/расширять все типы данных для реализации сложных фильтров с типами «OR»/«AND» в режиме реалтайма, НО:

библиотека не работает с одноименными классами данных и выдает ошибку.

«Schema must contain unique named types but contains multiple types named »\GraphQL\App\Type\Input\MixedInputType» (see http://webonyx.github.io/graphql-php/type-system/#type-registry).»

Для обхода ограничения необходимо добавлять «соль» при автогенерации новых типов данных.  Они создаются по аналогии с другими типами: news, rubric и т.д.

В качестве «соли» мы будем использовать hash входных аргументов и на основе него формировать итоговый тип, склеивая с наименованием класса. В итоге, наименование автогенерируемых новых типов данных будет выглядит следующим образом: MixedInputType05afd6ecb065cfd7b660a6b6a59a54cf.

Далее нам необходимо описать автоформирование полей на основе входных данных:

 self::class . $sHashGenerated,//автоформирование схемы типов, генерация уникального имени для нового типа, например, GraphQL\App\Type\Input\MixedInputType05afd6ecb065cfd7b660a6b6a59a54cf
            'fields' => function () use ($arExtendedFields, $sHashGenerated) {
                return [//автоформирование полей для Query запросов
                    'OR' => [
                        'type' => Types::listOf(
                            new InputObjectType(
                                [
                                    'name' => self::class . 'OR' . $sHashGenerated,
                                    'fields' => function () use ($arExtendedFields) {
                                        return $arExtendedFields;
                                    }
                                ]
                            )
                        )
                    ],
                    'AND' => [
                        'type' => Types::listOf(
                            new InputObjectType(
                                [
                                    'name' => self::class . 'AND' . $sHashGenerated,
                                    'fields' => function () use ($arExtendedFields) {
                                        return $arExtendedFields;
                                    }
                                ]
                            )
                        )
                    ]
                ];
            }
        ];
        parent::__construct($config);
    }
}

Зарегистрируем наш тип по аналогии с другими. Единственный момент, который стоит учитывать, это имя типа — оно должно генерироваться по аналогии с вышеописанным классом:

Далее, по аналогии с внедрением логики для проверки доступов, внедрим в единую точку входа автоматическое формирование фильтров со сложной логикой для каждого типа данных:

 [
                'type' => Types::listOf(Types::news()),
                'args' => ['id' => Types::int()],
                'resolve' => function ($root, $args, $context, $info) {return News::get($args);}
            ],
        ];
        $config = [//формируем фильтры со сложной логикой для каждого типа данных
            'fields' => function () use ($arConfig) {
                foreach ($arConfig as $sCodeConfig => $arParams) {
                    $arConfig[$sCodeConfig]['args']['filter'] = Types::listOf(Types::mixedInput($arConfig[$sCodeConfig]['args']));
                }
                return $arConfig;
            }
        ];
        parent::__construct($config);
    }
}

Ниже представлен рисунок, который наглядно демонстрирует разницу между ручным внедрением фильтра для каждого метода и автогенерацией. Стоит учитывать, что на скриншоте ниже (ручная генерация) приведен только маленький кусочек кода для одного query запроса для логики «OR», и размер кодовой базы будет расти пропорционально внедрению новых запросов. При автогенерации размер кодовой базы статичен.

6c9a43f4a12135369fba12989dba8852.png

Проверим работоспособность, послав query запрос с указанием в фильтре логики «OR»:

1092d6ea3d5a49693a83652622197abb.png

По итогам запроса мы получили две новости с запрашиваемыми ID.

Выводы

Мы описали >20 «простых» типов данных. В среднем один «комплексный» тип данных состоит из трех подтипов.

Исходя из формулы ниже, нам удалось реализовать >1 000 комбинаций/сочетаний.

58302528d6fe654886416f714023ace7.png

Краткие рекомендации:

  • Реализовывайте принцип One step-запросов;

  • Заложите систему разграничения доступа к объектам и их сущностям для каждого потребителя;

  • Настройте систему логирования с четкой фиксацией потребителя, который запрашивает информацию;

  • Сделайте фильтр со сложной логикой для большей гибкости запросов.

© Habrahabr.ru