Конвертуємо наявний REST API в GraphQL API

У цій статті я хочу продемонструвати новий спосіб створення Web API за допомогою GraphQL. Ми розглянемо його переваги порівняно з традиційним REST API та те, як ми можемо перетворити наявний REST API для підтримки GraphQL.

Over-fetching та under-fetching

Перш за все нам потрібно визначити, які проблеми ми можемо мати з REST API і як GraphQL може допомогти їх вирішити. Так, наприклад, ними можуть бути over-fetching та under-fetching. Вони дуже поширені у випадку, коли ми намагаємося створювати наш API з використанням best practice.

Тим часом як ці проблеми дуже часто просто ігнорують, оскільки сам REST не має зручних способів для їхнього уникнення. Тому під час використання деякі endpoint-и будуть мати over-fetching, а інші under-fetching. Under-fetching відбувається, коли початковий запит API не надає достатньо даних, змушуючи застосунок робити додаткові запити. Over-fetching, навпаки, відбувається, коли запит API повертає більше даних, ніж потрібно клієнту.

Розглянемо сценарій, коли розробник працює над профілем користувача, який потребує завантаження особистої інформації користувача (наприклад, ім’я, електронна пошта та фотографія профілю) та ролей користувача (наприклад, адміністратор, підписник тощо). У традиційному REST-підході отримання цих даних може вимагати принаймні двох endpoint-ів: одного для особистої інформації та іншого для ролей.

Якщо ви отримуєте дані з endpoint-y для особистої інформації, і вона не має ролі користувача, ви стикаєтеся з under-fetching і повинні зробити додатковий запит до endpoint-у з ролями. Навпаки ж, якщо endpoint з особистою інформацію повертає всі доступні дані про користувача, зокрема деталі, які не потрібні для поточного UI, ви стикаєтеся з over-fetching, витрачаючи пропускну спроможність та потенційно сповільнюючи застосунок.

GraphQL допомагає вирішити цю проблему досить просто. Замість кількох endpoint-ів існує лише один endpoint. Усі запити проходять через нього і самостійно визначають, конкретно які дані потрібні клієнту, тим самим усуваючи under-fetching.

Ми можемо зробити один запит GraphQL:

query {
  user(id: 1) {
    name
    email
    profilePicture
    roles {
      title
    }
  }
}

Тут ми запитуємо конкретні поля (ім’я, електронну пошту, зображення профілю та ролі) для користувача з ідентифікатором один. Наш API повинен повернути JSON, який відображатиме структуру запиту і міститиме лише запитувані поля.

Гнучкість запитів GraphQL також вирішує проблему з over-fetching. Оскільки клієнт точно вказує, які дані йому потрібні, сервер ніколи не надсилатиме непотрібні дані. Використовуючи наш приклад, якщо нам не потрібне фото профілю користувача, ми можемо просто не додавати його до запиту.

query {
  user(id: 1) {
    name
    email
    roles {
      title
    }
  }
}

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

Конвертуємо REST API в GraphQL API

Початкові налаштування

Розгляньмо, як ми можемо перетворити наявний REST API в GraphQL API. Початкові endpoint-и виглядають так:

app.UseEndpoints(o =>
{
    o.MapSwagger();
    o.MapGet("/users", 
        async (int page, int size, UserService service) => await service.GetUsers(page, size));

    o.MapGet("/users/{id}",
        async (int id, UserService service) => await service.GetUser(id));

    o.MapGet("/users/{id}/roles",
        async (int id, RoleService service) => await service.GetRolesByUserId(new[] { id }));

    o.MapPost("/users", 
    async (UserCreationRequest request, UserService service) => await service.CreateUser(request.Name, request.Name));
});

Фактично це ті самі endpoint-и, які були показані в прикладі under-fetching та over-fetching на початку.

Стаття містить лише обраний вихідний код, аби бути короткою.

Якщо ви хочете побачити повний вихідний код, перейдіть до демонстраційного репозиторію github.com/byme8/DemoGraphQL

Існують дві гілки:

  • master — містить початковий REST API;
  • graphql — містить кінцевий GraphQL API.

Почнемо ми з того, що встановимо Nuget-пакет HotChocolate.AspNetCore.

dotnet add package HotChocolate.AspNetCore

HotChocolate — це набір інструментів, який дозволяє отримувати та обробляти GraphQL запити і має чудову інтеграцію з AspNet Core. Повну документацію можна знайти тут.

Тепер налаштуймо сам сервер:

+ services
+    .AddGraphQLServer()
+    .AddQueryType<Query>();

// ...

