В своей предыдущей статье я познакомил вас с 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