Нативний GraphQL С# клієнт — ZeroQL

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Сьогодні GraphQL стає все більш популярною технологією для створення вебсервера. В той самий час, С# не має «нативного» клієнту, який би дозволив з ним працювати. Під «нативним» я розумію клієнт, що дозволить створювати та надсилати запити прямо завдяки C# без необхідності писати GraphQL вручну і бути впевненим, що якщо проєкт вдалося збілдити, то скоріше за все, працюватиме як треба.

Я постійно шукав подібний інструмент і найкращим, що мені вдалося знайти, це був Strawberry Shake. Він потребує писати GraphQL вручну, водночас, генерує всі необхідні врапери для використання всього цього добра із C#.

Користовувався ним постійно, але все ж таки хотілося мати щось ще більш нативне для платформи. Це б значно спростило інтеграцію між різними частинами застосунку. Ідеальним варіантом було б мати інтерфейс, що дозволяє створювати GraphQL виклики наступним чином:

var response = await client.Query(q => q
   .User(42,
       user => new
       {
           user.Id,
           user.FirstName,
           user.LastName
       }));

І це було б рівноцінним наступному:

query {
  user(id: 42) {
    id
    firstName
    lastName
  }
}

Після декількох експериментів мені вдалося зробити інструмент, що робить все як треба.

Зустрічайте ZeroQL! Це GraphQL клієнт для C#, що має Linq-like інтерфейс та чудову швидкодію, що практично еквівалентна до простого HTTP-виклику.

Погляньмо на неї в дії. Уявімо, що в нас є локальний HotChocolate вебсервер, що слухає на localhost:10000, і він має наступну 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!
}

Тепер створимо консольку, яка зможе з ним працювати. Це можна зробити наступними командами:

dotnet new console -o QLClient # create console app
cd QLClient # go to the project folder
curl http://localhost:10000/graphql?sdl > schema.graphql # fetch graphql schema from server
dotnet new tool-manifest # create manifest file to track NuGet tools
dotnet tool install ZeroQL.CLI # add ZeroQL.CLI NuGet tool
dotnet add package ZeroQL # add ZeroQL NuGet package
dotnet zeroql generate --schema .\schema.graphql --namespace TestServer.Client --client-name TestServerGraphQLClient --output Generated/GraphQL.g.cs # generate wrappers from the schema.graphql

Останній крок можемо помістити в окремий target в нашому csproj, щоб бути впевненим, що ми маємо всі останні зміни з schеma.graphql, якось так:

<Target Name="GenerateQLClient" BeforeTargets="BeforeCompile">
   <Exec Command="dotnet zeroql generate --schema .\schema.graphql --namespace TestServer.Client --client-name TestServerGraphQLClient --output Generated/GraphQL.g.cs" />
</Target>

На перший погляд, це може виглядати занадто складним, але тут немає нічого страшного. Нам потрібно зробити це тільки один раз.

Тепер, коли у нас все налаштовано, ми можемо виконати перший виклик. Змінимо Program.cs, щоб він виглядав наступним чином:

var httpClient = new HttpClient();
httpClient.BaseAddress = new Uri("http://localhost:10000/graphql");

var client = new TestServerGraphQLClient(httpClient);

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

Console.WriteLine($"GraphQL: {response.Query}"); // GraphQL: query { me { id firstName lastName } }
Console.WriteLine($"{response.Data.Id}: {response.Data.FirstName} {response.Data.LastName}"); // 1: Jon Smith

Як ми бачимо, підхід доволі прямолінійний. Створюємо GraphQL клієнт, пишемо запит за допомогою. C#, виконуємо його та отримуємо результати. Погляньмо на цей приклад більш детально.