app.UseEndpoints(
    o =>
    {
+       o.MapGraphQL();
        o.MapSwagger();
        o.MapGet("/users", 
            async (int page, int size, UserService service) => await service.GetUsers(page, size));
// ...
+ public class Query 
+ {
+   public DateTimeOffset Utc() => DateTimeOffset.UtcNow;
+ }  

Зараз якщо ми запустимо наш сервер, ми можемо отримати доступ до GraphQL endpoint-у за адресою localhost:10000/graphql. Якщо відкрити через браузер, ми побачимо GraphQL IDE. Тут можна перевірити GraphQL-схему і виконати запити.

Початкові налаштування готові, і ми можемо трансформувати наші REST API.

Створення запитів

Почнемо ми з такого endpoint-у:

 o.MapGet("/users/{id}", async (int id, UserService service) => await service.GetUser(id));

Для цього створимо новий метод в класі Query:

public class Query 
{
  public DateTimeOffset Utc() => DateTimeOffset.UtcNow;

+ public Task<User?> GetUser(int id, [Service] UserService userService)
+   => userService.GetUser(id);
}  

Готово! Якщо ми відкриємо наш GraphQL IDE та перевіримо GraphQL-схему (табка Schema Definitions), виглядатиме вона так:

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

type User {
  id: Int!
  name: String!
  email: String!
}

HotChocolate автоматично виявляє типи, що ми використовуємо, та додає їх до схеми. Перейдемо до табки Operations та виконаймо перший GraphQL-запит:

query {   user(id: 1) {     id     name   } }

Відповідь буде такою:

{
  "data": {
    "user": {
      "id": 1,
      "name": "User Name 1"
    }
  }
}

Ось ми реалізували наш перший GraphQL API.

Зараз рухаємося до наступного endpoint-у. Це буде /users.

o.MapGet("/users", async (int page, int size, UserService service) => await service.GetUsers(page, size));

Однак зробимо це трохи інакше. Ми все ще можемо додати його до класу Query, але запихувати все в один клас — не найкраща ідея. Ми можемо розділити його так, щоб це виглядало дуже схоже на те, що ми маємо під час використання контролерів з AspNet Core.

Щоб це працювало, створимо новий клас UserQueryExtensions та додамо нові endpoints туди. Далі я буду використовувати визначення GraphQL-поле, щоб показати еквівалент REST endpoint-ів, але для GraphQL.

Це все буде мати такий вигляд:

 services
    .AddGraphQLServer()
    .AddQueryType<Query>()
+   .AddTypeExtension<UserQueryExtensions>();

// ..
public class Query 
{
  public DateTimeOffset Utc() => DateTimeOffset.UtcNow;

- public Task<User?> GetUser(int id, [Service] UserService userService)
-   => userService.GetUser(id);
}

+ [ExtendObjectType(typeof(Query))]
+ public class UserQueryExtensions
+ {
+     public Task<IEnumerable<User>> GetUsers(int page, int size, [Service] UserService userService)
+         => userService.GetUsers(page, size);
+     
+     public Task<User?> GetUser(int id, [Service] UserService userService)
+         => userService.GetUser(id);
+ }

Тут ми створили клас, який розширює інший GraphQL тип. У нашому випадку це кореневий тип Query. Також я перемістив GraphQL-поле GetUser з Query та додав його до нового розширення. Зверніть увагу на секцію конфігурації на початку. Вам потрібно вручну додати ці розширення до налаштувань. Інакше HotChocolate не побачить їх. Це можна автоматизувати за допомогою офіційного source generator-у, але я вирішив зробити це вручну для наглядності.

Тепер GraphQL схема має такий вигляд:

type Query {
  utc: DateTime!
+ users(page: Int!, size: Int!): [User!]!
  user(id: Int!): User
}

type User {
  id: Int!
  name: String!
  email: String!
}

Як ми бачимо з точки зору GraphQL, змінилася лише одна річ. А саме: додалося нове GraphQL-поле. Це саме те, що нам потрібно.

Складніші розширення

Додамо ролі до нашої GraphQL-схеми. Початковий REST endpoint виглядає так:

o.MapGet("/users/{id}/roles", async (int id, RoleService service) => await service.GetRolesByUserId(new[] { id }));

Ми можемо зробити це двома способами. Перший спосіб більш схожий на REST. Ми просто створимо ще одне GraphQL-поле, яке повертатиме список ролей для конкретного користувача. Схема буде виглядати так:

type Query {
  utc: DateTime!
  users(page: Int!, size: Int!): [User!]!
  user(id: Int!): User
+ userRoles(userId: Int!): [Role!]!
}

type User {
  id: Int!
  name: String!
  email: String!
}

+ type Role {
+  id: Int!
+  name: String!
+ }

Але існує більш GraphQL-ий підхід. Ми можемо розширити сам тип User. Так, коли ми отримуємо користувача, ми можемо додатково запитати і його ролі. У цьому разі в нас буде така GraphQL-схема:

type Query {
  utc: DateTime!
  users(page: Int!, size: Int!): [User!]!
  user(id: Int!): User
}

type User {
  id: Int!
  name: String!
  email: String!
+ roles: [Role!]!
}

+ type Role {
+  id: Int!
+  name: String!
+ }

Зробімо це GraphQL-льно. Для цього нам потрібно створити ще один клас розширення. Однак ми розширимо тип User, а не Query:

[ExtendObjectType(typeof(User))]
public class UserRolesQueryExtensions
{
    public async Task<Role[]> GetRoles([Parent] User user, [Service] RoleService roleService)
    {
        var rolesByUserId =  await roleService.GetRolesByUserId(ids);
        return rolesByUserId[user.Id];
    }
}

Це спосіб дозволить нам побудувати наступний запит з початку статті, де ми говорили про under and over fetching:

query GetUserProfile($userId: Int!) {
  user(id: $userId) {
    id
    name
    email
    roles {
      id
      name
    }
  }
}

Але як це працює? GraphQL-сервер отримує запит, він мапиться до відповідних GraphQL-розширень. Тут буде два з них — GetUser та GetRoles.

Перше розширення буде виконано, і його результат буде передано до другого. Зрештою ми маємо один запит від клієнта до GraphQL-серверу, який виконав два GraphQL-поля. Тоді клієнт отримає кінцевий результат швидше, оскільки все відбувається локально на сервері, і клієнту не потрібно слати ще один запит після того, як отримав користувача.

Однак існує одна проблема з таким підходом. Розгляньмо такий запит:

query GetUsers($page: Int!, $size: Int!) {
  users(page: $page, size: $size) {
    id
    email
    roles {
      id
    }
  }
}

Отже в нас є GraphQL-поле, яке повертає список об’єктів. Потім для кожного об’єкта ми маємо розширення. Якщо ми залишимо все так, як зараз, це може бути катастрофою.

Проаналізуймо, що відбувається:

  • Сервер GraphQL виконує поле GetUsers GraphQL і отримує п’ять об’єктів.
  • Сервер GraphQL виконує поле GetUserRoles GraphQL для першого об’єкта.
  • Сервер GraphQL виконує поле GetUserRoles GraphQL для другого об’єкта.
  • Сервер GraphQL виконує поле GetUserRoles GraphQL для третього об’єкта.
  • Сервер GraphQL виконує поле GetUserRoles GraphQL для четвертого об’єкта.
  • Сервер GraphQL виконує поле GetUserRoles GraphQL для п’ятого об’єкта.
  • Сервер GraphQL повертає кінцевий результат.

Якщо GetUserRoles звертається до бази даних для кожного об’єкта — це проблема. Це може суттєво погіршити швидкодію запиту.

Щоб вирішити цю проблему, GraphQL-сервер має таку штуку як даталоадери (data loaders). Вони дозволяють групувати виконання GraphQL-полів та отримувати дані за один раз. Ось як би виглядало покращене поле GetUserRoles:

[ExtendObjectType(typeof(User))]
public class UserRolesQueryExtensions
{
    public async Task<Role[]> GetRoles(
        IResolverContext context,
        [Parent] User user, [Service] RoleService roleService)
    {
        return await context.BatchDataLoader<int, Role[]>(async (ids, ct)
            => await roleService.GetRolesByUserId(ids))
            .LoadAsync(user.Id);
    }
}

Тут створюється екземпляр batch data loader-у на першому виклику та додає поточний id до списку. Потім для кожного наступного виклику ми просто запам’ятовуємо id користувача. Коли у нас є повний список, викликається лямбда із цими ідшниками, і ми можемо отримати ролі користувачів для всіх користувачів одразу. Таким чином, лог виглядатиме так:

  • GraphQL-сервер виконує GetUsers GraphQL полеі отримує п’ять елементів.
  • GraphQL-сервер виконує GetUserRoles GraphQL поле, яке створює пакетний data loader і передає id першого елемента.
  • GraphQL-сервер виконує GetUserRoles GraphQL поле, яке передає id другого елемента.
  • GraphQL-сервер виконує GetUserRoles GraphQL поле, яке передає id третього елемента.
  • GraphQL-сервер виконує GetUserRoles GraphQL поле, яке передає id четвертого елемента.
  • GraphQL-сервер виконує GetUserRoles GraphQL поле, яке передає id п’ятого елемента.
  • GraphQL-сервер виконує data loader.
  • GraphQL-сервер повертає кінцевий результат.

Повну документацію можна знайти тут.

Створення мутацій

Ще одна маленька річ про GraphQL. Можливо, ви забули, але був ще один endpoint:

 o.MapPost("/users", async (UserCreationRequest request, UserService service) 
              => await service.CreateUser(request.Name, request.Name));

Семантично це має інше значення порівняно з попередніми endpoint-ами. Це операція мутації, яка створює нову сутність. GraphQL розрізняє операції читання та редагування. Усі операції читання мають бути визначені у типі Query, а всі операції редагування — у типі Mutation. Тому створення цього endpoint-у як мутації було б гарною ідеєю.

Ось як ми можемо це зробити:

services
    .AddGraphQLServer()
    .AddQueryType<Query>()
+   .AddMutationType<Mutation>()
+   .AddTypeExtension<UserMutationExtensions>();

// ...

public class Mutation
{

}

// ...

[ExtendObjectType(typeof(Mutation))]
public class UserMutationExtensions
{
    public Task<User> CreateUser(string name, string email, [Service] UserService userService)
        => userService.CreateUser(name, email);
}

Це той самий підхід, як і з типом Query, але тепер ми маємо тип Mutation як кореневий тип.

Висновки

На початку ми коротко розглянули причини, чому був створений GraphQL. Under-fetching та over-fetching є загальними викликами традиційних RESTful API, що потенційно може призвести до не ефективного та поганого користувацького досвіду. GraphQL пропонує спосіб, як цього уникнути, дозволяючи клієнтам точно вказати, які дані їм потрібні, зменшуючи непотрібні запити та розмір даних що передаються.

Ця гнучкість оптимізує продуктивність та використання мережі, роблячи GraphQL привабливим вибором для сучасної веброзробки.

У другій частині ми розглянули, як налаштувати GraphQL сервер.З’ясували, як створювати GraphQL-поля і що потрібно враховувати під час трансформації наявних REST endpoint-ів у GraphQL-поля.

Сподіваюся, стаття була корисною. Наступного разу будемо розглядати, як проводити інтеграційне тестування наших GraphQL API.

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

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному4
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

Маю нагоду деякий час працювати з GQL зробленому з HotChocolate. Не виходить в мене полюбити GQL. Сам для себе досі не можу зрозуміти, коли варто було б обирати GQL для розробки АПІ, щоб дійсно отримати від цього помітну вигоду. Можу погодитись с автором, що можливо GQL допоможе зекономити за рахунок пропускної спроможності мережі. Але.

На прикладі з ролями, ми завжди виберемо з бази ід і ім’я, навіть якщо клієнт запитує лише ід або лише ім’я. Це можна було би вирішити, але в прикладі вище буде саме так. Інша проблема виникає якщо ми хочемо для поточного користувача отримати не всі ролі, а лише перші 3, ім’я яких починається з «adm». Не жорстко перші 3, ім’я яких починається з «adm», а з можливістю вказувати якийсь фільтр та сортування. Можливо і можна зробити, щоб при цьому генерувався відповідний оптимальний запит до БД, але я сходу навіть не знаю як. І приклад з ролями — це дуже простий приклад. Коли ваша бізнес логіка і структура даних складніша, запити до БД будуть неоптимальнішими і неоптимальнішими. Зрештою, неоптимальні запити до БД можуть невілювати економію на пропускній спроможності.

Інший мінус GQL (для мене) — складність його розробки і підтримки, порівнюючи с REST, значно вища в проектах складніших за хелоу ворлд. Робота з файлами там також певний біль.

ми завжди виберемо з бази ід і ім’я

Це можна оптимізувати. В HotChocolate є всі необхідні штуки для цього. Просто не хотілось ускладнювати реалізаацію.

ми хочемо для поточного користувача отримати не всі ролі

Нічого не заважає добавити фільтри в GraphQL філд. В результаті, матимемо такий extension

Task<Role[]> GetRoles([Parent] User user, int page, int size, string filter, [Service] RoleService roleService)

Ну і сама кверя

query GetUsers($userId: Int!, $page: Int!, $size: Int!, $filter: String!) {
  user(id: $userId) {
    id
    email
    roles(page: $page, size: $size, filter: $filter) {
      id
    }
  }
}
складність його розробки

В чому саме складність розробки? По факту ті ж самі контролери з іншими атрибутами.

складність його підтримки на великих проектах

Мені здається навпаки. Якщо ми маємо великий REST проект то практично завжди одна й та ж сутністі буде використовуватися в різних місцях по різному. В результаті, будуть додаткові ендпоїнти чи сервіси які роблять одне й теж, але трохи по різному. Відслідковувати це у великому проекті ще та проблема. З GraphQL є нас буде одна реалізація яку можна приатачити в потрібне місце.

Такий приклад. Приклад не ідеальний, місцями не логічний, але нам важлива структура даних і як робити GQL запити на подібній структурі даних під певні вимоги.
Є у нас список якихось процесів: Id, Name. Є сторінка, де нам їх треба показати. Є GQL запит, який їх повертає у такому вигляді:
[ { id: 1, Name: "word.exe" }, { id: 2, Name: "openofficeword.exe" } ]

Для цього пишемо простенький запит через EF:
return context.Select(p => new Process{...});

Є у нас також список дозволених розширень файлів для процесу. Табличка БД має приблизно наступний вигляд: Id | ProcessId | Extension
І допустим в нас там є такі записи:
100, 1, doc
101, 1, docx
102, 2, doc
103, 2, docx

З’являється сторінка, де нам потрібно показати список типу такого: процес, кількість дозволених розширень.

Process, Extensions count
word.exe, 2
openofficeword.exe, 2

Також повинна бути опція показувати цей список в розгорнутому виді з розширеннями:

Process, Extensions count
word.exe, 2
doc, 1
docx, 1
openofficeword.exe, 2
doc, 1
docx, 1

Отже для нової сторінки нам потрібно повертати для різних сценаріїв наступний джсон:

Раз:
[ { id: 1, Name: word.exe, Count: 2 } ]

Два:
[ { id: 1, Name: word.exe, Count: 2, Extentions: [ { extension: doc, count: 1 }, { extension: docx, count: 1 } ] } ]

Також є наступні вимоги:
— фільтрувати по Extensions count. Наприклад: показати лише ті процеси, у яких загальний Extensions count > X; показати лише ті процеси всередині яких є розширення з count > X; показувати всередені лише розширення із заданого списку;
— сортувати по Extensions count. Сортувати процеси по count; сортувати розширення всередині процеса по count;

У мене є 2 вариінти як це можна зробити:
1. Розширити існуючий GQL запит (тип), додавши туда відповідні властивості та заповняти їх за допомогою DataLoader-ів. При цьому певно прийдеться самому реалізовувати щось для сортування та фільтрування. Не впевнений, що це можна реалізувати, тому що я такого взагалі не робив.
2. Створити новий GQL запит з іншим SQL запитом.

Другий варіант мені здається легшим в реалізації. В першому я просто не впевнений чи то взагалі вийде. Що би ви запропонували в такому випадку (3, 4 вариінта також можна пропонувать)?

Я б зробив якось так:
``` graphql
type Query {
processes: [Process]
}

type Process {
id: Int!
name: String!
extenstions: ExtentionsQueryNode
}

type ExtentionsQueryNode {
count: Int!
extentions: [Extention]!
}

type Extention {
extension: String!
count: Int!
}
```

`` extenstions: ExtentionsQueryNode `` - це окремий резолвер який працює саме з extention-ами, приймає `` Process `` як parent, передає його ід в лоадер. Далі на рівні sql-я буде запит в наступному стилі:
``` sql
select count(*) as Count,
(select extention, count(*) as Count from Extentions where ProcessId = @ProcessId groupBy extention FOR JSON PATH) as JSON
from Extentions
where ProcessId = @ProcessId
```
З такою реалізацією ми зрівняємося з REST. Додатково можна глянути що саме юзер вибрав із `` ExtentionsQueryNode `` і вирубати частину sql квері якщо вона не потрібна. Так наприклад, якщо треба тільки count, то не робити inner select і навпаки. Ну і сортування тут можна запроста запихнути в inner select

Я правильно тебе зрозумів, ти пропонуєш отримувати extenstions за допомогою DataLoader-а? Якщо так, то можеш, будь-ласка, додати деталей по наступним пунктам:
1. Не зовсім зрозумів твій SQL запит. Якщо ми говоримо про DataLoader для extensions, то він прийматимиме список ID процесів і на виході повертатимиме щось типу IReadOnlyDictionary<int, List<MyExtension>>, де ключом є ід процеса. Так от, що таке select count(*) as Count (перша строка запиту) в твоєму SQL запиті? Виглядає як Count на рівні процеса. Але нащо він нам в DataLoader-і для extensions?
2.

Додатково можна глянути що саме юзер вибрав із ExtentionsQueryNode і вирубати частину sql квері якщо вона не потрібна

Можна детальніше куди саме глянути і як саме вирубити?
3.

Ну і сортування тут можна запроста запихнути в inner select

Як в DataLoader-і отримати встановлене клієнтом сортування для extentions?
Давай так, потрібно підтримати наступний GQL запит:
query { processes( where: { extentions: { some: {} } } ) { count, extentions( where: {...}, order: {...}, take: 3, skip: 2 ) { extensions count } } }
Як таке зробити, якщо ми використовуємо DataLoader і щоб при цьому на SQL запит впливали встановленні фільтри, сортування, пагінація?

Зробив повноціний приклад github.com/...​/GraphQLSQLProcessExample
Почати дивитися можна з ExtensionService.GetExtensions.

1. Виглядає як Count на рівні процеса. Але нащо він нам в DataLoader-і для extensions?

Так, то я тупонув. В цьому прикладі використовується правильний count. Він буде рахувати саме extensions

2. Можна детальніше куди саме глянути і як саме вирубити?

Все показано в ExtensionService, а саме в GetCountSql та GetExtensionsSql.

3. Як в DataLoader-і отримати встановлене клієнтом сортування для extentions?

В моєму прикладі це є. Тільки зараз зрозумів що реалізував не зовсім те що тут в прикладі. У мене сортування на рівні extentions. Тут сортування на рівні processes by extensions. Щоб зробити як тут треба розширити ProcessService і генерити кастомний ORDER BY на базі ProcessOrderByField. Зараз там все дуже примітивно зроблено, але розширити повинно бути дуже просто. Думаю займусь цим трохи пізніше.

Перевів на dapper і змінив підхід до генерації в окремому бранчі. Я б сказав стало більш прямолінійно.

github.com/...​ee/feature/move-to-dapper

Дякую! Я подивлюсь найближчим часом і потім ще відповім.

Перш за все дуже тобі дякую за те, що знайшов час зробити наглядний приклад!

Все, що буде сказано далі, це не критика, а лише мій суб’єктивний погляд і, вірогідно, не дуже добре концептуальне розуміння GQL як такого.
Не дивлячись на простий сценарій, для мене особисто код виглядає доволі складним і дуже багато (як для доволі простого сценарію) ручної роботи. Якщо нам потрібно якось більш гнучніше фільтрувати (по декільком полям, in, not in, >, <, contains, any...) або сортувати, я боюсь уявити у що перетвориться код. У запитах ти маєш сталий список колонок для вибору, що буде не зовсім ефективно. Ти вище писав:

Це можна оптимізувати. В HotChocolate є всі необхідні штуки для цього. Просто не хотілось ускладнювати реалізаацію.

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

І можна ще пару питань, будь-ласка:

1. По структурі даних. Я хотів, щоб загальна кількість розширень була на рівні процесів, а Extensions був список об’єктів:
[ { id: 1, Name: word.exe, Count: 2, Extentions: [ { extension: doc, count: 1 }, { extension: docx, count: 1 } ] } ]
В твоему прикладі ти зробив трохи іншу структуру. В даному випадку може це і не сильно важливо, але тим не менше, якщо б потрібно було саме так, то можна було б це зробити наступним чином: отримуємо список вибраних полів для процеса (типу як ти це робиш в GetExtensions) і якщо там є кількість, то робимо відповідний запит і заповнюємо поле?

2. А можна якось отримати список полів, які ми вказуємо у стандартному where і у стандартному order? По типу як ти це робиш для вибраних полів?

3. Для GetExtensions резолвера ти визначив where параметр де можна задати поле filter, яке завжди працюе по eq. А як (чи можна?) зробити, щоб:
a) був стандартний фильтр where. Я пробував зробити щось таке:
public class ExtensionQueryNodeType : ObjectType<ExtensionQueryNode> {     protected override void Configure(IObjectTypeDescriptor<ExtensionQueryNode> descriptor)     {         descriptor.Field(o => o.Extensions).UseFiltering();         base.Configure(descriptor);     } }
Але чомусь .UseFiltering() метода тут немає. Хоча у мене в проекті таке використовується і працює.
b) поле filter працювало з варіантами eq, neq, in, nin?
c) був стандартний фильтр where + свій filter з варіантами eq, neq, in, nin?

