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

GraphQL: внедрение и советы
GraphQL — это язык запросов с открытым исходным кодом.
Хотелось бы выделить три основные характеристики языка:
-
Позволяет клиенту точно указать, какие данные ему нужны;
-
Облегчает агрегацию данных из нескольких источников;
-
Использует систему типов для описания данных.

На картинке выше указан граф, состоящий из четырех типов: users, rubrics, news, comment. Но между ними множество взаимосвязей, более 10-и. Таким образом, описав четыре типа данных, мы получаем большую вариативность.
В качестве основы мы стали использовать библиотеку, которая соответствует изначально заложенной спецификации, имеет неплохую документацию и постоянно развивается.
Процесс установки зависимостей достаточно хорошо описан в документации, поэтому сильно заострять на этом внимание не будем.
За время использования этой технологии у нас появились свои must have правила/методы, которые успешно тиражируются и применяются на различных проектах и информационных системах. Давайте поговорим о них.
Хотелось бы отметить, что код, который будет представлен ниже:
-
написан на чистом PHP без каких-либо обраток данных, входных/выходных параметров и т.д.;
-
носит информативный характер и показывает принцип внедрения/использования какого-либо функционала;
-
данные примеры нельзя использовать в информационных системах в чистом виде.
Базовая структура
В GraphQL есть три корневых типа данных:
-
MutationType – используется для изменения информации
-
QueryType – используется для получения информации
-
SubscriptionType – используется для подписки на события, но он реализован только для «реактивных» библиотек PHP или PHP > 8.1 (актуально для библиотеки). В данной статье мы его рассматривать не будем. У себя в компании мы используем другие механизмы подписок, реализованные через различные шины данных, брокеры сообщений и т.д.
Мы используем структуру ниже:

