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

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

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

5 питань

GraphQL викликає резолвер для кожного поля кожного об'єкта. Запит

{
  posts(first: 50) {
    title
    author { name }
  }
}

виконає один запит за постами, а потім резолвер author - 50 разів, по одному на пост. Класичне N+1, тільки його не видно в коді: кожен резолвер окремо виглядає невинно.

DataLoader - патерн (і бібліотека з такою назвою від авторів GraphQL), що збирає звернення в пакет:

  1. резолвери не запитують базу одразу, а кажуть завантажувачу «мені потрібен автор 7», «мені потрібен автор 12»;
  2. завантажувач накопичує ключі протягом поточного «такту» виконання;
  3. потім робить один запит за всіма ключами (WHERE id IN (7, 12, ...)) і роздає результати резолверам;
  4. в межах запиту кешує вже завантажені значення - той самий автор не завантажиться двічі.

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

У Laravel з Lighthouse це зроблено за вас для зв'язків:

type Post {
  title: String!
  author: User! @belongsTo
  comments: [Comment!]! @hasMany
}

Директиви зв'язків Lighthouse пакетують запити до бази - зв'язок для 50 постів завантажиться одним запитом. Для даних, що не є зв'язками Eloquent (зовнішній сервіс, обчислення), Lighthouse дає змогу писати власні пакетні завантажувачі.

Типові пастки:

  • власний резолвер з ->find() усередині поля обходить пакетування й повертає N+1;
  • вкладені списки з пагінацією в кожному елементі (posts { comments(first: 5) }) складні для пакетування - «перші 5 коментарів кожного поста» не виражається простим IN, потрібні віконні функції чи окремі стратегії;
  • кеш завантажувача - на запит, не глобальний: інакше користувачі побачать чужі дані.

Як помітити: лічильник SQL-запитів на один GraphQL-запит (Telescope, Debugbar, Pulse) - тест, що перевіряє: кількість запитів не росте разом із кількістю елементів у списку.

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

Lighthouse - пакет, у якому GraphQL API описується схемою-першою (schema-first): ви пишете файл graphql/schema.graphql, а директиви пов'язують поля з моделями Eloquent.

composer require nuwave/lighthouse
php artisan vendor:publish --tag=lighthouse-schema
type Query {
  posts: [Post!]! @paginate(defaultCount: 20) @orderBy(column: "created_at", direction: DESC)
  post(id: ID! @eq): Post @find
  me: User @auth
}

type Mutation {
  createPost(input: CreatePostInput! @spread): Post!
    @guard
    @canModel(ability: "create")
    @create
}

input CreatePostInput {
  title: String! @rules(apply: ["required", "max:255"])
  body: String!
}

type Post {
  id: ID!
  title: String!
  author: User! @belongsTo
}

Що тут відбувається без жодного PHP-коду:

  • @paginate - пагінація з типами для сторінок;
  • @find, @all - вибірка моделей; @eq - умова where;
  • @belongsTo, @hasMany - зв'язки з пакетним завантаженням (без N+1);
  • @guard - автентифікація (Sanctum чи інший guard), @canModel (і решта сімейства @can*: @canFind, @canQuery...) - політики Laravel. Стара універсальна @can у Lighthouse 6 позначена застарілою;
  • @rules - звичайні правила валідації Laravel;
  • @create, @update, @delete - мутації над моделями.

Власна логіка - резолвер-клас:

final class PublishPost
{
    public function __invoke(null $_, array $args): Post
    {
        $post = Post::findOrFail($args['id']);
        Gate::authorize('publish', $post);
        $post->publish();

        return $post;
    }
}
publishPost(id: ID!): Post! @field(resolver: "App\\GraphQL\\Mutations\\PublishPost")

Що налаштувати одразу:

  • безпеку в config/lighthouse.php: max_query_depth, max_query_complexity, вимкнення інтроспекції в продакшені - за замовчуванням обмеження вимкнені;
  • кешування схеми в продакшені (php artisan lighthouse:cache);
  • авторизацію на кожному полі, що віддає чутливі дані, а не лише на запитах верхнього рівня;
  • тести: трейт MakesGraphQLRequests з методом graphQL() і перевіркою відповіді.

Чим відрізняється від REST-контролерів: схема - єдине джерело правди і документація одночасно; клієнти (Apollo, urql) генерують з неї типи TypeScript.

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

Одержувач вебхука може бути недоступним, повільним чи повертати помилку - доставка мусить це пережити.

Архітектура відправлення:

  1. подія фіксується в базі в тій самій транзакції, що й зміна даних (запис «вебхук до відправки»). Інакше при падінні після коміту подія загубиться - патерн outbox;
  2. джоба в черзі надсилає запит;
  3. результат кожної спроби записується в журнал доставок.
final class DeliverWebhook implements ShouldQueue
{
    public $tries = 8;

    public function __construct(public WebhookDelivery $delivery) {}

    public function backoff(): array
    {
        return [10, 60, 300, 1800, 3600, 7200, 21600];   // секунди: до кількох годин
    }

    public function handle(): void
    {
        $response = Http::timeout(5)
            ->withHeaders($this->delivery->signatureHeaders())
            ->post($this->delivery->endpoint->url, $this->delivery->payload);

        $this->delivery->recordAttempt($response->status());

        if ($response->failed()) {
            throw new WebhookDeliveryFailed($response->status());
        }
    }
}