В чому саме складність розробки?

Прямо зараз є проблема, яку я не можу вирішити нормальним (в мому розумінні нормальності) шляхом. Є запит, який повертає об’єкт Т. У моєму коді я повертаю IQueryable в методі. Маючи це, я можу використовувати where у цьому запиті і фільтрувати по властивостям об’єкта Т.

Також, у Т є властивість Х з кількома властивостями. Ця властивість Х заповнюється за допомогою DataLoader-а.

Що мені потрібно. Мені потрібно додати можливість задавати певний додатковий фільтр, зберігаючи можливість фільтрації, яка була до цього. Тобто додати в where додаткове MyFilter. Поля MyFilter в вихідному об’єкту Т немає. Це додаткове поле я сам повинен опрацювати в запиті і в DataLoader-і.

Проблема 1: як для цього конкретного запита розширити where, додавши туди своє поле зі своїми варіантами фільтрації (eq, neq) і як отримати його значення в методі запиту і дата лоадері?

Проблема 2: проблему 1 частково можна обійти, просто додавши вхідний параметр до методу запита. В такому випадку я можу опрацювати його і повернути потрібний IQueryable. Але наразі я не знаю як цей вхідний параметр я можу отримати в дата лоадері.

А в іншу сторону немає гайда? Як раз на днях замислювався наскільки швидко можна переписати квері і мутації на REST

