Как мы приготовили массу блюд c помощью одного ингредиента: GraphQL
Всем привет! Не говорите, что вам нечего приготовить, если у вас дома одна картошка. При смекалке и достаточном количестве специй — пюре, драники, запеканка, чипсы фри… Конечно, мы обсуждаем не кулинарию. Мне хотелось бы поговорить о GraphQL и некоторых фичах, которые мы внедрили у себя в компании, и тиражируем на различные проекты, где используется этот язык запросов.
Эта статья о базовой структуре, производительности, безопасности и гибкости GraphQL и будет интересна архитекторам, интеграторам, аналитикам и разработчикам, которые не ограничиваются рассмотрением информационных систем только с точки зрения «кода», а учитывают полный жизненный цикл системы, включая поддержку, развитие, систему управления знаниями и многое другое.
GraphQL: внедрение и советы
GraphQL — это язык запросов с открытым исходным кодом.
Хотелось бы выделить три основные характеристики языка:
Позволяет клиенту точно указать, какие данные ему нужны;
Облегчает агрегацию данных из нескольких источников;
Использует систему типов для описания данных.
На картинке выше указан граф, состоящий из четырех типов: users, rubrics, news, comment. Но между ними множество взаимосвязей, более 10-и. Таким образом, описав четыре типа данных, мы получаем большую вариативность.
В качестве основы мы стали использовать библиотеку, которая соответствует изначально заложенной спецификации, имеет неплохую документацию и постоянно развивается.
Процесс установки зависимостей достаточно хорошо описан в документации, поэтому сильно заострять на этом внимание не будем.
За время использования этой технологии у нас появились свои must have правила/методы, которые успешно тиражируются и применяются на различных проектах и информационных системах. Давайте поговорим о них.
Хотелось бы отметить, что код, который будет представлен ниже:
написан на чистом PHP без каких-либо обраток данных, входных/выходных параметров и т.д.;
носит информативный характер и показывает принцип внедрения/использования какого-либо функционала;
данные примеры нельзя использовать в информационных системах в чистом виде.
Базовая структура
В GraphQL есть три корневых типа данных:
MutationType — используется для изменения информации
QueryType — используется для получения информации
SubscriptionType — используется для подписки на события, но он реализован только для «реактивных» библиотек PHP или PHP > 8.1 (актуально для библиотеки). В данной статье мы его рассматривать не будем. У себя в компании мы используем другие механизмы подписок, реализованные через различные шины данных, брокеры сообщений и т.д.
Мы используем структуру ниже:
Входной точкой является файл /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];
}
}
Проделав операции выше, мы можем проверить работоспособность, послав запрос на сервер и указав необходимую сущность и набор требуемых полей:
Так как GraphQL — это умный endpoint, то давайте проверим его работоспособность на другом наборе полей. Например, получим только id и name новости. Сразу скажу, это работает!) Скриншот ниже.
Таким образом, мы убедились, что 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 рубрики:
Но, чтобы получить информацию по самой рубрике, нам необходимо сделать дополнительный запрос, т.е. получить информацию step-by-step.
Делаем запрос по рубрике:
Мы получили требуемую информацию, но в два запроса:
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);
}
}
Теперь мы можем послать запрос, указав в аргументах всю необходимую информацию по новости и рубрике, которую мы хотели бы получить:
Нам вернулся корректный ответ по новости и рубрике за один шаг (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 проверят доступ на основе переданных данных: объект и поля объекта, к которым был запрошен доступ. В случае неудачи метод выкидывает исключение, и останавливается работа скрипта.
Теперь можно проверить работоспособность, сделав запрос к новости из-под нелегитимного пользователя:
Гибкость: Реализация сложных фильтров с логикой 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», и размер кодовой базы будет расти пропорционально внедрению новых запросов. При автогенерации размер кодовой базы статичен.
Проверим работоспособность, послав query запрос с указанием в фильтре логики «OR»:
По итогам запроса мы получили две новости с запрашиваемыми ID.
Выводы
Мы описали >20 «простых» типов данных. В среднем один «комплексный» тип данных состоит из трех подтипов.
Исходя из формулы ниже, нам удалось реализовать >1 000 комбинаций/сочетаний.
Краткие рекомендации:
Реализовывайте принцип One step-запросов;
Заложите систему разграничения доступа к объектам и их сущностям для каждого потребителя;
Настройте систему логирования с четкой фиксацией потребителя, который запрашивает информацию;
Сделайте фильтр со сложной логикой для большей гибкости запросов.