ZeroQL — клиент C# GraphQL добавляет поддержку фрагментов

В своей предыдущей статье я познакомил вас с ZeroQL.
Это «родной» клиент graphql на C# с Linq-подобным интерфейсом без компромиссов в производительности.
В этой статье я хочу представить поддержку фрагментов.

Что означают фрагменты?

В graphql вы должны указать каждое поле, которое вы хотите получить с сервера, вот так:

query GetMe {
    me {
        id
        firstName
        lastName
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Это хорошо, если вам нужно сделать это только один раз, но в некоторых случаях вы можете захотеть получать один и тот же набор полей снова и снова. Это может стать слишком многословным:

query GetMeAndFriend($friendId: Int!) {
    me {
        id
        firstName
        lastName
    }
    user(id: $friendId) {
        id
        firstName
        lastName
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Чтобы облегчить жизнь, в GraphQL есть поддержка фрагментов. Они позволяют определить набор полей и затем повторно использовать их при необходимости.

fragment UserFields on User {
    id
    firstName
    lastName
}

query GetMeAndFriend($friendId: Int!) {
    me {
        ...UserFields
    }
    user(id: $friendId) {
        ...UserFields
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Думаю, идея понятна. Теперь давайте посмотрим, как мы можем создавать фрагменты на стороне C#.

Мы будем работать со следующей схемой GraphQL:

schema {
  query: Query
  mutation: Mutation
}

type Query {
  me: User!
  user(id: Int!): User
}

type Mutation {
  addUser(firstName: String!, lastName: String!): User!
}

type User {
  id: Int!
  firstName: String!
  lastName: String!
  role: Role!
}

type Role {
  id: Int!
  name: String!
}
Войти в полноэкранный режим Выход из полноэкранного режима

Как настроить ZeroQL, вы можете найти в этой статье.

GraphQL-запрос GetMeAndFriend из примера выше выглядит следующим образом:

var variables = new { FriendId = 2 };
var response = await client.Query(
    variables, 
    static (i, q) => new
    {
        Me = q.Me(o => new { o.Id, o.FirstName, o.LastName }),
        User = q.User(i.FriendId, o => new { o.Id, o.FirstName, o.LastName }),
    });

Console.WriteLine(response.Query); // query ($friendId: Int!) { me { id firstName lastName }  user(id: $friendId) { id firstName lastName } }
Console.WriteLine(response.Data); //  { Me = { Id = 1, FirstName = Jon, LastName = Smith }, User = { Id = 2, FirstName = Ben, LastName = Smith } }
Войти в полноэкранный режим Выйти из полноэкранного режима

Давайте перенесем пользовательские поля во фрагмент. Для этого нам понадобится модель пользователя и метод расширения::

public record UserModel
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

public static class UserFragments
{
    [GraphQLFragment]
    public static UserModel AsUserModel(this User user)
    {
        return new UserModel
        {
            Id = user.Id,
            FirstName = user.FirstName,
            LastName = user.LastName
        };
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь мы можем переписать запрос следующим образом:

var variables = new { FriendId = 2 };
var response = await client.Query(
    variables, 
    static (i, q) => new
    {
        Me = q.Me(o => o.AsUserModel()),
        User = q.User(i.FriendId, o => o.AsUserModel()),
    });

Console.WriteLine(response.Query); // query ($friendId: Int!) { me { id firstName lastName }  user(id: $friendId) { id firstName lastName } }
Console.WriteLine(response.Data); // { Me = UserModel { Id = 1, FirstName = Jon, LastName = Smith }, User = UserModel { Id = 2, FirstName = Ben, LastName = Smith } }
Войти в полноэкранный режим Выйти из полноэкранного режима

И все работает, как и ожидалось. Если вы посмотрите на сгенерированный GraphQL-запрос, вы увидите, что, строго говоря, это не «фрагмент graphql». Это скорее подзапрос, который вставляется в конечный запрос. Таким образом, мы можем пойти дальше и объединить несколько подобных запросов:

var variables = new { FriendId = 2 };
var response = await client.Query(
    variables, 
    static (i, q) => q.GetMeAndFriend(i.FriendId));

Console.WriteLine(response.Query); // query ($friendId: Int!) { me { id firstName lastName }  user(id: $friendId) { id firstName lastName } }
Console.WriteLine(response.Data); // MeAndFriendResponse { Me = UserModel { Id = 1, FirstName = Jon, LastName = Smith }, Friend = UserModel { Id = 2, FirstName = Ben, LastName = Smith } }

// ...

public record MeAndFriendResponse
{
    public UserModel Me { get; set; }
    public UserModel Friend { get; set; }
}

public static class QueryFragments
{
    [GraphQLFragment]
    public static MeAndFriendResponse GetMeAndFriend(this Query query, int friendId)
    {
        return new MeAndFriendResponse
        {
            Me = query.Me(o => o.AsUserModel()),
            Friend = query.User(friendId, o => o.AsUserModel())
        };
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

И снова все работает, как и ожидалось.

Ограничения

Есть одна вещь, которая может немного усложнить жизнь. Генератор исходных текстов просматривает исходный код, чтобы сгенерировать запрос. Ему также необходимо просмотреть каждый подзапрос, что вполне нормально, пока он не определен в другой сборке/проекте. В этом случае генератор исходников не может в нем разобраться, потому что исходный код таких сборок недоступен. В результате мы не можем сгенерировать для них запрос. Если мы хотим, чтобы фрагмент работал, он должен быть определен в той же сборке, в которой мы вызываем методы client.Query или client.Mutation. Это требование определенно может нарушить некоторые рабочие процессы и ожидания. В то же время, как часто вам приходится разбивать SQL-запросы на разные сборки? Это случается, но не так часто. В худшем случае вам придется копировать-вставлять фрагменты из одного проекта в другой.

Опять же, это не значит, что эта проблема не может быть исправлена в будущем. У меня есть несколько идей, как ее решить, но на данный момент это просто идеи с непростой реализацией. Тем временем борьба еще не закончена.

Производительность

Я уже говорил, что ZeroQL имеет отличную производительность. Но насколько она превосходна?
В репозитории есть бенчмарк. В нем сравниваются сырой запрос graphql, StrawberryShake и ZeroQL.

Краткая версия выглядит так:

[Benchmark]
public async Task<string> Raw()
{
    var rawQuery = @"{ ""query"": ""query { me { firstName }}"" }";
    var response = await httpClient.PostAsync("", new StringContent(rawQuery, Encoding.UTF8, "application/json"));
    var responseJson = await response.Content.ReadAsStreamAsync();
    var qlResponse = JsonSerializer.Deserialize<JsonObject>(responseJson, options);

    return qlResponse["data"]["me"]["firstName"].GetValue<string>();
}

[Benchmark]
public async Task<string> StrawberryShake()
{
    var firstname = await strawberryShake.Me.ExecuteAsync(); // query { me { firstName }}
    return firstname.Data.Me.FirstName;
}

[Benchmark]
public async Task<string> ZeroQL()
{
    var firstname = await zeroQLClient.Query(static q => q.Me(o => o.FirstName));

    return firstname.Data;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Вот результаты:


BenchmarkDotNet=v0.13.1, OS=macOS Monterey 12.4 (21F79) [Darwin 21.5.0]
Apple M1, 1 CPU, 8 logical and 8 physical cores
.NET SDK=6.0.302
  [Host]     : .NET 6.0.7 (6.0.722.32202), Arm64 RyuJIT
  DefaultJob : .NET 6.0.7 (6.0.722.32202), Arm64 RyuJIT


Войти в полноэкранный режим Выйти из полноэкранного режима
Метод Среднее Ошибка StdDev Ген 0 Распределенный
Сырой 182,5 мкс 1.07 мкс 1.00 мкс 2.4414 5 КБ
StrawberryShake 190.9 мкс 0.74 мкс 0.69 мкс 3.1738 6 КБ
ZeroQL 185,9 мкс 1.39 мкс 1.30 мкс 2.9297 6 КБ

Как видно, метод Raw является самым быстрым.
Метод ZeroQL немного быстрее метода StrawberryShake.
Но в абсолютном выражении все они практически одинаковы. Разница ничтожно мала.

Резюме

Итак, с ZeroQL вы можете забыть о graphql и просто использовать полностью типизированный Linq-подобный интерфейс. Это мало повлияет на производительность. В будущем я планирую выяснить, как генерировать запросы для фрагментов, определенных в другой сборке, и подумать о том, как сделать начальную настройку более простой.

Спасибо за ваше время!
Если вам нравится то, над чем я работаю. Пожалуйста, дайте старт репозиторию на Github, чтобы больше людей могли это увидеть.

Ссылки

Github
NuGet

Оцените статью
devanswers.ru
Добавить комментарий