Чому ми почали використовувати GraphQL: переваги і декілька практичних порад

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

Усім привіт, мене звуть Кирило, я Java-розробник в компанії Luxoft (DXC).

За час роботи на проєкті ми пройшли не один етап рефакторингу наших додатків, міграції на нові технології та зміни процесів.

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

SCS або як підтримувати 1000 сервісів

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

Побудувавши систему на мікросервісах, налаштувавши різні CI/CD, перетворивши всі сервіси в Docker-образи і розгорнувши все в K8, більшість розробників заспокоюється і починає спокійно реалізовувати нову бізнес-логіку, фіксити баги і далі жити своїм життям. Але ось, прокинувшись одного ранку, ви раптом усвідомлюєте, що кількість репозиторіїв на гітхабі давно перевалила за 1000, а кожен новий сапорт тікет змушує руки трястись, напередодні довгих годин інвестігейшену, стрибаючи від одного сервісу до іншого, і намагаючись поєднати всю бізнес-логіку воєдино.

Цей опис, звичайно, можна назвати перебільшенням, і хтось, можливо, помітить, що не варто розносити бізнес-логіку по різним сервісам. І буде абсолютно мати рацію. Однак ми живемо у недосконалому світі. Іноді ми нехтуємо якимись умовами та правилами задля простоти реалізації, іноді дедлайни підтискають, або просто залишаємо //todo: у коді, сподіваючись, що колись ми повернемося до цього місця та обов’язково все виправимо.

Віднедавна, після довгих обговорень, ми вирішили почати дотримуватися практик Self-Contained Systems (SCS).

Двома словами про основні ідеї цієї архітектури:

  • Ізолює групу мікросервісів, що реалізують суміжну бізнес-логіку.
  • Розмежовує команди по SCS: над однією SCS може працювати лише одна команда.
  • Забороняє пряму взаємодію між сервісами, що належать різним SCS (переважна UI-layer інтеграція).
  • Кожна SCS надає публічний API, який визначає всі можливі взаємодії з цією частиною.

Крім UI-інтеграції SCS, ми не могли обійтися без інтеграції на бекенд-стороні. У цьому питанні вирішили дотримуватись повністю асинхронної обробки. Для запису та будь-яких інших змін — використовувати Event-based approach, а для читання — GraphQL, мова про який і піде далі.

Хто такий цей ваш GraphQL і навіщо ви його покликали

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

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

Так що ж таке GraphQL?

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

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

GraphQL привернув увагу такими речами::

  • Є можливість описати повну бізнес-модель у межах одного документа (GraphQL Schema).
  • 1 schema = 1 SCS означає, що відповідальність за домен і бізнес-логіку визначена.
  • Нові запити згідно зі схемою, реалізуються клієнтами та не вимагають нових змін з боку команди власника SCS.
  • Відразу ж відпадають питання щодо доречності та коректності застосувань різних HTTP методів, QueryParam, PathParam.

У гонитві за швидкістю

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

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

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

schema {
        query: QueryDefinition
}
type QueryDefinition {
        listing(listingIds: [ListingIdInput!]!): [Listing!]! @rolesAllowed(role: ["ROLE_READER"])
}
input ListingIdInput {
        baseInstrumentId: URI!
        tradingVenueId: URI
        quoteInstrumentId: URI
}
type Listing {
        baseInstrumentId: URI!
        tradingVenue: TradingVenue!
        quoteInstrumentId: URI!
        alternativeTradingVenues: [TradingVenue!]!
        orderModes: [OrderMode!]!
}

Для обчислення даних GraphQL використовується сутність під назвою DataFetcher.

Якщо DataFetcher реалізований найпримітивнішим способом і постійно завантажує всі атрибути сутності, ми отримаємо в результаті такі проблеми:

  • Ми хочемо отримати тільки основну інформацію про продукт (baseInstrumentId, tradingVenue, quoteInstrumentId) і нам не цікаві інші атрибути. У такому випадку можливі зайві запити на бекенді для обчислення інших полів.
  • Якщо необхідно завантажити декілька продуктів (Listing) та їх варіанти торгівлі (OrderMode) — можливе множинне звернення до джерела даних (база, сервіс), якщо OrderMode завантажується один за одним для кожного продукту.

На щастя, GraphQL досить гнучкий для розв’язання цих проблем. Основні правила — постійно завантажувати лише скалярні типи даних та використовувати Batch Loaders для завантаження вкладених сутностей.

