Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Senior: питання на співбесіді з теми «GraphQL, gRPC і вебхуки»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

GraphQL дає клієнту змогу самому будувати запит - а отже й побудувати дуже дорогий. Один HTTP-запит може змусити сервер виконати мільйони операцій.

Типові атаки й проблеми:

# глибина: циклічні зв'язки дають експоненційне зростання
{ user(id: 1) { friends { friends { friends { friends { name } } } } } }

# ширина: величезні списки
{ posts(first: 100000) { comments(first: 1000) { author { name } } } }

# псевдоніми: одне поле, викликане тисячу разів в одному запиті
{ a1: login(email: "...", password: "1") a2: login(email: "...", password: "2") ... }

Останній приклад обходить обмеження частоти на рівні HTTP: один запит - тисяча спроб входу.

Захист - кілька рівнів:

  • обмеження глибини запиту (наприклад, 8-10 рівнів);
  • аналіз складності: кожному полю призначається «вартість» (списки - помножена на first), запит понад ліміт відхиляється до виконання;
  • обов'язкова пагінація з максимумом - жодних списків без first чи з first: 100000;
  • обмеження частоти за складністю, а не за кількістю HTTP-запитів: бюджет «очок» на клієнта за хвилину (так працює GitHub GraphQL API);
  • обмеження псевдонімів і пакетних запитів (batching кількох операцій в одному HTTP-запиті);
  • тайм-аути виконання запиту.

Інтроспекція в продакшені. Вона показує всю схему, включно з внутрішніми полями й мутаціями. Для публічних API зі схемою як документацією це нормально; для API лише свого фронтенду - вимкнути.

Збережені (persisted) запити / довірені документи: клієнт надсилає не текст запиту, а його хеш з переліку, відомого серверу під час збирання. Сервер виконує лише заздалегідь відомі запити - довільні атаки неможливі взагалі. Найсильніший захист для API, яке використовує лише ваш фронтенд, плюс бонус - запити можна робити через GET і кешувати на CDN.

Авторизація на рівні полів. Перевірка лише на верхньому запиті недостатня: доступ до order не означає доступу до order.customer.paymentMethods. Кожен резолвер чутливих даних має перевіряти права.

Повідомлення про помилки: у продакшені не віддавати стек і внутрішні деталі в errors - це витік інформації про реалізацію.

У Lighthouse обмеження задаються в config/lighthouse.php (max_query_depth, max_query_complexity, disable_introspection), а вартість полів - директивою @complexity. За замовчуванням ліміти вимкнені - їх треба ввімкнути свідомо.

Докладніше в документації: Безпека GraphQL

Чому REST кешується легко: кожен ресурс має свою адресу, читання - через GET, і вся інфраструктура HTTP (браузер, CDN, проксі) розуміє Cache-Control і ETag без жодної участі застосунку.

Чому з GraphQL складніше:

  • одна адреса /graphql для всього;
  • запити через POST - HTTP-кеші їх не кешують;
  • кожен клієнт формує свій запит - навіть однакові дані запитуються різними наборами полів, і ключ кешу «URL» не працює;
  • одна відповідь змішує дані з різним терміном актуальності (назва товару - години, залишок на складі - секунди).

Рішення на різних рівнях:

1. Нормалізований кеш на клієнті (Apollo Client, urql з Graphcache, Relay). Кеш зберігає об'єкти за __typename + id, а не відповіді цілком. Мутація, що повертає змінений об'єкт, автоматично оновлює його в усіх екранах. Тому корисно мати глобально унікальні ідентифікатори і завжди запитувати id.

2. Збережені запити + GET. Клієнт надсилає хеш заздалегідь відомого запиту і змінні в рядку запиту:

GET /graphql?extensions={"persistedQuery":{"sha256Hash":"ab12..."}}&variables={"id":42}

Тепер відповідь має стабільну адресу, і її можна кешувати на CDN - як REST.

3. Підказки кешування в схемі. Сервер обчислює Cache-Control для відповіді з найкоротшого терміну серед полів (директиви на кшталт @cacheControl(maxAge: 60)), а персональні поля позначає приватними. Відповідь з даними користувача не повинна потрапити в спільний кеш.

4. Кеш на сервері:

  • на рівні резолверів чи DataLoader - кешування окремих сутностей у Redis;
  • кеш цілих відповідей за нормалізованим текстом запиту + змінними + користувачем - простий, але інвалідація складна.

Інвалідація - найважче місце. Зміна одного товару торкається безлічі різних запитів, що його містять. Тому популярний підхід - теги: відповідь позначається тегами сутностей (Product:42), і зміна сутності скидає всі відповіді з цим тегом (так працюють CDN з purge за ключами).