Тут трохи неправильний приклад, на мою думку. Класичний мап не покаже всіх можливостей граф кьюел. Його придумали, щоб у бізнес кейсі покажи мені всіх юзерів з групи даного юзера і їх права, а також у кого є машина і собака породи коллі воно не потребувала змін беку, і виконувалося за один хіт в базу. Коли у тебе йде другий кол в репо — то вже щось про мову та глобус, або про тролейбус з хліба. Графкьюел то досить тонка абстракція над базою даних, і стандартний бойлерплейт дотнету з репозиторіями тут як козі сідло, на мою думку

На моєму проекті є декілька місць де зроблено саме так, якраз щоб забезпечити мінімальний час відповіді. В той самий час, реалізувати та підтримувати це буде на порядки складніше. Тому зазвичай буде достатньо підходу що описано в статі. Він уже може нівелюювати суттєвий шматок часу під час початкового завантаження сторінки, оскільки мінімізує latency від запитів, що може бути 20-50мс на запит. Це чисто відправка пакету через інтернет до серверу.

Може я туповатий, але і так і так 1 кол на бек і 2 в базу, просто дата менеджер клас абстрагований в мідлвар граф кьюел. Де бенефіти?

Один виклик на бек в REST буде тільки якщо створити ендпоїнт спеціально для цього юзкейсу. Юзкейсів для тих самих даних багато. Будуть «дублікати» які повертають одне й те саме, але трохи по іншому. Щоб цього уникнути треба буде робити декілька незалежних ендпоїнтів аби не тягнути все зразу коли воно не портібно.

