GraphQL w Ruby: API od schematu
GraphQL pozwala klientowi poprosić dokładnie o dane, których potrzebuje, rozwiązując over- i under-fetching REST. Deep-dive o graphql-ruby — typy, zapytania, mutacje, resolvery — pułapka N+1, którą tworzy, i uczciwe spojrzenie na GraphQL vs REST.
REST służył nam dobrze, ale ma dwie uporczywe frustracje, a pogarszają się, im więcej masz
klientów. Over-fetching: endpoint zwraca tłusty obiekt, gdy ekran potrzebuje trzech pól.
Under-fetching: ekran potrzebuje danych z kilku zasobów, więc klient robi trzy–cztery rundy.
GraphQL — coraz bardziej mainstreamowy w 2018, z dojrzałym gemem graphql-ruby — atakuje oba,
pozwalając klientowi określić dokładnie dane, których chce, w jednym żądaniu. Oto jak działa w
Ruby, pułapka, którą zastawia, i kiedy faktycznie jest właściwym wyborem.
Sedno: klient prosi o kształt
W REST serwer decyduje, co każdy endpoint zwraca. W GraphQL klient. Wystawiasz pojedynczy endpoint i schemat opisujący twoje typy i ich relacje, a klient wysyła zapytanie opisujące precyzyjnie pola i zagnieżdżone asocjacje, których chce — dostając z powrotem dokładnie ten kształt, nic więcej, nic mniej:
query {
product(id: 42) {
name
price
reviews(first: 3) {
rating
author { name }
}
}
}
Jedno żądanie, dokładnie pola, których potrzebuje ekran, w tym zagnieżdżone dane z powiązanych typów. Bez over-fetchingu (poprosiłeś o trzy pola recenzji, dostajesz trzy), bez under-fetchingu (produkt, jego recenzje i nazwiska recenzentów przyszły w jednej rundzie). Dla UI składającego widok z kilku zasobów — a zwłaszcza dla mobile, gdzie rundy są kosztowne — to prawdziwa poprawa względem orkiestrowania wielu wywołań REST.
Od schematu: typy, zapytania, mutacje
GraphQL jest typowany, a schemat to kontrakt. W graphql-ruby definiujesz typy odzwierciedlające
twoją domenę:
class Types::Product < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
field :price, Float, null: false
field :reviews, [Types::Review], null: false do
argument :first, Integer, required: false
end
end
Odczyty dzieją się przez zapytania, zakorzenione w typie query; zapisy przez mutacje, które są jawnymi, nazwanymi operacjami:
class Mutations::AddReview < Mutations::BaseMutation
argument :product_id, ID, required: true
argument :rating, Integer, required: true
argument :body, String, required: true
field :review, Types::Review, null: true
field :errors, [String], null: false
def resolve(product_id:, rating:, body:)
review = Review.new(product_id: product_id, rating: rating, body: body)
if review.save
{ review: review, errors: [] }
else
{ review: nil, errors: review.errors.full_messages }
end
end
end
Schemat będący silnie typowany to realna korzyść: jest samodokumentujący się (narzędzia jak GraphiQL pozwalają klientom eksplorować go interaktywnie), waliduje zapytania, zanim się uruchomią, a narzędzia frontu mogą generować typy z niego. Kontrakt między klientem a serwerem jest jawny i czytelny maszynowo w sposób, w jaki endpointy REST rzadko są.
Pułapka: zapytania N+1 w resolverach
Oto rzecz, przed którą nikt nie ostrzega i która gryzie każdego początkującego w GraphQL. Każde pole może mieć resolver — funkcję pobierającą jego dane — a ponieważ klient zagnieżdża zapytania dowolnie, naiwne resolvery produkują katastrofalne N+1. Rozważ zapytanie powyżej proszące o 50 produktów i recenzje każdego: resolver produktów uruchamia jedno zapytanie, potem resolver pola reviews uruchamia się raz na produkt, potem pole author raz na recenzję. Elastyczne zagnieżdżenie, które czyni GraphQL potężnym, czyni problem N+1 strukturalnym, nie sporadycznym.
Rozwiązaniem jest batchowanie: zbierz ID potrzebne przez wszystkie rodzeństwa rozwiązań i
załaduj je w jednym zapytaniu. W Ruby to graphql-batch (albo wzorzec dataloader):
field :author, Types::User, null: false
def author
# batchuje wszystkie wyszukiwania author przez całe zapytanie w jedno SELECT ... WHERE id IN (...)
Loaders::RecordLoader.for(User).load(object.author_id)
end
To niezbędne, nie opcjonalne: API GraphQL bez batchowania przewróci się w chwili, gdy klient wyśle głęboko zagnieżdżone zapytanie, bo drzewo resolverów eksploduje w setki pojedynczych zapytań. Zrozumienie tego — i wpięcie batchowania od początku — to najważniejsza rzecz do zrobienia dobrze w serwerze GraphQL w Ruby.
Inne koszty, które GraphQL wnosi
Uczciwość ma znaczenie, bo GraphQL jest modny i przereklamowany. Co cię kosztuje:
- Cache’owanie jest trudniejsze. Wielką siłą REST jest cache’owanie HTTP —
GET /products/42jest trywialnie cache’owane przez przeglądarki, CDN-y i proxy. GraphQL to zwykle jeden endpointPOSTz zapytaniem w ciele, więc standardowe cache’owanie HTTP się nie stosuje; cache’ujesz na warstwie resolvera/danych, co jest większą pracą. - Złożoność i bezpieczeństwo. Klient może wysłać dowolnie głębokie, kosztowne zapytanie — złośliwe albo nieostrożne może poprosić o głęboko zagnieżdżone dane, które kosztują cię ogromnie. Musisz dodać limity głębokości i złożoności zapytań, by zapobiec odmowie usługi, czego stałe endpointy REST nie wymagają.
- To więcej maszynerii. Typy, resolvery, mutacje, batchowanie, analiza złożoności — jest po prostu więcej do zbudowania i zrozumienia niż garść endpointów REST.
Kiedy GraphQL, a kiedy REST
Uczciwy framework decyzji:
- GraphQL błyszczy, gdy masz wielu różnorodnych klientów o różnych potrzebach danych (web, iOS, Android, partnerzy) uderzających w ten sam backend, gdy masz głęboko zagnieżdżone/powiązane dane, które REST każe ci pobierać w wielu rundach, i gdy klienci ewoluują szybciej, niż możesz rewidować endpointy — po prostu proszą o nowe pola. Im bardziej różnorodni klienci, tym bardziej GraphQL się opłaca.
- REST wciąż jest właściwy dla prostego API z jednym znanym klientem, gdzie chcesz trywialnego cache’owania HTTP, albo gdzie maszyneria GraphQL jest przesadą dla tego, co naprawdę jest garścią endpointów CRUD. Nie przyjmuj GraphQL, bo jest nowoczesny; przyjmij, bo różnorodność klientów albo kształt danych naprawdę tego potrzebuje.
Werdykt
GraphQL to prawdziwe rozwiązanie over- i under-fetchingu REST: typowany kontrakt od schematu, gdzie
klient prosi dokładnie o dane, których potrzebuje, w jednym żądaniu, co jest realną wygraną dla
aplikacji z wieloma klientami i bogato zagnieżdżonymi danymi. graphql-ruby czyni budowanie tego
przyjemnym — ale wejdź, znając pułapkę (N+1 resolverów są strukturalne; wepnij graphql-batch od
pierwszego dnia) i koszty (cache’owanie HTTP już nie działa po prostu; musisz ograniczyć głębokość i
złożoność zapytań przeciw nadużyciom). Sięgaj po niego, gdy różnorodność klientów albo zagnieżdżone
dane uzasadniają dodatkową maszynerię, i zostań przy REST, gdy proste, cache’owalne API dobrze
służy jednemu klientowi. Użyty do właściwego problemu, GraphQL jest znakomity; przyjęty jako moda
nad prostą potrzebą REST, to mnóstwo złożoności dla problemu, którego nie miałeś.