Практичний висновок: якщо важливий кеш на CDN для публічних даних (каталог, статті), - або REST для цих частин, або збережені запити через GET. Для персональних даних основну роботу робить нормалізований кеш клієнта.

Докладніше в документації: Кешування в GraphQL

У Protocol Buffers у повідомленні передаються не назви полів, а їхні номери. Звідси головні правила еволюції схеми: важливо не те, як поле називається, а який у нього номер і тип.

Безпечні зміни:

  • додати нове поле з новим номером. Старі клієнти його проігнорують, нові - отримають значення за замовчуванням, якщо старий сервер його не надіслав;
  • перейменувати поле - номер той самий, на «дроті» нічого не змінилося (але згенерований код зміниться - це зміна API для коду, не для формату);
  • видалити поле, якщо його номер і назву зарезервувати.

Небезпечні зміни:

  • змінити номер поля - для формату це видалення старого поля і поява нового;
  • повторно використати номер видаленого поля з іншим змістом - старі клієнти розберуть нові дані як старе поле, з тихим пошкодженням даних;
  • змінити тип на несумісний (string → int64). Деякі пари сумісні на рівні формату (int32/int64/bool), але з можливим обрізанням значень - покладатися на це не варто;
  • перетворити одиничне поле на repeated чи навпаки - з неочевидними наслідками для різних мов.

Резервування видалених полів:

message Invoice {
  reserved 3, 7;
  reserved "discount", "legacy_status";

  int64 id = 1;
  string number = 2;
  int64 total_cents = 4;
}

Компілятор не дасть використати зарезервовані номери й назви повторно.

Інші практики:

  • значення за замовчуванням у proto3 - 0, "", false неможливо відрізнити від «не задано». Якщо різниця важлива - optional (явна присутність) чи обгорткові типи;
  • перелічення: перше значення має бути 0 і означати «невідомо» (STATUS_UNSPECIFIED = 0), бо саме його отримає старий клієнт для нових значень;
  • версія в назві пакета (billing.v1, billing.v2) - для справді несумісних змін створюється новий пакет і обидва обслуговуються паралельно;
  • автоматична перевірка в CI: інструмент buf breaking порівнює схему з попередньою версією й знаходить ламаючі зміни до злиття.

Порядок розгортання: спершу оновлюються ті, хто читає нове поле (сервери, що його приймають), потім ті, хто починає його надсилати. Видалення - у зворотному порядку: спершу всі припиняють використовувати поле, потім воно резервується.

Докладніше в документації: Protocol Buffers: оновлення типу повідомлення

Подієві API - вебхуки, повідомлення в брокері (Kafka, RabbitMQ), канали WebSocket - мають ті самі проблеми, що й REST: контракт, документація, сумісність. Але є й власні - порядок і дублікати.

Документація - AsyncAPI. Те, чим OpenAPI є для REST, AsyncAPI є для подій: специфікація каналів, повідомлень і їхніх схем:

asyncapi: 3.0.0
info:
  title: Orders events
  version: 1.4.0
channels:
  orderPaid:
    address: orders.paid
    messages:
      orderPaid:
        payload:
          type: object
          required: [id, orderId, paidAt]
          properties:
            id: { type: string }
            orderId: { type: integer }
            paidAt: { type: string, format: date-time }

З неї генеруються документація, типи для споживачів і перевірки повідомлень.

Версіонування подій:

  • додавання полів - безпечне, якщо споживачі ігнорують невідомі поля (це треба вимагати в документації);
  • ламаючі зміни - нова назва чи версія події (order.paid.v2) і паралельна публікація обох версій на перехідний період;
  • версія у вебхуках - часто прив'язується до облікового запису одержувача (як api_version у Stripe): одержувач сам обирає, коли перейти на новий формат.

Порядок подій не гарантований. Повтори, паралельні воркери й мережа змішують порядок: order.shipped може прийти раніше за order.paid. Стратегії для одержувача:

  • мітка часу чи номер версії об'єкта в події - застосовувати лише якщо подія новіша за вже відомий стан;
  • «тонкі» події: подія лише повідомляє «замовлення 42 змінилося», а одержувач запитує актуальний стан через API - порядок перестає мати значення;
  • впорядкування за ключем у брокері (партиції Kafka за order_id) - порядок гарантується в межах однієї сутності, а не глобально.

Дублікати - наслідок доставки «щонайменше один раз»: одержувач зберігає оброблені id подій і пропускає повтори.

Документуйте явно: гарантії доставки (щонайменше один раз), відсутність гарантії порядку, тайм-аути й політику повторів, як перевіряти підпис. Більшість помилок інтеграцій - від неявних припущень одержувача про порядок і унікальність.

Докладніше в документації: AsyncAPI: основні поняття