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

в 6:56, , рубрики: .net, csharp, expressions, graphql, graphql-client, linq, linq2graphql, open source, translation, translator, Алгоритмы, Разработка веб-сайтов

Немного про GraphQL

Дисклеймер: В статье рассматриваются только Query (аналог GET-запросов). Мутации и подписки не рассматриваются.

GraphQL - это инструмент, позволяющий заменить привычное API. Вместо написания контроллеров и методов, вы пишете методы в Query:

public class GraphQLQuery 
{
  public IQueryable<UserModel> 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<UserModel> 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)

Минусы

  • Потребителю необходимо почти для каждого запроса писать громоздкую строку Query или тратить большое количество времени на автоматизацию формирования этой строки.

Описание проблемы

Из рассмотренных минусов следует, что самым затруднительным процессом при использовании 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<Human> query = new Query<Human>("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<human>( // 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<UserModel>()
  .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<UserModel>()
  .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<UserModel>("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-пакет

Автор: Роман Давыденко

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js