Входной точкой является файл /graphql/index.php с приблизительным содержанием:
<?php //Подключаем GraphQL require_once realpath('../vendor/autoload.php'); try { //Получаем исходные данные $rawInput = file_get_contents('php://input'); $input = json_decode($rawInput, true); $query = $input['query']; //Создаем схему $schema = new Schema([ 'query' => 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:
<?php class QueryType extends ObjectType { public function __construct() { $config = [ 'fields' => 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:
<?php class Types extends Type { private static $query; private static $news; private static $rubric; public static function query() { return self::$query ?: (self::$query = new QueryType()); } public static function news() // Зарегистрировали новый тип { return self::$news ?: (self::$news = new NewsType()); //необходимо описать поля этого типа } }
Опишем возвращаемые поля для типа news:
<?php class NewsType extends ObjectType { public function __construct() { $config = [ 'fields' => function () { return [ //описали поля таблицы и указали их типы 'id' => [ 'type' => Types::int(),//id новости ], 'name' => [ 'type' => Types::string(),//имя новости ], 'text' => [ 'type' => Types::string(),//текст новости ], ]; } ]; parent::__construct($config); } }
Реализуем взаимодействие с хранилищем:
<?php namespace GraphQL\App\Type; use GraphQL\App\DB\ConnectDB; class News { public static function get($args) { $DB = new ConnectDB(); return $DB->query("SELECT * from news WHERE id = {$args['id']}")[0]; } }
Проделав операции выше, мы можем проверить работоспособность, послав запрос на сервер и указав необходимую сущность и набор требуемых полей:

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

Таким образом, мы убедились, что GraphQL действительно позволяет указать и возвращать только те данные, которые необходимы «потребителю».
Производительность: составной запрос (One step)
Как и говорили ранее, новость имеет различные взаимосвязи, в том и числе и с рубрикой.
Давайте опишем запрос для типа rubric, он аналогичен описанию типа news:
<?php class QueryType extends ObjectType { public function __construct() { $config = [ 'fields' => 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); } }
Зарегистрируем его:
<?php class Types extends Type { private static $query; private static $news; private static $rubric; public static function query() { return self::$query ?: (self::$query = new QueryType()); } public static function news() { return self::$news ?: (self::$news = new NewsType()); } public static function rubric() // Зарегистрировали новый тип { return self::$rubric ?: (self::$rubric = new RubricType()); // необходимо описать поля этого типа } }
Опишем возвращаемые поля:
<?php class RubricType extends ObjectType { public function __construct() { $config = [ 'fields' => function () { return [ //описали поля таблицы и указали их типы 'id' => [//id рубрики 'type' => Types::int(), ], 'name' => [//наименование рубрики 'type' => Types::string(), ], 'code' => [//символьный код рубрики 'type' => Types::string(), ], ]; } ]; parent::__construct($config); } }
Реализуем взаимодействие с хранилищем:
<?php namespace GraphQL\App\Type; use GraphQL\App\DB\ConnectDB; class Rubric { public static function get($args) { $DB = new ConnectDB(); return $DB->query("SELECT * from rubrics WHERE id = {$args['id']}")[0]; } }
Для того, чтобы нам сопоставить новость с рубрикой, нам необходимо добавить в список возвращаемых полей идентификатор рубрики:
<?php class NewsType extends ObjectType { public function __construct() { $config = [ 'fields' => 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 рубрики:

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

Мы получили требуемую информацию, но в два запроса:
-
news ~ 159 ms
-
rubric ~121 ms
Итого: ~280 ms
Давайте попробуем сделать составной запрос, чтобы сократить накладные расходы (сеть/транспорт, анализ запросов, формирование ответов и т.д.).
На самом деле делается это достаточно просто. Переходим в класс с новостью с возвращаемыми полями и добавляем буквально 2-3 строчки в resolve функцию:
<?php class NewsType extends ObjectType { public function __construct() { $config = [ 'fields' => 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); } }
Теперь мы можем послать запрос, указав в аргументах всю необходимую информацию по новости и рубрике, которую мы хотели бы получить:

Нам вернулся корректный ответ по новости и рубрике за один шаг (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).
<?php class QueryType extends ObjectType { public function __construct() { $arConfig = [ //Вынесли наши Query запросы в массив 'news' => [ '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); } }
Теперь опишем структуру класса для проверки доступа:
<?php //файл App\Auth\Permission.php namespace GraphQL\App\Auth; use GraphQL\Server\RequestError; class Permission { /** * Переменная с конфигом допустимых методов и полей для определенного пользователя */ private static $envConfig = [ [ "LOGIN" => "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 проверят доступ на основе переданных данных: объект и поля объекта, к которым был запрошен доступ. В случае неудачи метод выкидывает исключение, и останавливается работа скрипта.
Теперь можно проверить работоспособность, сделав запрос к новости из-под нелегитимного пользователя:

Гибкость: Реализация сложных фильтров с логикой 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.
Далее нам необходимо описать автоформирование полей на основе входных данных:
<?php class MixedInputType extends InputObjectType { public function __construct(array $arExtendedFields = []) { $sHashGenerated = md5(implode(',', $arExtendedFields));//создаем хеш аргументов $config = [ 'name' => 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); } }
Зарегистрируем наш тип по аналогии с другими. Единственный момент, который стоит учитывать, это имя типа – оно должно генерироваться по аналогии с вышеописанным классом:
<?php class Types extends Type { private static $query; private static $mixedInput; public static function query() { return self::$query ?: (self::$query = new QueryType()); } /**Смешанный тип данных для query запроса*/ public static function mixedInput(array $arExtendedFields = []) { $sArguments = md5(implode(',', $arExtendedFields)); return self::$mixedInput[$sArguments] ?: self::$mixedInput[$sArguments] = new MixedInputType($arExtendedFields); } }
Далее, по аналогии с внедрением логики для проверки доступов, внедрим в единую точку входа автоматическое формирование фильтров со сложной логикой для каждого типа данных:
<?php class QueryType extends ObjectType { public function __construct() { $arConfig = [//Вынесли наши Query запросы в массив 'news' => [ '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», и размер кодовой базы будет расти пропорционально внедрению новых запросов. При автогенерации размер кодовой базы статичен.

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

По итогам запроса мы получили две новости с запрашиваемыми ID.
Выводы
Мы описали >20 «простых» типов данных. В среднем один «комплексный» тип данных состоит из трех подтипов.
Исходя из формулы ниже, нам удалось реализовать >1 000 комбинаций/сочетаний.

Краткие рекомендации:
-
Реализовывайте принцип One step-запросов;
-
Заложите систему разграничения доступа к объектам и их сущностям для каждого потребителя;
-
Настройте систему логирования с четкой фиксацией потребителя, который запрашивает информацию;
-
Сделайте фильтр со сложной логикой для большей гибкости запросов.
ссылка на оригинал статьи https://habr.com/ru/articles/780328/
Добавить комментарий