GraphQL Batch Load

Згідно з документацією, graphql-java дозволяє отримувати дані кількома способами. Основними є:

  • DataFetcher — шляхом реалізації інтерфейсу дозволяє повертати POJO об’єкт, що відповідає наданому ключу чи запиту.
  • PropertyDataFetcher — стандартний, вбудований завантажувач даних GraphQL. З коробки автоматично та самостійно шукає відповідні методи чи поля, які можна повернути у відповідь на запит.
  • BatchLoader та MappedBatchLoader (з окремого модуля java-dataloader) — схожий з DataFetcher, але замість обчислення однієї сутності для одного ключа дозволяє приймати колекцію ключів і повертати колекцію сутностей.

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

Для використання batch data loader необхідно оголосити функцію, яка буде використовуватися для завантаження даних.

/**
* Creates the data fetcher that will be called to execute all the batch requests.
*/
private MappedBatchLoader<Long, List<OrderMode>> buildBatchLoader() {
return listingIds -> CompletableFuture.supplyAsync(() -> fetchOrderModes(listingIds),
dataLoadingExecutor);
}

private Map<Long, List<OrderMode>> fetchOrderModes (Set<Long> listingIds) {
Map<Long, List<OrderMode>> resolved = 
orderModeRepository.findByListingIdIn(new ArrayList<>( listingIds)).stream()
.collect(groupingBy(OrderModeEntity::getListingId,
HashMap::new,
                                                           mapping(this::toOrderMode, Collectors.toList())));
listingIds.forEach(id -> resolved.putIfAbsent(id, Collections.emptyList()));
            return resolved;
}

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

@Bean
public DataLoaderRegistry dataLoaderRegistry() {
DataLoaderRegistry registry = new DataLoaderRegistry();
            registry.register(“orderModesLoader”,  
	DataLoader.newMappedDataLoader(buildBatchLoader()));
return registry;
}

Залишається тільки використовувати наш зареєстрований loader у нашому DataFetcher.

public CompletableFuture<List<OrderModes>> getOrderModes(Long listingId,
DataFetchingEnvironment env) {
return env.getDataLoader(“orderModesLoader”).load(listingId);
}

Варто звернути увагу, що GraphQL самостійно запровадить другий аргумент методу DataFetchingEnvironment. Нам потрібно лише оголосити його.

У результаті даних маніпуляцій, GraphQL згрупує наші listingIds і передасть їх у функцію, яку ми оголосили на початку. Завантаження буде виконано одним запитом, і GraphQL сам розподілить, куди який список віддавати — тобто в потрібному DataFetcher виявиться потрібний список даних.

Error handling

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

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

За замовчуванням, а також у найпростішій реалізації DataFetcher, ми не зможемо використовувати таку можливість — необхідно внести невеликі зміни.

DataFetcher дозволяє обернути дані, що повертаються в обгортку під назвою DataFetcherResult.

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

У нашому випадку, сигнатура методу стане наступною:

public CompletableFuture<DataFetcherResult<List<OrderModes>>> getOrderModes(
Long listingId, DataFetchingEnvironment env) {}

Для простоти обробки фінального результату можна оголосити допоміжну функцію:

public static <T> BiFunction<T, Throwable, DataFetcherResult<T>>
withDefaultOnError(Function<Throwable, GraphQLError> errorBuilder,
T defaultValue) {
return (value, ex) -> {
            	if (ex != null) {
                        	return DataFetcherResult.<T>newResult()
.error(errorBuilder.apply(ex))
.data(defaultValue).build();
}
                        	
return DataFetcherResult.<T> newResult().data(value).build();                                   };
}

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

public CompletableFuture<DataFetcherResult<List<OrderModes>>> getOrderModes(
Long listingId, DataFetchingEnvironment env) {
return env.getDataLoader(“orderModesLoader”)
.load(listingId)
.handle(withDefaultOnError(GraphQLError::new, Collections.emptyList()));
}

За підсумками, в респонсі ми матимемо об’єкт, що містить як помилки (поле «errors»), так і дані (поле «data»), які ми змогли обчислити.