В результаті, ми попадаємо в ситуацію, що є два послідовні запити. В REST ми отримуємо першу відповідь і ідемо за додатковою інвормацією. В цьому випакду timeline буде наступний.
При нормальному інеті:
— починаємо перший запит
— 20мс доставка запиту до серверу
— 10мс обробка запиту
— 20мс доставка відповіді назад

— беремо ідшники і робимо другий запит
— 20мс доставка запиту до серверу
— 10мс обробка запиту
— 20мс доставка відповіді назад

Ітого: 100ms

При мобільному інеті:
— починаємо перший запит
— 50-100мс доставка запиту до серверу
— 10мс обробка запиту
— 50-100мс доставка відповіді назад

— беремо ідшники і робимо другий запит
— 50-100мс доставка запиту до серверу
— 10мс обробка запиту
— 50-100мс доставка відповіді назад

Ітого: 220ms — 420ms

З GraphQL
При нормальному інеті:
— починаємо перший запит
— 20мс доставка запиту до серверу
— 10мс обробка запиту
— беремо ідшники і робимо другий запит
— 10мс обробка запиту
— 20мс доставка відповіді назад

Ітого: 60ms — 40% покращення

При мобільному інеті:
— починаємо запит
— 50-100мс доставка запиту до серверу
— 10мс обробка запиту
— беремо ідшники і робимо другий запит
— 10мс обробка запиту
— 50-100мс доставка відповіді назад

