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. За замовчуванням ліміти вимкнені - їх треба ввімкнути свідомо.
Чому 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. Для персональних даних основну роботу робить нормалізований кеш клієнта.
У 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 подій і пропускає повтори.
Документуйте явно: гарантії доставки (щонайменше один раз), відсутність гарантії порядку, тайм-аути й політику повторів, як перевіряти підпис. Більшість помилок інтеграцій - від неявних припущень одержувача про порядок і унікальність.