<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
АРХІТЕКТУРА · MIDDLE

REST, GraphQL чи RPC: як обрати стиль API для проєкту?

Стиль API вибирається за клієнтами і за тим, що ви готові втратити: REST дає безкоштовне HTTP-кешування й прозорі логи, але ліпить over-fetching і зайві раунд-трипи; GraphQL забирає вибірку полів у клієнта, натомість вимикає кеш на CDN і переносить N+1 та авторизацію в резолвери; RPC виграє у внутрішній комунікації зі строгим контрактом. Для одного власного фронтенду GraphQL зазвичай зайвий - дешевше sparse fieldsets, include і BFF-ендпоінт під екран.

Мобільний застосунок робить сім запитів, щоб намалювати один екран. Це вже причина переходити на GraphQL?
Ми переїхали з REST на GraphQL, трафік той самий, а база стала гарячішою і відповіді повільнішими. Чому?
Коли ви обрали б gRPC або JSON-RPC замість REST і чи реально тримати gRPC-сервер на PHP?
Як версіонувати GraphQL-схему, якщо там немає /v2?
Чим `POST /api/getOrdersByUser` гірший за `GET /orders?user=42`, якщо працює однаково?
API REST GraphQL gRPC JSON-RPC HTTP-кешування контракт

Обидві проблеми, з яких починається ця розмова, походять з того, що ресурс і екран мають різну форму. Over-fetching - коли GET /orders?page=1 віддає по двадцять полів на замовлення, включно з адресою доставки та історією статусів, а списку в мобільному потрібні три. Under-fetching зворотний: щоб намалювати картку замовлення, клієнт послідовно тягне замовлення, клієнта, позиції, товари по позиціях і статус оплати, і кожен наступний запит чекає попереднього. Ціна в них теж різна. Зайві поля коштують трафіку й серіалізації, і на LTE це помітно, але один запит лишається одним RTT. Зайві раунд-трипи коштують латентності, яка множиться на пінг, і саме вони частіше ламають відчуття швидкості. Тому починати треба з того, яка з двох проблем у вас реальна: лікуються вони різними засобами, а GraphQL продають як відповідь на обидві одразу.

У REST на обидві є прості інструменти, про які часто забувають. Проти зайвих полів працюють sparse fieldsets: ?fields[order]=id,total,status у стилі JSON:API або хоч би власний ?fields=, який фільтрує вихід ресурсу. Проти раунд-трипів - контрольований ?include=customer,lines з whitelist дозволених зв'язок і eager loading під ним, або compound document, який віддає зв'язані сутності поруч у included. Найдешевший і найчастіше правильний варіант - окремий ендпоінт під екран: GET /dashboard/summary замість семи запитів. Він порушує уявлення, що URL мусить відповідати таблиці, зате має одну перевірку доступу, один план запитів і повністю кешується. Це і є BFF у найпростішій формі, без окремого сервісу.

Кешування - головне, що ви ставите на карту, обираючи стиль. У REST з GET-адресами кеш працює на чотирьох рівнях безкоштовно: браузер, shared-проксі або CDN за Cache-Control: public, s-maxage=..., умовні запити через ETag/If-None-Match з поверненням 304 без тіла (RFC 9110), плюс stale-while-revalidate, коли дані можна віддавати трохи протухлими. У Laravel це middleware cache.headers:public;max_age=120;etag, який сам порівняє ETag і поверне 304; у Symfony - атрибут #[Cache] і вбудований reverse proxy. Щойно API переїжджає на єдиний POST /graphql або на RPC-ендпоінт, усі чотири рівні вимикаються: проксі не знає, що лежить у тілі, і кешувати не має права. Кеш нікуди не зникає, він переїжджає в застосунок і стає вашою роботою: Redis по сутностях, batch-лоадери в межах запиту, persisted queries з GET, щоб повернути кешовані URL. Для приватних відповідей різниця менша, бо Vary: Authorization і так вбиває shared-кеш, а для публічного read-heavy трафіку вона вирішальна.

Контракт у трьох стилях евольвує по-різному, і це впливає на щоденну роботу сильніше, ніж синтаксис. REST живе на additive-змінах: нові поля додаються, видалення й звуження типів чекають нової major-версії. GraphQL має одну схему без версій: поля додаються вільно, а застарілі позначаються @deprecated(reason: ...) і прибираються тоді, коли телеметрія показує нуль звернень до поля, для чого сервер мусить логувати, які поля зачіпала кожна операція. Protobuf прив'язує сумісність до номерів полів: номер не можна перевикористати, видалене поле заносять у reserved, у proto3 усі поля необов'язкові, тому додавання безпечне за конструкцією. Спільне в усіх трьох: видалення поля ламає клієнтів, і нове значення в enum ламає навіть коректно написаного клієнта, якщо той розгалужується по ньому exhaustive-ом. І в усіх трьох перевірку варто робити машинно, diff схеми в CI (graphql-inspector для SDL, oasdiff для OpenAPI, buf breaking для proto), а не оком на ревʼю.