{
        "errors": [
            {
                "message": "Some error describing the issue",
                "locations": [
                    {
                    	"line": 1,
                        "column": 1
                    }
                ],
                "path": [
                    "listing"
                ],
                "extensions": {
                    "classification": "DataFetchingException"
                }
            }
        ],
        "data": {
            "listing": [
                 {
                     “baseInstrumentId”: “uri:1”,
                     “orderModes”: []
     }
           ]
        }
}

Нюанси при асинхронному виконанні

У прикладах вище ви могли помітити, що наш DataFetcher повертає значення як CompletableFuture.

За замовчуванням GraphQL вже використовує AsyncExecutionStrategy, і весь ланцюжок виконання можна побудувати на неблокуючих викликах.

Однак при реалізації часткових респонсів ми зіткнулися з досить дивною ситуацією.

Наприклад, наша схема має той самий вигляд:

type QueryDefinition {
        listing(listingIds: [Long!]!): [Listing!]! @rolesAllowed(role: ["ROLE_READER"])
}

Уявімо, що ми виконуємо запит, передаючи кілька listingIds (1L, 2L) як вхідний аргумент.

Припустимо, що для listingId = 1L, у нашому DataLoader, дані будуть обчислюватися успішно, а для listingId = 2L ми повертатимемо exception.

У такому разі, респонс, який ми отримаємо, буде не зовсім таким, як ми очікуємо.

{
        "errors": [
            {
                "message": "error for listingId = 2L"
                    //..other fields
            },
           {
                "message": "error for listingId = 2L"
                    //..other fields
            }
        ],
        "data": {
            "listing": [
                 {
                 	“baseInstrumentId”: “uri:1”,
                        “orderModes”: [//..some data here]
     },
     {
                 	“baseInstrumentId”: “uri:2”,
                        “orderModes”: []
     }
           ]
        }
}

Як ви можете бачити, у відповідь ми отримали дві помилки у масиві errors, тоді як очікували там тільки одну помилку.

Пов’язано це з тим, що в асинхронному виконанні GraphQL не зовсім розуміє, куди віддавати помилку (у якій конкретно CompletableFuture, якщо так можна сказати).

Однак обхідний шлях є і для цієї проблеми.

GraphQL надає ще одну сутність для того, щоб описати: було виконання операції успішним чи ні — Try.

Для цього слід змінити сам DataLoader:

private MappedBatchLoader<Long, Try<List<OrderMode>>> buildBatchLoader() {
return listingIds -> CompletableFuture.supplyAsync(() -> fetchOrderModes(listingIds),
dataLoadingExecutor);
}

private Map<Long, Try<List<OrderMode>>> fetchOrderModes (Set<Long> listingIds) {
Map<Long, Try<List<OrderMode>>> resolved =
orderModeRepository.findByListingIdIn(new ArrayList<>( listingIds))
.stream()
.collect(groupingBy(OrderModeEntity::getListingId,
HashMap::new,
                                                           mapping(this::toOrderMode, Collectors.toList()))); 

listingIds.forEach(id ->
resolved.putIfAbsent(id,Try.succeeded(Collections.emptyList())));

return resolved;
}

У цьому прикладі метод toOrderMode повертатиме значення типу Try>.

Після цього варто зареєструвати цей DataLoader за допомогою функції DataLoader.newMappedDataLoaderWithTry(); замість DataLoader.newMappedDataLoader();

Ось і все, результатом буде очікуваний нами респонс:

{
        "errors": [
            {
                "message": "error for listingId = 2L"
                    //..other fields
            }
        ],
        "data": {
            "listing": [
                    {
                    	“baseInstrumentId”: “uri:1”,
                        “orderModes”: [//..some data here]
        },
        {
                    	“baseInstrumentId”: “uri:2”,
                        “orderModes”: []
        }
           ]
        }
}

На завершення

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

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

👍ПодобаєтьсяСподобалось7
До обраногоВ обраному7
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, каскадом росла проверка пермишенов, отсечение полей, слои логики на слои слои, и еще куча проблем с ORM, который ставил на колени базу запросами. Выпилили весь GQL и все перевели на grpc-web, все стало намного проще, и куча проблем отвалилась.

У більшості випадків, GraphQL є надмірним

таки, да, полностью согласен, в некоторых проектах GQL это оверкилл, нужно очень тщательно подумать и обсудить, нужен ли этот монстр в ваших проектах.

Дякую за статтю, цікаво було почити як у світі Java реалізовують graphql сервіси. Маю досвід реалізацї таких сервісів і ви прямо точку вказали дві найпоширеніші проблеми graphql, які потрібно вирішувати: N+1 запит і витягування всього об‘єкту, навіть коли потрібно лише декілька полів.

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

type Query {
  me: User!
}

type User {
  id: String!
  name: String!
  messages: [Message!]!  # user's messages
}

type Message {
  id: String!
  text: String!
  userFrom: User!  # message author
  userTo User!  # message receiver
}
В цьому варіанті зловмисник може леко витягути повідомлення отримувача, просто відправиши, йому хоча б одне повідомлення:
{
   me {
      messages {
         userTo {
             messages: {
                text
             }
         }
      }
   }
}

Найкраще вирішується, вказавши детальніше типи:

type Query {
  me: CurrentUser!
}

type CurrentUser {
  id: String!
  name: String!
  messages: [Message!]!  # user's messages
}

type MessageUser {
   id: String!
   name: String!
}

type Message {
  id: String!
  text: String!
  userFrom: MessageUser!  # message author
  userTo MessageUser!  # message receiver
}

як ефективно можна обмежувати доступ користувача до певних полів? скажімо, користувач може сформувати запит на отримання певної моделі, але дозволено йому тільки деякі проперті (перелік вказано наприклад в клеймах JWT)

Є інтерфейс SchemaDirectiveWiring. Він дозволяє проводити перевірки по полям і не тільки. Ми використовуємо його для перевірки UserRoles —

@rolesAllowed(role: [«ROLE_READER»])

А gRPC не розглядали? Довелось попрацювати з SOAP/XML, REST, GraphQL, зараз на проекті Thrift, але планується міграція на gRPC, з яким я знайомий лише в теорії. І мені gRPC бачиться черговим етапом еволюції, де є всі переваги Graphql, але є і класні штуки по типу бінарної передачі даних замість JSON.

де є всі переваги Graphql,

і як в

gRPC

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

Ну, значить не всі — кажу ж, ще не працював з ним. Тому і питаю.

де є всі переваги Graphql,

і як в

gRPC

можна вибрати два потрібні філда, щоб не тягнути всю модель?

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

[Listing!]!

Аллилуйя)
Нормальные типы для массивов сущностей)

