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

от автора

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

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

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

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

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

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

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

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

На картинке выше указан граф, состоящий из четырех типов: 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *