Всем привет! Не говорите, что вам нечего приготовить, если у вас дома одна картошка. При смекалке и достаточном количестве специй — пюре, драники, запеканка, чипсы фри... Конечно, мы обсуждаем не кулинарию. Мне хотелось бы поговорить о 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-запросов;

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

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

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