Strona główna / Artykuły

REST kontra GraphQL: rzeczywiste kompromisy stojące za każdą z tych architektur

Wyjaśnia konkretne problemy, które rozwiązują REST i GraphQL, ich wewnętrzną mechanikę oraz ukryte kompromisy, które należy wziąć pod uwagę przed wyborem jednego z nich do swojej API.

Wydawca: REACTAPP.TOP10 min czytania2224 słów

Źródła: REST vs GraphQL: Which Problem Does Each One Actually Solve?

Gdy zaczynamy projektować sposób komunikacji między klientem a serwerem, niemal za każdym razem pojawia się jedno pytanie: REST czy GraphQL?

Dyskusje na ten temat często przedstawiają je jako konkurencję, jakby jedna ze stron musiała wygrać. Niektórzy programiści twierdzą, że REST już wyczerpał swój potencjał i powinien zostać odrzucony. Inni utrzymują, że GraphQL wprowadza warstwy złożoności, których większość projektów nie potrzebuje. Żadne z tych ujęć nie jest szczególnie przydatne.

Techologie nie są projektowane po to, by zdobywać punkty w sporach. Istnieją po to, aby rozwiązywać konkretne problemy, z którymi programiści borykają się w określonych sytuacjach.

Przyjęcie GraphQLa tylko dlatego, że wydaje się aktualne, może sprawić, że będziesz musiał radzić sobie z problemami z buforowaniem danych oraz drogimi zapytaniami po stronie serwera, których w ogóle nie planowałeś. Z drugiej strony, narzucenie sztywnej struktury REST aplikacji mobilnej pracującej z dużą ilością danych może spowolnić działanie ekranów przez ciągłe, sekwencyjne żądania sieciowe.

Aby podjąć właściwą decyzję, pomocne jest odrzucenie nadmiernego hałasu medialnego i przyjrzenie się podstawowym motywacjom: czego miało służyć rozwiązanie w każdym z podejść, jak funkcjonuje ono wewnętrznie oraz jakie są z nim związane kompromisy.

Jaki problem rozwiązał REST?

Zanim REST stał się normą, usługi internetowe w dużej mierze polegały na protokołach takich jak SOAP i XML-RPC. Wymagały one sztywnych schematów XML, rozbudowanych struktur wiadomości oraz skomplikowanej konfiguracji. Aby uzyskać dane, klienty musiały znać dokładne nazwy zdalnych procedur.

Roy Fielding wprowadził REST (Representational State Transfer) w roku 2000.

W swojej istocie REST oferował prosty, ale skuteczny koncept: korzystanie z własnej architektury sieci.

Zamiast traktować serwer jako nieprzejrzysty silnik wykonywania funkcji, REST modeluje system jako zbiór zasobów, z których każdy można adresować za pomocą jasnego URL. Działania na tych zasobach są wyrażane za pomocą standardowych poleceń HTTP (GET, POST, PUT, DELETE), a wynik każdej prośby jest zgłaszany za pomocą powszechnie znanych kodów stanu HTTP, takich jak 200 OK oznaczający sukces, 404 Not Found w przypadku braku zasobu lub 500 Server Error, gdy coś idzie nie tak po stronie serwera.

