GraphQL to język zapytań dla interfejsów programowania aplikacji oraz środowisko uruchomieniowe po stronie serwera, które pozwala klientom prosić dokładnie o te dane, których potrzebują, i nic poza tym. Stanowi bezpośrednią alternatywę dla architektury REST API, eliminując problem nadmiarowego pobierania informacji poprzez zastąpienie wielu sztywnych endpointów jednym elastycznym punktem końcowym.
Kiedyś projektowanie systemów wymiany danych opierało się na sztywnych regułach. REST zdominował rynek. Nakazywał tworzenie oddzielnych ścieżek URL dla każdego zasobu. Pobranie informacji o użytkowniku wymagało zapytania pod adres profilu, a zebranie jego ostatnich wpisów na blogu wymuszało kolejne uderzenie do serwera. Architektura ta działała znośnie w czasach stron renderowanych w całości po stronie serwera. Aplikacje typu Single Page Application obnażyły jej słabości. Architekci z Facebooka stworzyli GraphQL w 2012 roku, żeby rozwiązać problemy z wolnym ładowaniem aplikacji mobilnej na słabych połączeniach sieciowych. W 2015 roku udostępnili tę technologię jako open source. Od tamtej pory sposób, w jaki frontend rozmawia z backendem, zmienił się technicznie do spodu.
Prawda jest zresztą absolutnie taka, że REST API w wielu złożonych projektach po prostu dławi produkcję. Deweloperzy tworzą dziesiątki niestandardowych endpointów tylko po to, by obsłużyć specyficzne widoki w interfejsie użytkownika. Powstają potworki architektoniczne. Utrzymanie tego kodu kosztuje czas i pieniądze.
Dlaczego REST API przestało nam wystarczać w projektach frontendowych?
Większość problemów z REST sprowadza się do dwóch technicznych zjawisk. Over-fetching i under-fetching. Wyobraź sobie, że budujesz widok listy autorów w aplikacji mobilnej. Potrzebujesz tylko wyświetlić ich imię oraz miniaturkę zdjęcia. W klasycznym podejściu wysyłasz zapytanie GET na odpowiedni adres. Serwer odpowiada. Zwraca ogromny obiekt JSON.
Dostajesz imię i zdjęcie. Ale dostajesz też adres e-mail, numer telefonu, datę urodzenia, historię logowania, status konta i preferencje powiadomień. Pobrałeś kilkaset kilobajtów danych. Zużyłeś transfer użytkownika. Wykorzystałeś tylko ułamek. To jest właśnie over-fetching. Marnotrawstwo zasobów sieciowych.
Z under-fetchingiem mamy do czynienia w odwrotnej sytuacji. Widok wymaga wyświetlenia autora, jego trzech ostatnich artykułów i liczby komentarzy pod każdym z nich. REST wymusza sekwencyjne zapytania. Najpierw pobierasz autora. Czekasz na odpowiedź. Następnie na podstawie jego ID uderzasz w endpoint z artykułami. Czekasz. Potem w pętli odpytujesz serwer o komentarze do każdego tekstu. Opóźnienia sieciowe sumują się w sekundach. Użytkownik widzi kręcące się kółko ładowania ekranu. Aplikacja mobilna dławi się na przejściach między widokami.
GraphQL rozwiązuje to brutalnie prosto. Wysyłasz jedno zapytanie. Wymieniasz w nim dokładnie te pola, które cię interesują. Serwer oddaje strukturę identyczną z zapytaniem. Nic więcej. Żadnych nadmiarowych bajtów.
Czym właściwie różni się podejście deklaratywne od endpointów?
W architekturze REST myślimy w kategoriach zasobów i adresów URL. Serwer dyktuje strukturę odpowiedzi. Frontend musi się do niej dostosować. GraphQL odwraca tę relację. To klient decyduje, co chce otrzymać. Podejście deklaratywne oznacza, że opisujemy pożądany wynik, a nie kroki potrzebne do jego osiągnięcia.
Siedzieliśmy w biurze na Pradze Północ w zeszły wtorek do trzeciej nad ranem. Baza wyrzucała błędy timeout. Aplikacja mobilna dławiła się od nadmiaru niepotrzebnych pól pobieranych z serwera podczas ładowania głównego feedu. Prawda jest taka, że REST API w tamtym momencie po prostu nas zabiło. Zmieniliśmy te zasady na robocie. Przejście na jeden graf i wycięcie 40 zbędnych pól z payloadu uratowało nam poranny release przed awarią. Bez mała prawie pięćdziesiąt procent transferu zeszło w dół w kilka godzin po wdrożeniu łatki.
Zamiast tworzyć URL dla każdego wariantu danych, GraphQL wystawia jeden punkt końcowy. Zazwyczaj jest to po prostu `/graphql`. Wszystkie zapytania idą przez metodę HTTP POST. W ciele zapytania przesyłamy string zawierający naszą deklarację. Znika problem wersjonowania API. W REST często widujemy adresy typu `/v1/users` i `/v2/users`. W podejściu grafowym dodajemy nowe pola do schematu, a stare oznaczamy jako przestarzałe. Klienci korzystający ze starszych wersji aplikacji nadal proszą o stare pola i je dostają. Zmiany nie psują istniejącego kodu.
Jak działa architektura GraphQL od strony serwera i zapytań?
Serwer GraphQL opiera się na trzech filarach. Schemat, zapytania i resolvery. Schemat to twardy kontrakt. Zbiór zasad określających, o co w ogóle można zapytać. Definiujemy go używając specjalnego języka Schema Definition Language. Określamy typy danych. String, Int, Boolean, ID. Budujemy z nich własne obiekty.
- Schemat definiuje strukturę grafu, wymuszając na zespole backendowym i frontendowym wspólne ustalenie kształtu danych jeszcze przed napisaniem pierwszej linijki logiki biznesowej w kodzie, co drastycznie ogranicza późniejsze nieporozumienia przy integrowaniu widoków z bazą.
- Resolvery wykonują brudną robotę.
- Zapytania (Queries) służą wyłącznie do odczytu danych z serwera bez modyfikowania ich stanu w bazie.
- Mutacje (Mutations) odpowiadają za każdy zapis, aktualizację lub usunięcie rekordu.
Każde pole w schemacie jest wspierane przez funkcję zwaną resolverem. Kiedy zapytanie trafia na serwer, silnik GraphQL parsuje je i buduje drzewo składniowe. Następnie przechodzi przez to drzewo węzeł po węźlu. Dla każdego pola wywołuje przypisany mu resolver. Resolver wie, skąd wziąć dane. Może uderzyć do bazy SQL. Może odpytać inne zewnętrzne API. Może wyciągnąć informacje z pamięci podręcznej Redis. Klienta to nie interesuje. On dostaje złączony, gotowy obiekt JSON.
Czy typowanie ścisłe w Schema Definition Language faktycznie ratuje produkcję?
Tak. Typowanie ścisłe to jedna z największych zalet tej technologii. W REST dokumentacja często mija się z prawdą. Swagger bywa nieaktualny. Deweloper backendu dodaje nowe pole do odpowiedzi, ale zapomina zaktualizować specyfikację. Frontend wybucha błędami w przeglądarce klienta.
W GraphQL schemat jest źródłem prawdy. Nie da się wysłać zapytania o pole, które nie istnieje w definicji. Serwer natychmiast odrzuci taki strzał i zwróci błąd walidacji. Narzędzia deweloperskie potrafią na bieżąco analizować zapytania wpisywane w edytorze kodu. Edytor podpowiada dostępne pola. Zaznacza na czerwono literówki. Generuje automatycznie typy dla języka TypeScript. Praca z mocno typowanym grafem ucina o połowę czas spędzany na debugowaniu głupich pomyłek w nazwach zmiennych.
Kiedy wdrożenie GraphQL to techniczny strzał w kolano?
Technologia ta posiada poważne wady architektoniczne. Entuzjaści rzadko o nich wspominają. Chociaż prawdę mówiąc brakuje nam twardych danych z rynku za ubiegły kwartał, wydaje się, że wiele małych zespołów wycofuje się z grafów na rzecz prostych endpointów. Dlaczego? Bo utrzymanie serwera GraphQL jest trudne.
Caching na poziomie protokołu HTTP leży i kwiczy. W REST każdy adres URL to unikalny identyfikator zasobu. Przeglądarki internetowe, serwery proxy i systemy CDN świetnie rozumieją zapytania GET. Potrafią zapamiętać odpowiedź dla `/users/1` i zaserwować ją z pamięci przy kolejnym żądaniu w ułamku sekundy. GraphQL wysyła wszystko przez POST na jeden adres. Ciało zapytania za każdym razem jest inne. Standardowy cache sieciowy tego nie rozumie. Traktuje każdy strzał jako nową operację.
Siedzę nad logami błędów od rana. API zwraca status HTTP 200 OK, a w body JSON widzę wielki obiekt `errors`. W REST dostałbym twarde 400 BAD REQUEST i system monitoringu od razu by to wyłapał na wykresach w Datadog. Tutaj musimy parsować odpowiedź za każdym razem. Zwykłe lenistwo deweloperów z Facebooka. Wdrożyli ten standard pod własne potrzeby dekadę temu i zostawili resztę branży z problemem obsługi błędów na warstwie transportowej.
Aby naprawić brak prostego cachowania, musisz instalować ciężkie biblioteki po stronie klienta. Apollo Client to kombajn. Waży sporo. Wprowadza własną złożoność. Zarządza znormalizowaną pamięcią podręczną w przeglądarce. Dla prostej strony wizytówki z kilkoma dynamicznymi sekcjami zaciąganie Apollo to absurd. Zwykły `fetch` i REST sprawdzi się tam o wiele lepiej.
Porównanie wydajności zapytań i zarządzania danymi na łączach mobilnych
Zestawienie obu architektur pokazuje wyraźne różnice w sposobie traktowania zasobów sprzętowych.
| Cecha architektury | Tradycyjny REST API | Serwer GraphQL |
| Struktura endpointów | Wiele ścieżek URL odpowiadających różnym zasobom. | Jeden adres URL obsługujący cały ruch. |
| Rozmiar payloadu sieciowego | Sztywny. Zawsze pobierasz pełny zestaw danych dla zasobu. | Elastyczny. Pobierasz tylko zdefiniowane w zapytaniu pola. |
| Rozwiązywanie relacji w bazie | Wymaga wysyłania wielu oddzielnych zapytań HTTP. | Pobiera powiązane dane w jednym strzale. |
| Kontrola wersji | Dodawanie /v1, /v2 do ścieżki w adresie. | Ewolucja schematu bez łamania kompatybilności. |
Wykonywanie zapytań zagnieżdżonych to potężna broń. Użytkownik mobilny jadący pociągiem traci zasięg co kilka minut. Zależy mu na tym, żeby aplikacja załadowała się przy jednym połączeniu z masztem bazowym. GraphQL pakuje zapytanie o profil, najnowsze wiadomości, listę znajomych i ustawienia konta w jedną paczkę. Serwer przetwarza to lokalnie po szybkiej sieci wewnętrznej w centrum danych. Klient otrzymuje jeden zwrot. To ratuje konwersję w e-commerce.
Jak radzimy sobie z problemem N+1 przy użyciu DataLoader?
Elastyczność grafu ma mroczną stronę. Problem zapytań N+1. To zmora każdego początkującego zespołu backendowego. Załóżmy, że pytasz o listę stu wpisów na blogu. Dla każdego wpisu prosisz o dane jego autora. Silnik GraphQL najpierw uderza do bazy po sto artykułów. To jedno zapytanie SQL. Następnie dla każdego z tych stu artykułów wywołuje resolver pola autora. Resolver wykonuje zapytanie do bazy o użytkownika. Baza danych dostaje sto jeden zapytań SQL zamiast jednego JOIN-a.
Baza danych płonie. Dosłownie.
Rozwiązaniem jest wzorzec DataLoader. Biblioteka ta grupuje pojedyncze żądania o zasoby w paczki (batching). Zamiast wysyłać sto zapytań `SELECT * FROM users WHERE id = X`, DataLoader zbiera wszystkie identyfikatory podczas jednego cyklu zdarzeń Node.js. Następnie wysyła jedno zapytanie `SELECT * FROM users WHERE id IN (X, Y, Z)`. Wyniki są mapowane z powrotem do odpowiednich resolverów. Zastosowanie tej metody to absolutny obowiązek przy przechodzeniu na produkcję. Bez tego wydajność bazy danych spadnie drastycznie już przy kilkudziesięciu aktywnych użytkownikach.
Co musisz wiedzieć o bezpieczeństwie i limitowaniu zapytań w grafach?
Oddanie klientowi kontroli nad kształtem zapytania rodzi nowe wektory ataków. W REST haker uderza w endpoint tysiąc razy. Systemy rate limitingu oparte o adres IP szybko go blokują. W GraphQL sprawa jest bardziej skomplikowana. Haker może wysłać jedno zapytanie HTTP. Ale zapytanie to może być złośliwe.
Wyobraź sobie schemat, w którym autor ma artykuły. Artykuły mają komentarze. Komentarze mają swoich autorów. Ktoś złośliwy pisze zapytanie proszące o autora, jego artykuły, komentarze do tych artykułów, autora tych komentarzy, jego artykuły, i tak dalej. Zagnieżdża zapytanie na sto poziomów w dół. Wysyła jeden strzał POST o wielkości kilku kilobajtów. Serwer próbuje to zrealizować. Pamięć RAM się zapycha. Proces Node.js umiera. Aplikacja leży.
Zabezpieczamy to na kilka sposobów. Główny mechanizm to limitowanie głębokości zapytania (Depth Limiting). Konfigurujemy serwer tak, aby odrzucał analizę drzewa składniowego, jeśli zagnieżdżenie przekracza na przykład pięć poziomów. Drugi krok to analiza złożoności (Query Complexity). Przypisujemy wagi do poszczególnych pól w schemacie. Pole tekstowe kosztuje 1 punkt. Pole wymagające złączenia tabel w bazie kosztuje 10 punktów. Przed wykonaniem zapytania serwer oblicza jego całkowity koszt. Jeśli przekracza ustalony limit, żądanie jest odrzucane z błędem.
Kolejny trik to wyłączenie introspekcji na środowisku produkcyjnym. Introspekcja to wbudowana funkcja pozwalająca klientom odpytać serwer o cały jego schemat. Narzędzia deweloperskie używają tego do podpowiadania składni. Na produkcji zostawienie otwartej introspekcji to jak oddanie hakerowi mapy wszystkich tabel w bazie danych na tacy. Ucinamy to przed wdrożeniem.
Przebuduj swój najcięższy widok w aplikacji na jedno zapytanie grafowe. Zmierz czas ładowania przed i po. Przeanalizuj zużycie pamięci po stronie serwera i zrzuć wyniki na stół przed zespołem. Zobaczysz różnicę na własnych logach z bazy, a nie w teoretycznych rozważaniach z blogów technologicznych.
Najczęściej zadawane pytania (FAQ)
- Czym różni się GraphQL od REST API? REST API opiera się na wielu endpointach przypisanych do zasobów, przez co często pobiera nadmiar danych lub wymusza wielokrotne zapytania. GraphQL korzysta z jednego punktu końcowego, pozwalając klientowi dokładnie określić strukturę pobieranych danych w jednym żądaniu.
- Co to jest over-fetching i jak GraphQL to rozwiązuje? Over-fetching to sytuacja, w której serwer wysyła więcej danych, niż potrzebuje aplikacja frontowa. GraphQL rozwiązuje to poprzez wymuszenie na kliencie deklaracji konkretnych pól, które chce pobrać. Serwer zwraca wyłącznie te wskazane informacje.
- Do czego służą resolvery w architekturze grafowej? Resolvery to funkcje po stronie serwera odpowiedzialne za dostarczenie danych dla konkretnego pola w schemacie. Wykonują one logikę biznesową, odpytują bazę danych SQL, pamięć podręczną lub inne zewnętrzne interfejsy API.
- Jak zapobiec przeciążeniu bazy danych problemem N+1? Aby uniknąć problemu zapytań N+1, należy wdrożyć wzorzec DataLoader. Grupuje on pojedyncze żądania o zasoby z wielu resolverów i wysyła je do bazy danych jako jedno zbiorcze zapytanie (batching), drastycznie zmniejszając obciążenie systemu.
- Czy GraphQL posiada wbudowany system cachowania? Nie, nie posiada mechanizmów cachowania na poziomie protokołu HTTP, ponieważ wszystkie żądania wysyłane są metodą POST na jeden adres. Wymaga to stosowania bibliotek po stronie klienta, takich jak Apollo Client, lub zaawansowanych mechanizmów po stronie backendu.
- Jak chronić serwer GraphQL przed złośliwymi zapytaniami? Należy wdrożyć mechanizmy limitowania głębokości zapytania (Depth Limiting) oraz analizę złożoności (Query Complexity). Dodatkowo na środowisku produkcyjnym bezwzględnie wyłącza się funkcję introspekcji schematu.
Bibliografia
1. GraphQL Foundation – https://graphql.org
2. Apollo GraphQL – https://www.apollographql.com
3. Mozilla Developer Network (MDN) – https://developer.mozilla.org
4. Node.js Foundation – https://nodejs.org
5. GitHub Engineering Blog – https://github.blog