Клас TestServerGraphQLClient згенерований за допомогою ZeroQL.CLI. У нього є метод Query, який приймає «graphql» лямбду (не C# expression). Ця «graphql» лямбда приймає аргумент з типом Query, котрий також згенерований. Потім source generator проаналізує, що ми використовуємо всередині «graphql» лямбди та трансформує це все у відповідний GraphQL запит. Після цього результати кладуться в спеціальний словник (dictionary). Цей словник тримає в собі «graphql» лямбду в текстовому вигляді та відповідний graphql запит.

[System.CodeDom.Compiler.GeneratedCode("ZeroQL", "1.1.2.0")]
public static class ZeroQLModuleInitializer
{
   [global::System.Runtime.CompilerServices.ModuleInitializer]
   public static void Init()
   {
       GraphQLQueryStore.Query["static q => q.Me(o => new { o.Id, o.FirstName, o.LastName })"] = "{ me { id firstName lastName } }";
   }
}

Далі, якщо ми звернемо увагу на сам метод Query, ми побачимо, що він має прихований аргумент queryKey:

public async Task<GraphQLResult<TResult>> Query<TResult>(
 Func<TQuery, TResult> query,
 [CallerArgumentExpression("query")] string queryKey = null)
{
 var graphQlResult = await this.Execute<Unit, TQuery, TResult>(OperationKind.Query, null, null, (Func<Unit, TQuery, TResult>) ((i, q) => query(q)), queryKey);
 return graphQlResult;
}

Атрибут CallerArgumentExpression — це нововведення, що з’явилося разом з C# 10. Він дозволяє отримати текстове відображення того, що було передано як аргумент в метод. У нашому випадку ми отримуємо аргумент query, який має наступний вигляд static q => q.Me(o => new { o.Id, o.FirstName, o.LastName }) в результаті queryKey буде мати текст "static q => q.Me(o => new { o.Id, o.FirstName, o.LastName })" — саме те, що необхідно, щоб отримати відповідний graphql зі спеціального словника. Як результат, ми завжди знаємо, який graphql запит необхідно відправити у кожному окремому випадку. Важливо зазначити, що генерація тіла graphql запиту відбувається під час компіляції. Тому під час виконання залишається лише дістати запит зі словника та зробити простий HTTP виклик. Завдяки цьому ми уникаємо будь-яких не потрібних розрахунків.

Ще одна важлива замітка. «graphql» лямбда повинна бути статичною. Є дві причини для цього. Перша полягає в тому, що аналізувати її за допомогою генератора коду (source generator) так значно простіше, оскільки повністю відсутні будь-які змінні, що створені за межами скоупу цієї лямбди. По-друге, якщо ви плануєте передавати будь-які змінні наступним чином:

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

То це найпростіший спосіб переконатися, що всі вхідні параметри проаналізовані як потрібно. Крім того, завдяки такому підходу ми маємо змогу передати параметри та серіалізувати їх перед посилання запиту.

Тепер погляньмо, що ще вміє ZeroQL. Наприклад, ми можемо глибоко занурюватися в типи, щоб отримати складніші поля:

var variables = new { Id = 1 };
var response = await client.Query(
   variables,
   static (i, q) => q
       .User(i.Id,
           o => new
           {
               o.Id,
               o.FirstName,
               o.LastName,
               Role = o.Role(role => role.Name) // dig inside the Role field
           }));


Console.WriteLine($"GraphQL: {response.Query}"); // GraphQL: query GetUserWithRole($id: Int!) { user(id: $id) { id firstName lastName role { name }  } }
Console.WriteLine($"{response.Data.Id}: {response.Data.FirstName} {response.Data.LastName}, Role: {response.Data.Role}"); // 1: Jon Smith, Role: Admin

Також можемо отримати декілька полів за один запит:

var variables = new { Id = 1 };
var response = await client.Query(
   variables,
   static (i, q) => new
   {
       MyFirstName = q.Me(o => o.FirstName),
       User = q.User(i.Id,
           o => new
           {
               o.FirstName,
               o.LastName,
               Role = o.Role(role => role.Name)
           })
   });

Console.WriteLine($"GraphQL: {response.Query}"); // GraphQL: query GetUserWithRole($id: Int!) { me { firstName }  user(id: $id) { firstName lastName role { name }  } }
Console.WriteLine($"Me: {response.Data.MyFirstName}, User: {response.Data.User.FirstName} {response.Data.User.LastName}, Role: {response.Data.User.Role}"); // Me: Jon, User: Jon Smith, Role: Admin

Можемо виконувати мутації:

var response = await client.Mutation(m => m.AddUser("Jon", "Doe", o => o.Id));

Console.WriteLine($"GraphQL: {response.Query}");
Console.WriteLine($"Id: {response.Data}");

По факту мутації нічим не відрізняються від запитів. Разом з ними можна використовувати будь які конструкції що наявні для запитів.

Фрагменти

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 запит 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; }
}

та метод розширення (extension method):

public static class UserFragments
{
   [GraphQLFragment]
   public static UserModel AsUserModel(this User user)
   {
       return new UserModel
       {
           Id = user.Id,
           FirstName = user.FirstName,
           LastName = user.LastName
       };
   }
}

Зараз ми можемо переписати C# запит наступним чином:

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())
       };
   }
}

І знову ж таки, все працює як ми очікували.

Обмеження

Є тут одна штука, що може ускладнити життя. Коли генератори аналізують код, то вони аналізують і кожен підзапит. Що логічно. Це все працює до тих пір, поки підзапит знаходиться в тій самій проєкті/збірці (assembly), що й сам виклик. У цьому випадку генератор не має доступу до коду, оскільки він запускається тільки в межах однієї збірки.

У результаті, graphql запит не буде створений. Якщо ми хочемо, щоб фрагменти працювали, вони повинні бути об’явлені в збірці разом з викликом методу client.Query чи client.Mutation. Це обмеження точно може бути завадою для деяких підходів та зруйнувати логічні очікування. Водночас наскільки часто є необхідність розділити, скажімо, SQL запити між різними збірками? Така потреба може з’явитися, але зовсім не часто. У найгіршому випадку, необхідно закопіпастити фрагмент з одного проєкт в інший.

У мене є ідея, як це можна обійти, але, на разі, це тільки ідея з дуже цікавою реалізацією.

Швидкодія

Я говорив, що ZeroQL має просто чудову швидкодію. Наскільки вона чудова? Репозиторій має бенчмарк. Він порівнює захардкоджений graphql запит(Raw), 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();
   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

Method

Mean

Error

StdDev

Gen 0

Allocated

Raw

182.5 μs

1.07 μs

1.00 μs

2.4414

5 KB

StrawberryShake

190.9 μs

0.74 μs

0.69 μs

3.1738

6 KB

ZeroQL

185.9 μs

1.39 μs

1.30 μs

2.9297

6 KB

Як ми бачимо, захардкоджений варіант найшвидший. ZeroQL трохи швидший за StrawberryShake. Але в абсолютних величинах вони практично однакові. Різниця просто мізерна.

Висновки

Отже, за допомогою ZeroQL ми можемо забути про graphql на клієнті й просто використовувати повністю типізований Linq-like інтерфейс. При цьому, це не матиме ніякого впливу на швидкодію застосунку під час виконання.

На майбутнє, я маю плани розібратися з генерацією фрагментів, яка б працювала завжди, також думаю над тим, як зробити початкову інсталяцію більш прямолінійною.

Посилання

Github репозиторій ZeroQL
ZeroQL — C# friendly GraphQL(англомовна версія)
ZeroQL — C# GraphQL client adds fragments support(англомовна версія)

Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU

👍ПодобаєтьсяСподобалось5
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі