Конвертуємо наявний 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.
49 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів