C# Linq для GraphQL-запросов
Немного про GraphQL
Дисклеймер: В статье рассматриваются только Query (аналог GET-запросов). Мутации и подписки не рассматриваются.
GraphQL — это инструмент, позволяющий заменить привычное API. Вместо написания контроллеров и методов, вы пишете методы в Query:
public class GraphQLQuery
{
public IQueryable GetUsers([Service] IUsersRepository repository)
{
return repository.Users;
}
}
Всего пару строк и вы добавили в приложение новый GraphQL-endpoint. Теперь к нему можно обратиться POST-запросом (обычно), передав вот такую строку:
users {
id
userName
roles {
code
description
}
}
На выходе мы получим список пользователей с выбранными полями — id, userName и списком ролей — roles (с полями code и description).
В этой статье рассматривается взаимодействие с GraphQL-сервером от ChilliCream — HotChocolate. Изучить его документацию можно тут.
HotChocolate поддерживает атрибуты endpoint’а, в т.ч. и самописные, которые позволяют добавлять новую функциональность к вашему запросу. Например, можно модифицировать пример выше, используя готовые атрибуты:
public class GraphQLQuery
{
[UseOffsetPaging] // Добавили пагинацию
[UseProjection] // Добавили проекцию
[UseFiltering] // Добавили фильтрацию
[UseSorting] // Добавили сортировку
public IQueryable GetUsers([Service] IUsersRepository repository)
{
return repository.Users;
}
}
Теперь мы можем применить дополнительные инструменты к запросу (фильтрация, сортировка и пагинация):
users (
where: { userName: { startsWith: "a" } }
order: [{ id: DESC }],
skip: 100,
take: 20
) {
items {
id
userName
roles {
code
description
}
}
pageInfo {
hasNextPage
hasPreviousPage
}
totalCount
}
Итак, мы смогли добавить фильтрацию, сортировку и пагинацию в наш GraphQL-запрос. Благодаря атрибуту [UseOffsetPaging]
наш список пользователей теперь обернут в особую структуру и лежит в items
, а так же ответ содержит информацию о текущей странице pageInfo
и общее количество элементов IQueryable<>
— totalCount
.
Вывод: Благодаря использованию GraphQL конечному пользователю (это может быть ваш фронтенд, например) не нужно ждать, пока добавится новый параметр фильтрации или добавится новое поле в выходную модель какой-нибудь GET REST-api вашего бэкенда. Потребитель сам решает какие поля ему нужны и как ему фильтроваться/сортироваться по вашим данным.
Плюсы
Нет необходимости тратить много времени на создание такого гибкого GET REST-api (с фильтрацией, сортировкой и т.д.)
Потребитель сам решает, как ему использовать ваши GraphQL-методы
Минимальное время на доработку бэкенда
Оптимальные запросы в базу данных (благодаря трансляции запросов в SQL)
Минусы
Описание проблемы
Из рассмотренных минусов следует, что самым затруднительным процессом при использовании GraphQL является формирование Query-строки. Да, это действительно может отнимать много времени при использовании на реальных проектах, особенно при back-to-back интеграции с GraphQL-сервером.
Примерно так может процесс формирования Query-строки:
var query = @$"
users (
where: {{
{(model.UserId.HasValue ? $"\{ id: \{ eq: {model.UserId} \} \}" : null)}
{(model.UserName != null ? $"\{ userName: \{ contains: {model.UserName} \} \}" : null)}
}}
{model.Order != null
? $"order: [\{ {model.Order.Field}: {model.Order.Direction} \}]"
: null}
) {{
items {{
id
name
}}
}}
";
То, с чем вам точно придется столкнуться:
Бесконечное экранирование всего чего только можно (символы
{
и}
)Кучи тернарников, причем чаще всего с большой вложенностью
Исключения в рантайме, когда у модели изменилось поле, а вы вовремя не отсмотрели все строки в проекте и не нашли его
Решение проблемы
Я задал себе вопрос:
Почему нет адекватного и удобного для разработчика инструмента, чтобы писать типизированные запросы к GraphQL, не строя бесконечного количества строк?
Strawberry Shake
Почти сразу я нашел GraphQL-клиенты, которые предоставляются большими проектами. Взять тот же ChillliCream — у них тоже есть свой GraphQLClient (Strawberry Shake). Использование его выглядит примерно так:
Вы пишете в файле query
Запускаете специальную тулзу кодогенерации
Получаете типизированный клиент с этой query
Да, удобно, но когда у вас часто меняется query, т.е., например, если приходится с одного и того же Endpoint’а вытягивать разный набор данных (разная проекция), то придется постоянно что-то придумывать, заново генерировать классы и дублировать query.
GraphQL.Client
Есть еще GraphQL Client от GraphQL-dotnet. Сценарий его использования примерно такой:
var personAndFilmsRequest = new GraphQLRequest {
Query =@"
query PersonAndFilms($id: ID) {
person(id: $id) {
name
filmConnection {
films {
title
}
}
}
}",
OperationName = "PersonAndFilms",
Variables = new {
id = "cGVvcGxlOjE="
}
};
Снова пришли к строкам, но теперь у нас есть переменные, стало чуть удобней. Так же не стоит забывать, что на каждый такой запрос по-хорошему надо создавать DTO-класс. В общем, тоже совсем не то, чтобы хотелось видеть.
Использование Expression’ов
В голове возникла следующая мысль:
Почему бы не попробовать использовать Expression’ы для построения нужной query? Ведь у нас есть механизмы для транслирования Expression’ов в SQL для базы данных в Entity Framework’е. Почему бы не сделать то же самое?
С такой мыслью я продолжил поиск существующих решений.
GraphQL.Query.Builder
Первое, что удалось найти это — GraphQL.Query.Builder. Ссылка на GitHub.
Автор библиотеки предлагает строить запрос так:
IQuery query = new Query("humans") // set the name of the query
.AddArguments(new { id = "uE78f5hq" }) // add query arguments
.AddField(h => h.FirstName) // add firstName field
.AddField(h => h.LastName) // add lastName field
.AddField( // add a sub-object field
h => h.HomePlanet, // set the name of the field
sq => sq /// build the sub-query
.AddField(p => p.Name)
)
.AddField( // add a sub-list field
h => h.Friends,
sq => sq
.AddField(f => f.FirstName)
.AddField(f => f.LastName)
);
Уже неплохо, но достаточно простенько, да и бесконечные вызовы AddField()
выглядят не очень хорошо. К тому же нет ни фильтрации, ни сортировки, ни пагинации, да и api библиотеки не похож на привычное всем Linq.
GraphQLinq.Client
Еще одна библиотека — GraphQLinq.Client.
Автор библиотеки реализовал api, похожий на Linq. И запросы выглядят следующим образом:
var launches = await spaceXContext.Launches(null, 10, 0, null, null)
.Include(launch => launch.Links)
.Include(launch => launch.Rocket)
.Include(launch => launch.Rocket.Second_stage.Payloads
.Select(payload => payload.Manufacturer));
Есть поддержка Include
'ов, Select
'ов., но я так и не увидел фильтрации и сортировки. Из плюсов еще можно отметить, что автор предлагает тулзу для генерации DTO-классов из схемы GraphQL-сервера, что, в целом, может быть полезно и сократит часть времени разработки.
Выводы: GraphQL.Query.Builder и GraphQLinq.Client выглядят удобней для построения GraphQL-запросов, особенно последний вариант, который предлагает подобие Linq-методов расширений. Но, все равно, у нас нет ни фильтрации, ни сортировки.
Реализация собственного решения
После обзора существующих решений, я подумал, что было бы неплохо реализовать собственный Linq-подобный api для построения запросов к GraphQL-серверу на Expression’ах и реализовать в нем все то, чего нет в других библиотеках.
Необходимая функциональность:
Построение проекций —
Select()
иInclude()
Построение условных выражений —
Where()
Построение выражений сортировки —
OrderBy()
,OrderByDescending()
,ThenBy()
,ThenByDescending()
Пагинация —
Take()
,Skip()
Кастомные аргументы —
Argument()
Различные варианты материализации результата —
ToArrayAsync()
,ToListAsync()
,ToPageAsync()
,FirstOrDefaultAsync()
,FirstAsync()
Определившись с основной функциональностью я принялся разрабатывать. Обход выражений очень удобно было выполнять при помощи Visitor
'ов. Если будет интересно, расскажу об этом подробнее и с примерами в другой статье.
В качестве примера приведу вот такое выражение:
client.Query()
.Where(x => x.Id > 1 && x.Roles.Any(r => r.Code == RoleCodes.ADMINISTRATOR));
Такое Where-выражение транслируется в следующую строку (проекцию пока не рассматриваем):
and: [
{ id: { gt: 1 } }
{ roles: { some: { code: { eq: ADMINISTRATOR } } } }
]
Также удалось реализовать трансляцию и для Select-выражений.
После обхода выражения такого вызова метода Select()
:
client.Query()
.Select(x => new
{
x.Id,
x.UserName,
Roles = x.Roles
.Select(r => new
{
r.Id,
r.Code
})
.ToArray()
});
Получаем вот такую сгенерированную строку проекции:
id
userName
roles {
id
code
}
Собрав все воедино, получил механизм, который позволяет при помощи Expression’ов формировать корректную GraphQL-строку для последующего запроса на GraphQL-сервер.
Результат:
var users = await client.Query("users")
.Include(x => x.Roles)
.ThenInclude(x => x.Users)
.Where(x => x.UserName.StartsWith("A") || x.Roles.Any(r => r.Code == RoleCode.ADMINISTRATOR))
.OrderBy(x => x.Id)
.ThenByDescending(x => x.UserName)
.Select(x => new
{
x.Id,
Name = x.UserName,
x.Roles,
IsAdministrator = x.Roles.Any(r => r.Code == RoleCode.ADMINISTRATOR)
})
.Skip(5)
.Take(10)
.Argument("secretKey", "1234")
.ToListAsync();
Такой запрос превратится в следующую строку и материализует результаты ответа от GraphQL-сервера в виде списка:
{
users (
where: {
or: [
{ userName: { startsWith: "A" } }
{ roles: { some: { code: { eq: ADMINISTRATOR } } } }
]
}
order: [
{ id: ASC }
{ userName: DESC }
]
skip: 5
take: 10
secretKey: "1234"
) {
id
userName
roles {
code
name
description
id
users {
userName
age
id
}
}
}
}
Заключение
Получилось реализовать работоспособный GraphQL-клиент, который соответствует всей заявленной функциональности.
Да, еще есть что дорабатывать:
Hеобходимо дорабатывать механизм трансляции выражений в GraphQL-строку, т.к. не все варианты могут корректно транслироваться
Добавить курсорную пагинацию
Добавить поддержку скаляров
Проверить работоспособность на других GraphQL-серверах и доработать при необходимости
Очень интересно было поработать с выражениями и методами их обхода. Пишите в комментариях, какую определенную часть функциональности было бы интересно рассмотреть более подробно. Задавайте свои вопросы.
Буду рад участию в жизни проекта — формируйте Issues, делайте Pull Request’ы, добавляйте новую функциональность, проводите рефакторинг кода, не забывайте добавлять новые Unit-тесты.
Спасибо за уделенное время.
Ссылки
Ссылка на GitHub-репозиторий клиента
Ссылка на Nuget-пакет