Большая часть бэк-макак и это не осиливает)

більша частина не може просто курлом запит зробити

GraphQL — это по сути концепт, спецификация. Как оно реализовано, это уже зависит от «реализатора». GraphQL это не волшебная палочка, которая из ниоткуда дает тебе много гибкости и разных приколов.

Мне нравится думать о GQL как о SQL, который работает через HTTP (хотя спецификацией не регламентируется, что оно должно работать через HTTP). Разница в том, что в случае с SQL мы выступаем клиентами и хотя для написания оптимальных запросов нам хорошо бы знать некоторые аспекты работы сервера, но над серверной реализацией нам думать не надо. А в случае разработки GQL бекенда мы выступаем сервером и на уровне сервера должны запрограммировать то, что клиент может делать и как именно сервер должен на это реагировать, как и откуда брать данные, доступ, прочее. Просто подключение какой-то библиотеки, которая позволяет сделать GQL сервер — это 0.3% всей работы.

Полностью согласен с автором:

вимагатиме набагато більше зусиль для того, щоб застосовувати його правильно
но над серверной реализацией нам думать не надо

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

А что такое «вложенный контекст» в контексте сервера СУБД и почему нам, как клиентам этого сервера, в какой-то момент придется об этом задуматься?

Це коли в процесі resolve-у на сервері виникає потреба ініціювати ще один запит всередині себе, але у нього повинен бути доступ до поточного контексту, шо може бути ознакою не дуже вдалої архітектури. Такі собі костилі.

Можливо, це була якась особливість самого vendor-а, але точно пам’ятаю, що на проекті, де я з Graphql познайомився, була така проблема, а більш досвідчений колега сказав, шо це просто така його особливість та не звертай уваги.

Зараз я пописую вечорами pet project, який по рівню складності давно уже переріс той самий проект і в мене жодного разу не виникло такої потреби.

Так вы же говорите про GraphQL. И при реализации GraphQL бекенда вам придется думать о том как именно это реализовать, т.к. GraphQL это чудным образом не делает.

но над серверной реализацией нам думать не надо

Я говорил это именно про использование СУБД.

Data/Batch Loader’ы похожи на костыли.

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