Skip to content
← Wszystkie wpisy
5 min czytania Dawid Skłodowski

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/42 jest trywialnie cache’owane przez przeglądarki, CDN-y i proxy. GraphQL to zwykle jeden endpoint POST z 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ś.