C# Linq для GraphQL-запросов

033ff8fa758b1e48482013ee4de1565d

Немного про 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). Использование его выглядит примерно так:

  1. Вы пишете в файле query

  2. Запускаете специальную тулзу кодогенерации

  3. Получаете типизированный клиент с этой 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-тесты.

Спасибо за уделенное время.

Ссылки

  1. Ссылка на GitHub-репозиторий клиента

  2. Ссылка на Nuget-пакет

© Habrahabr.ru