Немного про 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-пакет

Комментарии (24)


  1. Evengard
    25.06.2023 09:51

    Интересно было бы почитать как именно реализовывалось, наглядная демонстрация работы с Expression-ами.


    1. RDavydenko Автор
      25.06.2023 09:51
      +1

      Спасибо за комментарий. Если интересно покопаться в исходниках, вот исходный код Visitor'а, который строит Where-выражение. После рефакторинга получился совсем худенький - https://github.com/RDavydenko/SmartGraphQLClient/blob/master/src/SmartGraphQLClient.Core/Visitors/WhereExpressionVisitor/WhereExpressionVisitor.cs

      Постараюсь в ближайшее время написать про работу с Expression'ами в этом проекте. Ничего сильно сложного здесь нет, но есть неочевидные подводные камни, с которыми пришлось столкнуться


  1. nronnie
    25.06.2023 09:51
    +2

    Труд, конечно, объемный, но, то, что вы реализовали это вовсе не LINQ. Потому что LINQ работает в принципе не так. В нем вы не повторяете API System.Linq.Queryable, а реализуете только свой System.Linq.IQueryProvider который транслирует уже готовый expression в запрос к источнику данных провайдером для которого он является. А построением самого этого expression занимается уже готовый класс-расширение System.Linq.Queryable, который от конкретного провайдера не зависит. Статью все равно плюсанул :)


    1. RDavydenko Автор
      25.06.2023 09:51

      Согласен. Немного кликбейтный получился заголовок. Да, я не задавался целью реализовать свой собственный QueryProvider, хотел просто сделать похожий на Linq api, чтобы было удобно и привычно пользоваться таким GraphQL-клиентом. Под капотом здесь своя реализация некоторого IQueryable (IGraphQLQueryable), ничего общего с обычным Linq он не имеет


      1. lair
        25.06.2023 09:51

        А почему так? Через QueryProvider же по камушкам все.


        1. RDavydenko Автор
          25.06.2023 09:51
          +1

          Потому что далеко не все доступные методы-расширения Linq подходят для GraphQL-запроса. Мы, например, не можем выполнить какие-нибудь Average, Sum, Aggregate, AsNoTracking и т.д. GraphQL-сервер от ChilliCream без расширений на стороне сервера поддерживает только фильтрацию, сортировку, проекцию, пагинацию и FirstOrDefault, поэтому проще сделать свой интерфейс по типу IQueryable только для GraphQL, чтобы однозначно определить методы, которые точно будет поддерживаться. Иначе пришлось бы из большей половины Linq-методов кидать исключения, что они не поддерживаются


          1. mayorovp
            25.06.2023 09:51

            Только AsNoTracking как раз не является стандартным, он вообще не в классе Queryable находится. И как раз его-то при желании можно и поддержать (например, безопасно проигнорировать).


            А вот с остальным согласен.


          1. nronnie
            25.06.2023 09:51

            Это штатная для LINQ ситуация. Точно так же не каждый LINQ-запрос может быть оттранслирован в SQL. В этом случае провайдер просто кидает exception.


    1. mayorovp
      25.06.2023 09:51
      +2

      Вообще-то, LINQ — это встроенный в C# синтаксис запросов, тот который from … select …. И ему не важно какой так тип данных для построения запроса используется.


      1. nronnie
        25.06.2023 09:51

        Это все все равно транслируется во fluent API из System.Linq.Queryable (который, по факту, все и используют - ни разу не видел чтобы кто-то реально использовал from ... select синтаксис).

        И ему не важно какой так тип данных для построения запроса используется.

        Ровно до тех пор, пока этот тип данных реализует IQueryable.


        1. mayorovp
          25.06.2023 09:51

          ни разу не видел чтобы кто-то реально использовал from … select синтаксис

          Ну вот я его часто использую, особенно когда надоедает парные скобки выискивать...


          Ровно до тех пор, пока этот тип данных реализует IQueryable.

          Хотите сказать, что его нельзя применить ни к IEnumerable, ни к EntityQuery из WCF RIA Services? Советую попробовать и убедиться что всё прекрасно работает.


          1. nronnie
            25.06.2023 09:51
            -1

            что его нельзя применить ни к IEnumerable

            Можно. Только он опять-таки оттранслируется в расширения, только уже из System.Linq.Enumerable и, соответственно, работать будет несколько по-другому. Про WCF RIA, вот, реально не знаю, потому что видел его только раз и то краем глаза.


            1. mayorovp
              25.06.2023 09:51
              +1

              Я не говорю о том как оно будет работать. Я говорю о том, что синтаксис linq транслируется в вызовы методов независимо от того где эти методы объявлены, и это работает для любого класса. Главное — чтобы методы были видимы.


  1. Str5Uts
    25.06.2023 09:51

    «users» / «humans»
    По идее было бы лучше избежать текстовых значений. Банальная опечатка проблемы создаст.

    Если схема генерируется на базе SDL, то можно сделать на типах. Что-то типа

    client.Query<SvcUsers>();
    
    [GqlEndPoint("users")]
    public sealed class SvcUsers<UserModel> extends IGqlEndPoint {}
    

    Для включения данных, тоже по идее можно на типах попробовать сделать, т.е. вместо
    Include(x => x.Roles)
          .ThenInclude(x => x.Users)

    Определить интерфейсы и сделать что-то типа
    Include<IRoles>()
          .ThenInclude<IUsers>()
    или даже
    Include<IRoles, IUsers>()

    Можно ещё добавить собственный анализатор, чтобы в compile time ловить не поддерживаемые expressions. Иначе определённый тип ошибок только в runtime будет вылезать.

    .Skip(5).Take(10)
    Часто вместе идут, можно сахара добавить SkipAndTake(5, 10)


    1. RDavydenko Автор
      25.06.2023 09:51

      По идее было бы лучше избежать текстовых значений. Банальная опечатка проблемы создаст.

      Вынесите в класс с константами и проблем не будет.

      Если схема генерируется на базе SDL

      Схема не генерируется. Не добавлял такой функциональности. В принципе, и так полно инструментов, которые генерируют классы из GraphQL-схемы.

      Сделать типизированный класс-Endpoint - хорошая идея, мне нравится. Только я бы через конструктор тогда уж сохранял строку, а не через атрибут. В проекте есть возможность добавить атрибут [GraphQLEndpoint("users")] на сущность, но надо понимать, что одна и та же сущность может использоваться разных методах с разными endpoint'ами, поэтому лучше либо передавать строки, либо сделать такой типизированный класс-endpoint.

      Определить интерфейсы и сделать что-то типа

      Include<IRoles>() .ThenInclude<IUsers>()

      или даже

      Include<IRoles, IUsers>()

      Не вижу смысла, т.к. это уже не похоже на Linq. Цель была - сделать удобный для всех инструмент взаимодействия с GraphQL-сервером. Чтобы все, кто умеет работать с Linq на примере того же EntityFramework, без проблем поняли, что и зачем тут происходит.

      Часто вместе идут, можно сахара добавить SkipAndTake(5, 10)

      Первый раз такое вижу, но если кому-то сильно надо, то можно же написать свой собственный метод-расширения.

      Можно ещё добавить собственный анализатор, чтобы в compile time ловить не поддерживаемые expressions. Иначе определённый тип ошибок только в runtime будет вылезать.

      В compile time - это, конечно, круто


      1. mayorovp
        25.06.2023 09:51
        +1

        Вынесите в класс с константами и проблем не будет.

        Ещё как будут. Смотрите:


        client.Query<CarModel>(Constants.Users)

        Вроде и в константу вынесено что нужно, да вот что-то работать не будет… Надо не класс с константами делать, а аналог DbContext, для начала — хотя бы вот такой:


        class QueryContext {
            private readonly IGraphQLClient client;
        
            public QueryContext(IGraphQLClient client) {
                this.client = client;
            }
        
            public Query<UserModel> Users => client.Query<UserModel>("users");
        }


        1. RDavydenko Автор
          25.06.2023 09:51

          Ну от ошибок и опечаток никто не застрахован. Идея с QueryContext мне нравится ????


      1. Str5Uts
        25.06.2023 09:51
        +1

        В принципе, и так полно инструментов, которые генерируют классы из GraphQL-схемы.
        Если у вас собственная специфика и вы решите добавить QueryContext, то генерация как раз сможет избавить от ошибок типа опечаток.

        Ну и самое главное, что если в схеме что-то изменится, при перегенерации QueryContext эти изменения сломают компиляцию, что очень сильно поможет в избежании проблем.

        Не вижу смысла, т.к. это уже не похоже на Linq.

        Согласен что выглядит подругому, но тут уже преимущества объединения всех необходимых данных.

        т.е. если мы имеем

        public class UserModel: IUserAccount
        {
           public string Name { get; set; }
           public Role[] Roles { get; set; }
           public Contact[] Contacts { get; set; }
           public Permission[] Permissions { get; set; }
        }
        
        public interface IUserAccount {
           Role[] Roles { get; set; }
           Permission[] Permissions { get; set; }
        }
        


        и если вы используете IUserAccount
        Include<IUserAccount>()

        то будете уверенны что все необходимые данные для работы с интерфейсом будут подгружены, а в случае с обычным методом, то будет необходимо везде писать
        Include(x => x.Roles).Include(x => x.Permissions)

        Если интерфейс изменится, то придётся найти все места где идут запросы и добавить/убрать соответсвующие Include.

        Я не настаиваю, я привёл пример как раз того как мы доработали EF чтобы было удобнее.


        1. RDavydenko Автор
          25.06.2023 09:51

          Понял теперь о чем речь. Ну я думаю, что большинство функционала, который не является стандартным в том же Linq, и здесь можно оставить на откуп пользователя. Кому понадобится что-то необычное - добавит свой extension себе локально. В комментариях уже были примеры со SkipAndTake , в принципе, и это можно реализовать, если кому-то понадобится


          1. Str5Uts
            25.06.2023 09:51

            Понятно что могут, так как MIT лицензия, вообще что хочешь могут.


            Тут вопоос в том что небольшие удобства, типа кодогенерации и сахара делают библиотеку более привлекательной. А усилий на создание очень мало.


            Можно конечно сказать возми вот эти три библиотеки и подружи их все вместе — но это обычно гемеор, гораздо легче если эти фичи уже дружат и знают о специфике.


        1. RDavydenko Автор
          25.06.2023 09:51

          Ну и самое главное, что если в схеме что-то изменится, при перегенерации QueryContext эти изменения сломают компиляцию, что очень сильно поможет в избежании проблем.

          Да, я задумывался о том, чтобы не возиться со строками, чтобы при компиляции ловить ошибки, т.к. клиент все-таки типизированный. Но еще можно сделать инфраструктурный Nuget-пакет с выходными моделями, и в нужном сервисе подключать его. Обновится Nuget-пакет - если изменились модели, будет ошибка на этапе компиляции

          Но для этого, конечно, нужно следить за актуальностью версии Nuget-пакета в проекте


  1. MaNaXname
    25.06.2023 09:51

    А можно вопрос - как реализуются в GraphQL пермишены. Типо не все поля или не все типы можно возвращать для каких-то пользователей.


    1. RDavydenko Автор
      25.06.2023 09:51
      +1

      Permission'ы - т.е. доступ на основе Permission'ов нужно делать на стороне GraphQL-сервера. Думаю, в сети можно найти примеры, погуглив что-то типа "PermissionBased Authorization HotChocolate".

      Чтобы возвращать не все поля - тут посложнее будет, т.к. GraphQL-схема штука статичная и должна быть заранее известна при обращении к ней. Самый простой метод - это создать DTO и предоставить пользователю метод, возвращающий, например, список таких DTO. Тогда у пользователя не будет доступа до полей, которые ему не нужно видеть.

      Можно и в рантайме вычищать ответ от GraphQL-сервера, ставля в null, например, какие-то поля, до которых у пользователя нет доступа, но, если честно, мне это не нравится. Нужно будет тогда еще и в фильтрацию, сортировку залазить, чтобы по недоступным полям выкидывать из запроса фильтрацию/сортировку. В общем, те еще костыли с велосипедами


    1. RDavydenko Автор
      25.06.2023 09:51
      +1

      Добавлю еще один вариант. Можно на стороне сервера возвращать заранее отфильтрованную/отсортированную IQueryable<>, например. Например:

      public IQueryable<Entity> GetMyEntities(
        [Service] IUnitOfWork unitOfWork, 
        [Service] IUserAccessor userAccessor)
      {
          var userId = userAccessor.GetCurrentUserId();
          return unitOfWork.Entities.Where(x => x.CreateUserId == userId);
      }