Правила повторів:

  • наростаюча затримка (експоненційна, з випадковим розкидом) - щоб не «добити» сервер одержувача, який відновлюється, і щоб тисячі вебхуків не повторювалися синхронно;
  • повторювати мережеві помилки, тайм-аути, 5xx і 429 (з урахуванням Retry-After); 4xx на кшталт 400, 404, 410 - зазвичай ні: повтор не допоможе;
  • загальний горизонт - години чи дні (Stripe повторює до трьох днів), після чого доставка позначається невдалою.

Журнал доставок у кабінеті клієнта: подія, URL, час, код відповіді, фрагмент тіла відповіді, кількість спроб, кнопка «надіслати повторно». Найкорисніша функція для підтримки - більшість звернень «вебхук не прийшов» вирішуються переглядом журналу.

Захист від «мертвих» одержувачів: якщо ендпойнт падає днями, його автоматично вимикають і повідомляють власника листом - інакше черга забивається безнадійними спробами.

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

Готові інструменти: spatie/laravel-webhook-server (черга, підпис, повтори, події WebhookCallFailedEvent), або зовнішні сервіси доставки вебхуків, якщо обсяги великі.

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

Докладніше в документації: Stripe: автоматичні повтори вебхуків

Backend for Frontend - окремий серверний шар під конкретний клієнт: веб-застосунок, мобільний застосунок, адмінка. Кожен BFF збирає дані з внутрішніх сервісів у формі, зручній саме своєму інтерфейсу.

веб-застосунок    → BFF для вебу     ↘
мобільний застосунок → BFF для мобільних → сервіси замовлень, користувачів, каталогу
партнерське API   → публічне API     ↗

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

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

Що робить BFF:

  • агрегує: один запит від клієнта - кілька паралельних запитів до сервісів - одна відповідь під екран;
  • адаптує формат: лише потрібні поля, зручна структура, локалізовані підписи;
  • тримає автентифікацію браузера: BFF працює з сесійними cookie (HttpOnly), а токени до внутрішніх сервісів не потрапляють у браузер. Це рекомендований підхід для SPA з OAuth;
  • належить команді фронтенду - вона змінює його у власному темпі.

Ви, можливо, вже маєте BFF:

  • Next.js з серверними компонентами й Route Handlers - фактично BFF для React-застосунку;
  • Laravel з Inertia - контролери готують props саме для сторінок Vue/React;
  • GraphQL частково вирішує ту саму проблему іншим способом - клієнт сам вибирає поля.

Коли BFF виправданий:

  • кілька клієнтів з суттєво різними потребами;
  • мікросервісна архітектура, де клієнтові інакше довелося б знати про багато сервісів;
  • окремі команди фронтенду, яким потрібна незалежність.

Коли зайвий: один клієнт і моноліт - сам моноліт уже є «бекендом для фронтенду».

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

Докладніше в документації: Backends For Frontends

gRPC - фреймворк віддалених викликів процедур: клієнт викликає метод на сервері так, ніби це локальна функція. Контракт описується у файлі .proto мовою Protocol Buffers:

syntax = "proto3";

package billing.v1;

service InvoiceService {
  rpc GetInvoice (GetInvoiceRequest) returns (Invoice);
  rpc StreamPayments (StreamPaymentsRequest) returns (stream Payment);
}

message GetInvoiceRequest {
  int64 id = 1;
}

message Invoice {
  int64 id = 1;
  string number = 2;
  int64 total_cents = 3;
  repeated LineItem items = 4;
}

З .proto генеруються клієнти й серверні заготовки для десятків мов - контракт однаковий для Go, Java, PHP, Node.js.

Чому його обирають для внутрішнього зв'язку сервісів:

  • компактність і швидкість: Protocol Buffers - бінарний формат, повідомлення в рази менші за JSON і швидше розбираються. Імена полів не передаються - лише номери;
  • HTTP/2: одне з'єднання для багатьох паралельних викликів, стиснення заголовків;
  • стримінг: від сервера, від клієнта і двобічний - потоки подій, великі вивантаження без пагінації;
  • суворий контракт: типи перевіряються при генерації коду, а не в рантаймі;
  • дедлайни й скасування вбудовані в протокол: клієнт задає, скільки готовий чекати, і скасування поширюється ланцюжком викликів.

Обмеження:

  • браузер не говорить gRPC напряму - потрібен gRPC-Web з проксі (Envoy) чи шлюз, що перетворює на REST/JSON;
  • налагодження: бінарні повідомлення не прочитаєш у DevTools - потрібні grpcurl, Postman з підтримкою gRPC;
  • PHP-FPM погано підходить для gRPC-сервера (довгі з'єднання, HTTP/2) - сервери пишуть на Go, Java, Node.js, а з PHP частіше виступають клієнтом (розширення grpc) або використовують RoadRunner;
  • інфраструктура: балансувальники мають розуміти HTTP/2 і довгоживучі з'єднання.

Відповіді й помилки: у gRPC власні коди стану (NOT_FOUND, UNAVAILABLE, DEADLINE_EXCEEDED, PERMISSION_DENIED) замість HTTP-кодів.

Типова картина: публічне API - REST/JSON, внутрішні виклики між сервісами - gRPC, асинхронні події - черги чи брокери повідомлень.

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