Resource-Oriented Architecture (REST)
Client ─── GET /api/users/12 ──────────> Server (Find user #12)
Client <── 200 OK [ { user data } ] ──── Server
Client ─── DELETE /api/posts/99 ───────> Server (Delete post #99)
Client <── 204 No Content ───────────── Server

Dlaczego REST jest ważny

  • Prostota i przewidywalność: Gdy już zrozumiesz, że /api/products odnosi się do zbioru produktów, a /api/products/42 wskazuje na konkretny element, interfejs staje się samowyjaśniający.
  • Wbudowane cacheowanie HTTP: Ponieważ żądania GET są idempotentne i mapowane na unikalne adresy URL, infrastruktury takie jak przeglądarki, CDN (np. Cloudflare) oraz proxy odwrotne (jak Nginx) mogą natychmiast przechowywać w pamięci ciągłej odpowiedzi, bez konieczności dodatkowej logiki aplikacyjnej.
  • Rozdzielone cykle życia: Zespoły backendowe mogą swobodnie modyfikować wewnętrzne schematy bazy danych, o ile publiczny kontrakt HTTP pozostaje spójny.
  • To rozwiązanie przez długi czas dobrze służyło programistom. Jednak w miarę jak aplikacje klienckie stawały się bardziej złożone, pojawiły się określone problemy.

    Nowe problemy: nadmierny i niewystarczający pobór danych

    Wyobraźmy sobie aplikację mobilną renderującą ekran profilu użytkownika. Ten ekran wymaga dokładnie trzech rzeczy:

    • Imię użytkownika
    • Adres URL wskazujący na awatara użytkownika
    • Tytuły trzech najnowszych postów blogowych użytkownika

    W przypadku tradycyjnego REST API spełnienie tego prostego wymagania często powoduje dwa odrębne problemy.

    1. Nadmierny pobór danych

    Klient wysyła żądanie GET /api/users/12.

    Serwer odpowiada pełnym rekordem użytkownika:

    {
      "id": 12,
      "name": "Sarah Connor",
      "avatarUrl": "https://cdn.example.com/sarah.jpg",
      "email": "sarah@example.com",
      "address": { "street": "...", "zip": "...", "country": "..." },
      "billingHistory": [ ... ],
      "preferences": { ... },
      "createdAt": "2021-04-12T08:14:00Z"
    }
    

    Ekran wymagał jedynie danych name i avatarUrl, a mimo to zostały pobrałe niepowiązane informacje o adreście, dane fakturacyjne oraz ustawienia preferencji. Przy wolnym połączeniu komórkowym przesyłanie niepotrzebnych danych marnuje baterię i spowalnia renderowanie. Taki scenariusz nazywany jest nadmiernym pobieraniem danych.

    2. Niedostateczny pobór danych (problem sieciowy N+1)

    Klient nadal potrzebuje postów Sarah, ale odpowiedź z /api/users/12 ich nie zawierała.

    Dlatego wysyła drugi wywołanie: GET /api/users/12/posts.

    Załóżmy, że ten punkt końcowy zwraca jedynie prostą listę identyfikatorów postów. W takim przypadku klient może musieć wykonać trzy oddzielne dodatkowe wywołania – jedno po to, by pobrać /api/posts/101, drugie – /api/posts/102, a trzecie – /api/posts/103.

    W tym momencie urządzenie dokonało kilku sekwencyjnych przejść przez sieć tylko po to, by wyświetlić jeden ekran. Ten wzorzec nazywa się under-fetching.

    Jaki problem faktycznie rozwiązał GraphQL?

    Facebook napotkał dokładnie ten problem około 2012 roku, przenosząc swoją główną aplikację z wersji opartych na sieci mobilnej na aplikacje natywne. Połączenia mobilne były niestabilne i powolne, a interfejs Facebooka składał się z ściśle powiązanych ze sobą danych – użytkowników, przyjaciół, komentarzy, lajków i reakcji.

    Projektowanie dedykowanego punktu końcowego REST dla każdego ekranu, na przykład /api/mobile-profile-screen, szybko przerodziło się w nie do opanowania wzorzec.

    Aby to rozwiązać, Facebook stworzył GraphQL.

    GraphQL nie jest technologią bazy danych. To język zapytań dla API, połączony z silnikiem działającym po stronie serwera, który wykonuje zapytania w oparciu o system typów, który sam definiujesz.

    Zamiast wysyłać liczne żądania do różnych punktów końcowych, klient wysyła jedno żądanie POST zawierające ustrukturyzowane zapytanie, które dokładnie określa, jakiego kształtu są potrzebne dane. Serwer następnie zwraca treść w formacie JSON idealnie odpowiadającą temu kształtowi.

    Podejście GraphQL

    Oto jak ten sam ekran profilu mógłby żądać swoich danych za pomocą GraphQL:

    GraphQL

    query GetUserProfile {
      user(id: "12") {
        name
        avatarUrl
        posts(limit: 3) {
          title
        }
      }
    }
    

    A serwer zwraca dokładnie to, o co poproszono, nic więcej:

    JSON

    {
      "data": {
        "user": {
          "name": "Sarah Connor",
          "avatarUrl": "https://cdn.example.com/sarah.jpg",
          "posts": [
            { "title": "Building Reliable Systems" },
            { "title": "Understanding Memory Leaks" },
            { "title": "A Guide to Clean Architecture" }
          ]
        }
      }
    }
    

    Nie ma marnotrawstwa przepustowości z powodu nieużywanych pól, a także nie ma potrzeby drugiej lub trzeciej próby pobrania powiązanych danych. Jedna wywołanie sieciowe dostarcza dokładnie tych danych, których potrzebuje widok do renderowania.

    Zobaczmy, jak to działa

    Aby naprawdę zrozumieć zalety i wady poszczególnych podejść, pomocne jest przyjrzenie się temu, jak każde z nich radzi sobie z wykonywaniem zadań po stronie serwera.

    Model oparty na punktach końcowych w REST

    W frameworku takim jak Express.js trasy mapują przychodzące adresy URL bezpośrednio na funkcje obsługujące:

    Język JavaScript

    // Express.js REST route
    app.get('/api/users/:id', async (req, res) => {
      const user = await db.users.findById(req.params.id);
      if (!user) return res.status(404).json({ error: 'User not found' });
      res.json(user);
    });
    

    Rozwijający backend ma pełną kontrolę nad logiką zapytań, wywołaniami do bazy danych oraz strukturą odpowiedzi. Optymalizacja wydajności jest w tym przypadku prosta: wystarczy przejść bezpośrednio do odpowiedniej funkcji obsługującej punkt końcowy i zoptymalizować zapytanie SQL lub dodać tam cache.

    Model oparty na schemacie i rozwiązaczach w GraphQL

    GraphQL opiera się na schemacie wraz z poszczególnymi funkcjami zwanymi resolwerami:

    JavaScript

    // Type definitions
    const typeDefs = `
      type Post {
        id: ID!
        title: String!
      }
    
      type User {
        id: ID!
        name: String!
        avatarUrl: String!
        posts: [Post]
      }
    
      type Query {
        user(id: ID!): User
      }
    `;
    
    // Resolvers map fields to actual data sources
    const resolvers = {
      Query: {
        user: async (_, { id }) => {
          return await db.users.findById(id);
        },
      },
      User: {
        posts: async (parentUser) => {
          // Runs when the client query includes the 'posts' field
          return await db.posts.findByAuthorId(parentUser.id);
        },
      },
    };
    

    Gdy przychodzi zapytanie, silnik GraphQL sprawdza je pod kątem schematu, a następnie przechodzi przez resolwery, wykonywając je w wzorze przypominającym drzewo.

    To daje zespołom front-endu dużą swobodę w kształtowaniu swoich żądań. Istnieje jednak ważna kwestia, na którą warto zwrócić uwagę: klient decyduje o formie zapytania, ale to serwer musi ponosić koszty jego realizacji.

    Jakie koszty niesie ze sobą GraphQL w tle

    Ta elastyczność nie jest darmowa. Przejście z REST na GraphQL faktycznie przenosi złożoność z klienta na serwer.

    Problem zapytań N+1 na warstwie bazy danych

    Rozważmy ponownie powyższe ustawienie rozwiązującego zapytania. Co się stanie, jeśli klient poprosi o listę 20 użytkowników wraz z ich postami?

    GraphQL

    query {
      users {
        name
        posts {
          title
        }
      }
    }
    
    • Rozwiązujący zapytanie users jest wykonywany raz, pobierając wszystkie 20 wierszy jednocześnie (SELECT * FROM users).
    • Następnie rozwiązujący zapytanie posts jest uruchamiany oddzielnie dla każdego pojedynczego użytkownika zwróconego.
    • Ostateczny rezultat: baza danych musi obsłużyć 1 zapytanie dotyczące listy użytkowników plus 20 dodatkowych zapytań dotyczących postów — łącznie 21 zapytań.

    W przypadku REST zazwyczaj łączy się to w jedno połączenie SQL w kodzie endpointu (SELECT * FROM users JOIN posts...). W GraphQL potrzebne są narzędzia do grupowania zapytań, takie jak DataLoader, aby zebrać te rozproszone zapytania i połączyć je przed wysłaniem do bazy danych.

    Problemy z cache’owaniem HTTP

    Rozważmy wywołanie RESTowe w następujący sposób:

    GET /api/articles/42
    

    CDN może z przyjemnością przechowywać tę odpowiedź w pamięci cache na przykład przez 10 minut. Jeśli w tym czasie 5 000 użytkowników wejdzie na adres /api/articles/42, serwer źródłowy musi przetworzyć tylko pierwszego z nich – resztę obejmuje CDN.

    GraphQL łamie ten schemat, ponieważ niemal każda operacja jest wysyłana jako żądanie POST do jednego konkretnego endpointu, mianowicie /graphql.

    Ponieważ rzeczywista zapytanie znajduje się w ciele żądania, a nie w adreście URL, zwykłe cache HTTP, proxy’i i CDN nie mogą rozpoznać, że dwa żądania pobierają identyczne dane. Aby to obejść, potrzebny jest albo zaawansowany normalizowany cache na stronie klienta (np. Apollo Client lub Relay), albo specjalnie zaprojektowany bramkarz do cache’owania.

    Głębokość zapytania jako wektor ataku

    W przypadku REST nie ma prostego sposobu, aby atakujący mógł użyć pojedynczego punktu końcowego do przemieszczania się po danych relacyjnych o dowolnie dużej głębokości.

    Jednak w przypadku GraphQL złośliwy użytkownik mógłby wysłać coś takiego:

    GraphQL

    query EvilQuery {
      user {
        posts {
          author {
            posts {
              author {
                posts {
                  title
                }
              }
            }
          }
        }
      }
    }
    

    Bez zabezpieczeń takich jak ocena złożoności zapytania lub ograniczenie głębokości, takie zapytanie może spowolnić pracę bazy danych lub wyczerpać dostępną pamięć serwera.

    REST i GraphQL obok siebie

    FeatureRESTGraphQLPobieranie danychZwraca dane w ustalonej strukturze dla każdego URLZwraca dokładnie te pola, o które prosi klientWywołania siecioweCzęsto wymagają kilku przejśćW zasadzie rozwiązuje się je w jednej prośbiePamięć cache HTTPWbudowane, proste i efektywneTrudne do osiągnięcia; wymagają dodatkowych narzędzi po stronie klienta lub bramy uczenia sięKrzywa naukiPłaska, opiera się na znanych konwencjach HTTPStroma, wymaga zrozumienia schematów i mechanizmów rozwiązywania błędówRaportowanie błędówKonwencjonalne kody stanu HTTP (404, 500, 401)Prawie zawsze 200 OK, przy czym problemy są wymienione w polu errorsWgranie plikówProste za pomocą danych formularza typu multipartWymaga specjalnej logiki mutacji lub uprzednio podpisanych adresów do wgrywaniaIdealne zastosowaniaUsługi w stylu CRUD, mikrosłużby, otwarte API internetoweZaawansowane interfejsy użytkownika i aplikacje działające na kilku platformach

    Wykonywanie wywołania: jak podjąć decyzję

    Gdy REST jest lepszym wyborem

    • Twój model zasobów jest prosty. W przypadku typowych produktów opartych na operacjach CRUD, takich jak panele administracyjne, tablice kontrolne czy proste sklepy internetowe, REST umożliwia szybszą implementację.
    • Kasowanie z pamięci cache ma dla ciebie duże znaczenie. Dla publicznego treści o dużym obciążeniu czytelniczym, takiego jak blogi, strony z wiadomościami czy katalogi, natywne kasowanie z pamięci cache przez CDN jest nie do prześcignięcia pod względem szybkości i kosztów.
    • Publikujesz API dla użytkowników z zewnątrz. Zewnętrzni programiści mogą już korzystać z punktów końcowych REST za pomocą curl lub fetch, bez konieczności uczenia się specjalnej biblioteki klienta.

    Kiedy GraphQL ma większe sens

    • Obsługujesz wiele różnych typów klientów. Wyobraź sobie produkt z aplikacjami dla iOS, Android, zegarków inteligentnych oraz przeglądarki – każda z nich wymaga innego podzestawu tych samych danych.
  • Pana domena to ściśle powiązany graf. Dotyczy to sytuacji, gdy takie elementy jak użytkownicy, grupy, komentarze, uprawnienia i media stale odwołują się do siebie nawzajem.
  • Frontend i backend działają w oddzielnych harmonogramach. W większych zespołach GraphQL pełni rolę warstwy buforowej: po zdefiniowaniu resolverów i typów inżynierowie frontendu mogą wprowadzać nowe widoki bez czekania, aż zespoły backendu dodadzą nowe punkty końcowe przy każdym cyklu.
  • Podsumowanie

    Żadna z tych technologii nie została uznana za przestarzałą przez drugą.

    • REST rozwiązał problem potrzeby spójnego, skalowalnego i przyjaznego do cache sposobu udostępniania zasobów w Internecie.
    • GraphQL rozwiązał problem potrzeby umożliwienia klientom kształtowania własnych żądań danych, co zmniejsza marnotrawstwo zasobów wynikające ze slownych lub niewiarygodnych połączeń mobilnych.

    Wybór pomiędzy nimi nie polega na podążaniu za trendami, lecz na zrozumieniu rzeczywistych wąskich gardeł, z którymi boryka się twój system. Jeśli twoimi największymi wyzwaniami są wydajność w aplikacjach mobilnych oraz coraz dłuższy список punktów końcowych specyficznych dla interfejsu użytkownika, GraphQL może być doskonałym rozwiązaniem. Jeśli najbardziej cenisz prostotę, niezawodne przechowywanie w pamięci tymczasowej oraz łatwy w utrzymaniu backend, REST pozostaje solidną i sprawdzoną opcją.