Ітого: 120ms — 220ms ~ 50% покращення

Це при умові що сервер близько. Якщо він на сусідньому континенті, то нетворк буде x2-3. Покращення будуть ще суттєвіші. При цьому це все працює на існуючій інфрастурктурі і ніяких магічних налаштувать бази не треба.

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

Що заважає зробити витягування id з іншого сервісунна боці бекенду як в graphql? Ви ж не будете дублювати цей функціонал в кожній view?
Та й зазвичай, одного ендпоінта з тими самими даними достатньо для різних сторінок. А, наприклад, для сторінки перегляду профілю є get/id.
Але, при цьому, ви контролюєте всі запити до бд, join’и в т.д.

Де факто ви виносите комплексіті на фронт. Тепер він вирішує за потрібний набір даних. Якщо вдалося це продати разом з рефакторингом беку — честь та хвала. У мене в кривавому ентерпрайзі максимум-то оптимізація крітікал наборів колів. Кляті дата менеджери і юніт оф ворки. О, до речі, що в прикладі з транзакційністю? Я розумію, що там рід, а якщо писати в декілька сутностей?

якщо писати в декілька сутностей?

Мається на увазі декілька GraphQL філдів? Кожен з них буде запускати в своєму скоупі, тому транзакції між ними не вийде. Якщо потрібна транзакційність, то це прямо конкретний тісно повязаний use case і його варто обєднати під одну мутацію(один GraphQL філд). Далі уже як звичайно.

GraphQL це інструмент, який покликаний вирішити конкретні проблеми компанії, яка його створила. На мою думку, ці проблеми повʼязані з великою кількістю зовнішніх консюмерів з різними потребами. Якщо це ваш кейс, GraphQL гарне рішення. Водночас, якщо у вас 1-2 інхаус консюмери, значно ефективнішими будуть REST, gRPC.