Коли GraphQL зайвий: клієнт один і він ваш, релізиться разом з бекендом, сутностей десяток, трафік публічний і добре кешується, команда маленька. У цій конфігурації ви платите схемою, лімітами складності й глибини, авторизацією, розкиданою по резолверах, втраченим HTTP-кешем і моніторингом, де всі запити називаються POST /graphql, а взамін отримуєте гнучкість, якою нікому користуватися. GraphQL починає окупатися, коли клієнтів кілька і вони різні (iOS, Android, веб, партнерський дашборд), вони релізяться незалежно й версії лишаються на пристроях роками, а кожна нова фіча інакше вимагала б нового ендпоінта. RPC стоїть на іншій осі: JSON-RPC або gRPC беруть для внутрішньої комунікації між сервісами, де потрібні строгий контракт, генерація клієнтів, низька латентність і стрімінг, а браузерна прозорість та кеш нікого не цікавлять. У PHP тут є практичне обмеження: офіційний gRPC дає тільки клієнта, серверна частина живе на RoadRunner. І цілком нормальна відповідь на співбесіді - «у нас REST назовні, gRPC між сервісами, і один GraphQL-ендпоінт під мобільний застосунок»: стилі вибираються під межу, а не під увесь проєкт.

// GET /api/orders/42?include=customer,lines&fields[order]=id,total,status
// Кеш: ETag + 304 на If-None-Match, private - бо відповідь залежить від користувача
Route::get('api/orders/{order}', ShowOrder::class)
    ->middleware(['auth:sanctum', 'cache.headers:private;max_age=60;etag']);

final class ShowOrder
{
    /** Whitelist зв'язок: без нього клієнт сам собі влаштує N+1 через ?include */
    private const ALLOWED_INCLUDES = ['customer', 'lines', 'lines.product'];

    public function __invoke(Request $request, int $orderId): JsonResponse
    {
        $includes = array_values(array_intersect(
            array_filter(explode(',', (string) $request->query('include', ''))),
            self::ALLOWED_INCLUDES,
        ));

        // Під-fetching лікуємо одним запитом з eager loading, а не серією раундтрипів
        $order = Order::query()->with($includes)->findOrFail($orderId);

        $data = (new OrderResource($order))->resolve($request);

        // Sparse fieldset: клієнт бере лише потрібні поля, решта не летить по мережі.
        // Роботи базі це не зменшує - економія тільки на payload
        $fields = array_filter(explode(',', (string) $request->query('fields.order', '')));

        return response()
            ->json($fields === [] ? $data : Arr::only($data, $fields))
            // Last-Modified дає клієнту другий механізм умовних запитів (If-Modified-Since)
            ->setLastModified($order->updated_at);
    }
}
Що кандидат розрізняє over-fetching (зайві поля й рядки в одній відповіді) і under-fetching (екран збирається з кількох послідовних запитів) і знає, що в REST на обидва є відповіді: sparse fieldsets, `include`, compound document, окремий ендпоінт під екран.
Розуміння, що GraphQL платить за гнучкість кешуванням: один POST на `/graphql` непрозорий для CDN і браузера, тому кеш переїжджає в застосунок, а помилки полів приходять зі статусом 200 і зникають з HTTP-моніторингу.
Що вибірка полів на клієнті не зменшує роботу бази: якщо резолвер тягне модель цілком, `SELECT` лишається тим самим, а N+1 переїжджає з контролера в резолвери й лікується batch-лоадером.
Що контракт у кожному стилі евольвує по-різному: REST - additive-зміни плюс версія, GraphQL - одна схема з `@deprecated` і nullable-полями, protobuf - номери полів і `reserved`, і в усіх трьох видалення поля лишається breaking.
Що вибір робиться за кількістю й типом клієнтів, каденцією їхніх релізів і характером трафіку (публічний read-heavy проти внутрішнього service-to-service), а не за модою чи за тим, що «REST застарів».
Брати GraphQL, бо він «швидший за REST»: на одному запиті по одному ресурсу він приблизно такий самий, а на публічному кешованому трафіку явно повільніший.
Вважати, що клієнтський вибір полів економить базу, і лишати резолвер, який робить `Order::find()` з усіма колонками й прогріває ті самі індекси.
Накрутити GraphQL поверх наявних REST-контролерів, викликаючи HTTP-ендпоінти з резолверів, і отримати десятки внутрішніх запитів на один зовнішній.
Відкрити GraphQL публічно без ліміту складності й глибини та з увімкненою introspection: перший же вкладений запит по зв'язках стає безкоштовним DoS.
Називати REST-ом набір `POST /api/getUser`, `POST /api/saveUser`, а потім дивуватися, що ні кеш, ні повторний запит після таймауту, ні `304` не працюють.
Ставити один GraphQL-шлюз перед усім, щоб «не писати ендпоінти», і замість економії отримати авторизацію, розкидану по сотні резолверів.
ПОРАДА

Не відповідайте назвою стилю. Спитайте, хто клієнт і скільки клієнтів, як часто вони релізяться і який трафік переважає. Далі назвіть ціну кожного варіанта одним рядком: REST - зайві поля й раунд-трипи в обмін на HTTP-кеш і читабельні логи; GraphQL - гнучкість в обмін на кеш, per-field авторизацію і ліміти складності; RPC - швидкість і строгий контракт в обмін на непрозорість для браузера й проксі. Окремо скажіть, що для одного власного SPA дешевше зробити ендпоінт під екран, ніж піднімати схему.

оновлено 30 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

На публічному каталозі основну частину трафіку до переїзду віддавав кеш: однакові GET-адреси лягали на CDN і повертали 304 за ETag. Після переходу на POST /graphql проксі й браузер перестають розпізнавати запити, тож кожен долітає до застосунку. Одночасно вибірка полів економить лише payload: якщо резолвер тягне модель цілком, ті самі SELECT лишаються, а обхід зв'язків без batch-лоадера додає N+1. Парсинг і валідація схеми на цьому фоні - шум, а introspection до кількості запитів не має стосунку.