Це необов’язково повинні бути зовнішні консюмери. Це може бути декілька продуктів однієї компанії або різні версії одного й того ж продукту на різних платформах(веб, мобілки).

З мого досвіду, у таких випадках, краще робити BFF ніж один спільний API. Продукти (та і платформи також), у більшості випадків, рухаються незалежно і дуже зручно коли бекенд рухаеться разом з ними. Також, коли у вас один спільний API, дуже тяжко його оновлювати і деприкейтити, бо у кліентських додатків своєї роботи вистачає.

Я мав справу з BFF. Як результат, api для консюмерів робили деякі речі «трохи по своєму». Це прозводило до багрепортів. Знову і знову. Це не говорячи що реалізація нової фічі вимагала реалізувати її декілька разів, а потім і протестувати декілька разів.

Деякі проблеми вирішуються стандартизацією, але не всі. Срібної кулі не існує, за гнучкість треба платити. Ну і в BFF краще розміщати тільки специфічні для консюмера речі, основна логіка в сервісах/мікросервісах, тоді дублювання буде мінімальним.

Судячи зі статистики скачувань NestJS, GraphQL використовують всього ~20% юзерів. Тобто помітна, але все-таки невелика частка використовує GraphQL. І це при тому, що ця штука вже не перший рік у всіх на слуху. Щось таки стримує користувачів ширше використовувати GraphQL.

використовують всього ~20% юзерів

Я думав що буде менше) Тут важливіше дивитися на вимоги продукту. Якщо у вас є API що працює зразу з декількома продуктами або декількома версіями одного й того ж продукту, то тут GraphQL може бути уже корисним.

З іншої сторони мені подобається сама модель написання GraphQL полів. Коли ми описуємо сутність і як її дістати, а потім можемо приатачити цю сутніть до будь чого. Це допомагає працювати над чимось цікавим, а не прокидувані одного й того ж набору полів в десяток ендпоїнтів і різними inner join-ами всередені.

а не прокидувані одного й того ж набору полів в десяток ендпоїнтів і різними inner join-ами

На скільки я розумію, при роботі з базами даних, GraphQL не може обійтись без SQL-білдера, бо як він ще будуватиме запити щоб задовільнити динаміку?

ORM(HotChocolate має інтеграції з різними провайдерами) точно спростить використання, але не обов’язково. Ми можемо дробити запити на рівні GraphQL полів враховуючи внутрішню структуру бази.

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

Додатково можна самому подивитися на філди які були запитані і згенерити на основі цього сирий SQL. Ми так робимо для великих моделей, що логічно об’єднані.

Я ьы еще добавил что существующие клиенты можно сравнительно быстро конвертировать на новый API — теперь это будет просто POST запрос на эндпонит «/graphql» (в теле запроса надо отправить GraphQL выражение), в ответ на который будет прилетать обычный JSON

И есть один неприятный подводный камень. Если отправляется невалидный GraphQL запрос, или на сервере что-то падает во время его выполнения, в ответ вы все равно получаете 200 OK (а не 400 или 500) и описание ошибки в теле ответа.

Напряму «сирі» запити краще не відправляти. Для цього є GraphQL клієнти з тулінгом навколо них.

Для JS/TS apollo client — там повна підсвітка синтаксису, валідація запитів та autocomplete полів GraphQL + повна автоматична типизація відповідей через кодогенерацію.

Для C# я б виділив Strawberry Shake, що конвертуює GraphQL файли з запитами в типізовані врапери на C#. А також мій проект ZeroQL. Він дозволяє писати запити повністю на С# з чудовою швидкодією в рантаймі, але фіч поки менше ніж в Strawberry Shake.

Повний список для різних мов можна глянути тут graphql.org/code

Напряму «сирі» запити краще не відправляти. Для цього є GraphQL клієнти з тулінгом навколо них.

Естественно, что правильно вызывать сервер надо при помощи GraphQL клиентов.
Прямые POST-запросы — это скорее воркараунд пока не сделана нормальная поддержка. Или на случай какого-нибудь экзотического или полумертвого языка, или вообще DSL скрипта или curl

В идеальном мире конечно сервер какое-то время будет работать и с REST и с GraphQL эндпоинтами, пока все клиенты не перейдут на GraphQL эндпоинт.

вот_так_с_помощью_нехитрых_приспособлений.jpg

Що заважає додати ролі користувача до ендпоінта з профілем?

Той факт що можуть існувати ситуації коли вони не потрібні. Так, наприклад, коли ми хочемо знайти користувачів вводячи їх ім’я. Тут нам достаньо фільтрувати і повертати тільки ід та ім’я. Ролі будуть призводити до over-fetching. Способи як це вирішити і до чого це призводить я описав тут dou.ua/...​rums/topic/47167/#2777882

Список користувачів та їх пошук можуть бути як в одному ендоінті так і в різних, в залежності від вимог бізнес логіки.

Так в тому і проблема що вимоги до ендпоїнтів можуть бути різними. В результаті, потрібні різні ендпоїнти які повертають приблизно одні й тіж дані. GraphQL дозволяє їх обєднати і юніфікувати.

В цьому й проблема, що це все відбувається динамічно. Плюс реста в тому, що бекенд сам контролює що йому віддавати та як виконувати запити в базу.

А реалізувати підтримку схеми/фільтра для ендпоінтів не можна? Від розробника ж залежить що вміє ендпоінт, додаткові параметри REST не забороняє, це ж гола специфікація. Навчити переварювати щось типу GET /api/v1/user/1?schema=name,email,profilePicture,roles.title явно можна

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

В итоге через год-полтора развития проекта вы получите свой аналог GraphQL

Ну правильно, в цьому ж і суть що можна отримати той самий функціонал, але в парадигмі REST, тому записувати недоліки це не можна. В REST взагалі нема нічого про організацію фільтрів і т.д то будемо говорити що це недоліки? Різниця тільки в тому що FB заопенсорсила свій велосипед і його підтримує щоб не було «проблем з підтримкою», а імплементації на основі REST нема, от і все. Якби REST накладала обмеження, що унеможливлювала б це, то це вже інша справа.

Ну тоді можна сказати наступне:

Ну правильно, в цьому ж і суть що можна отримати той самий функціонал, але в парадигмі TCP, тому записувати недоліки це не можна. В TCP взагалі нема нічого про організацію фільтрів і т.д то будемо говорити що це недоліки? Різниця тільки в тому що foundation of data communication for the World Wide Web заопенсорсила свій велосипед HTTP і його підтримує щоб не було «проблем з підтримкою», а імплементації на основі TCP нема, от і все. Якби TCP накладала обмеження, що унеможливлювала б це, то це вже інша справа.

А якщо серйозно то тут різниця в тому що з REST у вас свій власний велосипед про який ніхто крім вас не знає. GraphQL — має спеку і різні реалізації. Це дає можлвість брати його і інтергувати між собою різні продукти.

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

хацй

Це що таке?

плюгавенький але рест

В статті приводяться конкретні приклади проблем які створює REST. Якщо вони для вас критичні, то GraphQL буде одним із варіантів для вирішення.

А какие ещё варианты кроме GraphQL порекомендуешь?

Важко назвати їх «рекомендаціями». Більше схожі на костилі. Прямо реальної заміни я поки не бачив.

Ми можемо створювати узько спеціалізовані ендпоїнти які достатють тільки конкретні дані під кожну ситуацію. По типу «/users», «/userWithRoles», «/usersWithoutProfile», «/userForSearchList». Всі вони будуть робити одне й те саме, але трохи по різному. З часом це може перетвориться ще в те легасі. Вимоги будуть змінуватися, що змушуватиме керувати всим цим зоопарком. Бажання уникнути цього буде тільки рости) Крім того його не вистаивш як публічний API щоб ним користувалися клієнти.

Можна робити ендпоїнти які приймають список полів які ми хочемо отримати. Буде одна велика модель поля якої всі nullable і потім залежно від того що прийшло вони заповнються. По факту це спроба зробити GraphQL на мінімалках без всього його тулінгу. Оскільки це не дуже розповсюджений спосіб, то в публічний API його теж не виставиш.

так фасеты же есть на такую вариативность, т.е. у тебя будет POST /users, а в теле JSON с фасетами

на публичное API открытый GraphQL выставлять будет проблемней www.imperva.com/...​cks/#introspection-attack

фасеты же есть на такую вариативность

Ну так вони підпадають під другий спосіб що я описав + трохи over-fetching-а. Так, наприклад, передаємо { «facets»: [«user», «roles»] } i отримуюємо { id, name, email, imageUrl, roles: [ id, name ]}. Хоча по факту нам треба тільки { id, name, roles: [ name ] }. Крім того, виникає ситуація що структура відповіді має дуже ефімерний зв’язок з параметрами що прийшли. Ми можемо забути передати один із фасетів отримати null і подумати що так і треба. В GraphQL є пряма залежність між квері та структурою у відповіді. Ми просто не зможемо взяти поле яке ми забули запросити.

Стосовно статі,

Introspection Attack, GraphiQL — ті самі проблеми щой в REST якщо використовувати OpenAPI.

Excessive Errors/Fields Suggestions, Pagination Limit, SQL Injection, Cross-Site Scripting, OS Command Injection, Server-Side Request Forgery — проблеми притаманні для будь-якого API

Batching Attacks, Alias Overloading, Field Duplication, Directives Overloading, Circular Queries, Circular Fragments — HotChocolate вруховує ці проблеми. Деякі фічі просто виключені(Batching Attacks). На інші встановлені ліміти.

Ви з цим графкуель тільки головняка додасте.

Що саме додає головняку під час написання GraphQL API на вашу думку?

Використання важких і небезпроблемних бібліотек. Необхідність у процесі розробки використовувати додаткові тули, які теж не безпроблемні. Запити стають важкими і потребують знання специфічного синтаксису. Оверхед, оверхед еврівеа.
Постає питання: а навіщо це все? Як тут вірно написали, це було створено певною компанією для вирішення певних задач. Якщо мої задачі радикально відрізняються від цього всього, то й використовувати слід щось інше. Навіть якщо підписки «з коробки» є приємним бонусом.

Необхідність у процесі розробки використовувати додаткові тули

Це залежить від того наскільки великий проект і від того які тулзи на ньому використовуються. Так якщо на проекті уже використовується OpenAPI та кодогенерація клієнтів до нього, то сетап GraphQL-я не буде суттєво відрізнятися. У вас буде веб «IDE» де можна визивати ендпоїнти, будуть типізовані клієнти для інтеграційних тестів та фронта. З точко зору розробника той самий флоу, тільки інший набір тузів.

Якщо у вас немає всього цього для REST-у, то так — дуже багато нового.

а навіщо це все?

Я не говорю що це потрібно всім. Мета була показати як відносно просто можна конвертувати REST API в GraphQL API.

Якщо мої задачі радикально відрізняються від цього всього, то й використовувати слід щось інше.

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

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