Architektura
ArchitekturaPipeline dyrektyw

Pipeline dyrektyw

Dyrektywy są umieszczane w potoku i wykonywane w kolejności. Ich pierwotny projekt jest prosty, wygląda tak:

Pipeline dyrektyw

W tej architekturze:

  • Wejściem do potoku jest wartość pola dostarczona przez resolver pola
  • Każda dyrektywa wykonuje swoją logikę i przekazuje wynik do następnej dyrektywy w potoku
  • Wyjściem potoku będzie rozwiązana wartość pola, przetworzona przez wszystkie dyrektywy

Ta architektura jednak nie wykorzystuje w pełni możliwości GraphQL. Poniżej znajduje się opis wszystkich etapów rzeczywistego potoku dyrektyw, aż do osiągnięcia faktycznego projektu zaimplementowanego w Gato GraphQL.

Dyrektywy jako bloki budulcowe rozwiązywania query

Początkowo moglibyśmy rozważyć, aby serwer GraphQL rozwiązywał pole za pomocą jakiegoś mechanizmu, a następnie przekazywał tę wartość jako wejście do potoku dyrektyw.

Jednak znacznie prostsze jest posiadanie jednego mechanizmu do obsługi wszystkiego: wywoływanie resolverów pola (zarówno do walidacji pól, jak i ich rozwiązywania) może już odbywać się za pośrednictwem potoku dyrektyw. W tym przypadku potok dyrektyw jest jedynym mechanizmem używanym do rozwiązywania query.

Z tego powodu serwer Gato GraphQL jest wyposażony w dwie specjalne dyrektywy:

  • @validate wywołuje resolver pola w celu sprawdzenia, czy pole może zostać rozwiązane (np.: składnia jest poprawna, pole istnieje itp.)
  • W przypadku powodzenia, @resolveValueAndMerge wywołuje resolver pola w celu rozwiązania pola i scala wartość z obiektem odpowiedzi

Te dwie dyrektywy są specjalnego typu „systemowego": są zarezerwowane wyłącznie dla silnika GraphQL i są niejawne dla każdego pola. (W przeciwieństwie do nich, standardowe dyrektywy są jawne: są dodawane do query przez użytkownika.)

Używając tych dwóch dyrektyw, ta query:

query {
  field1
  field2 @directiveA
}

...zostanie rozwiązana jako ta:

query {
  field1 @validate @resolveValueAndMerge
  field2 @validate @resolveValueAndMerge @directiveA
}

Potok wygląda teraz tak (należy zauważyć, że potok otrzymuje pole jako wejście, a nie jego pierwotną rozwiązaną wartość):

Pipeline dyrektyw z @validate i @resolveValueAndMerge

Sloty potoku

Dyrektywy są normalnie wykonywane po @resolveValueAndMerge, ponieważ najczęściej wiążą się z aktualizacją wartości rozwiązanego pola. Jednak istnieją inne dyrektywy, które muszą być wykonane przed @validate lub pomiędzy @validate a @resolveValueAndMerge.

Na przykład:

  • Aby zmierzyć czas potrzebny do rozwiązania pola, dyrektywa @traceExecutionTime może uzyskać bieżący czas przed i po rozwiązaniu pola, umieszczając subdyrektywy @startTracingExecutionTime na początku i @endTracingExecutionTime na końcu potoku
  • Dyrektywa @cache musi sprawdzić, czy żądane pole jest w pamięci podręcznej i zwrócić tę odpowiedź bezpośrednio, przed wykonaniem @resolveValueAndMerge

Potok będzie oferował pięć różnych slotów za pomocą klasy PipelinePositions, a dyrektywa wskaże, w którym z nich ma być wykonana:

  • Slot "beginning": na samym początku
  • Slot "before-validate": przed przeprowadzeniem walidacji
  • Slot "middle": po walidacji i przed rozwiązaniem pola
  • Slot "after-resolve": po rozwiązaniu pola
  • Slot "end": na samym końcu

Potok dyrektyw wygląda teraz tak (biorąc pod uwagę tylko 3 etapy, dla uproszczenia):

Pipeline dyrektyw ze slotami

Należy zauważyć, jak dyrektywy @skip i @include mogą być tak łatwo obsługiwane w tej architekturze: umieszczone w slocie "middle", mogą poinformować dyrektywę @resolveValueAndMerge (wraz ze wszystkimi dyrektywami na późniejszych etapach potoku), aby nie wykonywała się, ustawiając flagę skipExecution na true.

Dyrektywa @skip w potoku

Wykonywanie dyrektywy na wielu polach w jednym wywołaniu

Do tej pory rozważaliśmy jedno pole jako wejście do potoku dyrektyw. Jednak w typowej query GraphQL otrzymamy kilka pól, na których należy wykonać dyrektywy.

Na przykład w poniższej query dyrektywa @upperCase jest wykonywana na polach "field1" i "field2":

query {
  field1 @upperCase
  field2 @upperCase
  field3
}

Ponadto, ponieważ silnik GraphQL dodaje systemowe dyrektywy @validate i @resolveValueAndMerge do każdego pola w query, tak że ta query:

query {
  field1
  field2
  field3
}

...jest rozwiązywana jako ta query:

query {
  field1 @validate @resolveValueAndMerge
  field2 @validate @resolveValueAndMerge
  field3 @validate @resolveValueAndMerge
}

Zatem dyrektywy systemowe zawsze będą otrzymywać wszystkie pola jako wejścia.

W konsekwencji potok dyrektyw jest zaprojektowany tak, aby otrzymywać wiele pól jako wejście, a nie tylko jedno na raz:

Odbieranie wielu pól jako wejście w potoku dyrektyw

Ta architektura jest bardziej wydajna, ponieważ wykonanie dyrektywy tylko raz dla wszystkich pól jest szybsze niż wykonywanie jej raz na pole, a wyniki będą takie same.

Na przykład podczas walidacji, czy użytkownik jest zalogowany w celu przyznania dostępu do schematu, operacja może być wykonana tylko raz. Uruchomienie następującego kodu:

if (isUserLoggedIn()) {
  resolveFields([$field1, $field2, $field3]);
}

jest bardziej wydajne niż uruchamianie tego kodu:

if (isUserLoggedIn()) {
  resolveField($field1);
}
if (isUserLoggedIn()) {
  resolveField($field2);
}
if (isUserLoggedIn()) {
  resolveField($field3);
}

Może się to nie wydawać wielką sprawą podczas wywoływania funkcji lokalnej, takiej jak isUserLoggedIn, jednak może mieć duże znaczenie podczas interakcji z usługami zewnętrznymi, np. przy rozwiązywaniu endpointów REST za pośrednictwem GraphQL. W takich przypadkach wykonanie funkcji raz zamiast wielokrotnie może decydować o tym, czy możliwe jest dostarczenie określonej funkcjonalności.

Zobaczmy przykład. Podczas interakcji z Google Translate za pośrednictwem dyrektywy @translate, API GraphQL musi nawiązać połączenie przez sieć. Wykonanie tego kodu będzie więc tak szybkie, jak to możliwe:

googleTranslateFields([$field1, $field2, $field3]);

W przeciwieństwie do tego, wykonywanie funkcji oddzielnie, wielokrotnie, spowoduje większe opóźnienie, co przełoży się na dłuższy czas odpowiedzi i obniży wydajność API. Możliwe, że nie jest to duża różnica przy tłumaczeniu 3 ciągów znaków (gdzie pole jest ciągiem do przetłumaczenia), ale dla 100 lub więcej ciągów z pewnością będzie miało znaczenie:

googleTranslateField($field1);
googleTranslateField($field2);
googleTranslateField($field3);

Ponadto wykonanie funkcji raz z wszystkimi wejściami może dać lepszą odpowiedź niż wykonywanie funkcji na każdym polu niezależnie. Używając ponownie Google Translate jako przykładu, tłumaczenie będzie dokładniejsze, im więcej danych dostarczymy usłudze.

Na przykład podczas wykonywania poniższego kodu:

googleTranslate("fork");
googleTranslate("road");
googleTranslate("sign");

Przy pierwszym niezależnym wykonaniu Google nie zna kontekstu słowa "fork", więc może odpowiedzieć z fork jako sztućcem do jedzenia, jako rozwidleniem drogi lub z innym znaczeniem. Jednak jeśli zamiast tego wykonamy:

googleTranslate(["fork", "road", "sign"]);

Na podstawie tej szerszej ilości informacji Google może wywnioskować, że "fork" odnosi się do rozwidlenia drogi i zwrócić precyzyjne tłumaczenie.

Z tych powodów dyrektywy w potoku otrzymują pola wejściowe wszystkie razem, a następnie każda dyrektywa może zdecydować o najlepszym sposobie uruchomienia swojej logiki na tych wejściach (jedno wykonanie na wejście, jedno wykonanie obejmujące wszystkie wejścia lub cokolwiek pomiędzy).

Potok wygląda teraz tak:

Odbieranie wielu pól jako wejście w potoku dyrektyw

Wykonywanie jednego potoku dyrektyw dla całej query

Właśnie dowiedzieliśmy się, że sensowne jest wykonywanie wielu pól na dyrektywę, jednak działa to dobrze, dopóki wszystkie pola mają te same dyrektywy zastosowane do nich. Gdy dyrektywy są różne, może to prowadzić do większej złożoności, która utrudnia implementację i zmniejsza niektóre z uzyskanych korzyści.

Zobaczmy, jak to się dzieje. Rozważmy następującą query:

query {
  field1 @directiveA
  field2
  field3
}

Ta dyrektywa jest równoważna tej:

query {
  field1 @validate @resolveValueAndMerge @directiveA
  field2 @validate @resolveValueAndMerge
  field3 @validate @resolveValueAndMerge
}

W tym scenariuszu pola field2 i field3 mają ten sam zestaw dyrektyw, a field1 ma inny, więc musielibyśmy wygenerować 2 różne potoki, aby rozwiązać query:

Query wymaga 2 potoków dyrektyw do rozwiązania

A gdy wszystkie pola mają unikalny zestaw dyrektyw, efekt jest jeszcze bardziej wyraźny. Rozważmy tę query:

query {
  field1 @directiveA
  field2 @directiveB @directiveC
  field3 @directiveC
}

Która jest równoważna tej:

query {
  field1 @validate @resolveValueAndMerge @directiveA
  field2 @validate @resolveValueAndMerge @directiveB @directiveC
  field3 @validate @resolveValueAndMerge @directiveC
}

W tej sytuacji będziemy mieć 3 potoki do obsługi 3 pól, tak jak to:

Query wymaga 3 potoków dyrektyw do rozwiązania

W tym przypadku, mimo że dyrektywy @validate i @resolveValueAndMerge są stosowane do 3 pól, ponieważ są wykonywane przez 3 różne potoki dyrektyw, będą wykonywane niezależnie od siebie, co prowadzi nas z powrotem do sytuacji, w której dyrektywa jest wykonywana na jednym elemencie na raz.

Rozwiązaniem tego problemu jest unikanie tworzenia wielu potoków i obsługiwanie jednego potoku dla wszystkich pól. W konsekwencji silnik nie przekazuje już pól jako wejście do potoku, ponieważ nie wszystkie dyrektywy z jednego potoku będą wchodzić w interakcję z tym samym zestawem pól; zamiast tego każda dyrektywa musi otrzymywać własną listę pól jako własne wejście.

Zatem dla tej query:

query {
  field1 @directiveA
  field2
  field3
}

...dyrektywy @validate i @resolveValueAndMerge otrzymają wszystkie 3 pola jako wejścia, a directiveA otrzyma tylko "field1":

Pojedynczy potok dyrektyw do rozwiązania wszystkich pól

I dla tej query:

query {
  field1 @directiveA
  field2 @directiveB @directiveC
  field3 @directiveC
}

...dyrektywy @validate i @resolveValueAndMerge otrzymają wszystkie 3 pola jako wejścia, directiveA otrzyma tylko "field1", directiveB otrzyma tylko "field2", a directiveC otrzyma "field2" i "field3":

Pojedynczy potok dyrektyw do rozwiązania wszystkich pól

Kontrolowanie wykonania dyrektywy ID po ID

Do tej pory dyrektywa na pewnym etapie mogła wpływać na wykonanie dyrektyw na późniejszych etapach za pomocą flagi skipExecution. Jednak ta flaga nie jest wystarczająco szczegółowa dla wszystkich przypadków.

Na przykład rozważmy dyrektywę @cache, umieszczoną w slocie "end" w celu przechowywania wartości pola, tak aby następnym razem, gdy pole jest odpytywane, jego wartość mogła być pobrana z pamięci podręcznej za pomocą dyrektywy @getCache umieszczonej w slocie "middle":

Potok z dyrektywami @getCache i @cache

Podczas wykonywania tej query:

{
  posts(pagination: { limit: 2 }) {
    title @translate @cache
  }
}

Serwer pobierze i zapisze w pamięci podręcznej 2 rekordy. Następnie wykonujemy tę samą query, ale zastosowaną do 4 rekordów:

{
  posts(pagination: { limit: 4 }) {
    title @translate @cache
  }
}

Podczas wykonywania tej 2. query 2 rekordy z 1. query były już w pamięci podręcznej, ale pozostałe 2 rekordy nie były. Jednak potrzebowalibyśmy, aby wszystkie 4 rekordy były już w pamięci podręcznej, żeby móc użyć flagi skipExecution. Lepiej byłoby, gdybyśmy mogli pobrać pierwsze 2 rekordy z pamięci podręcznej i rozwiązać tylko pozostałe 2 rekordy.

Dlatego ponownie aktualizujemy projekt potoku. Rezygnujemy z flagi skipExecution i zamiast tego przekazujemy każdej dyrektywie listę ID obiektów na pole, gdzie dyrektywa ma być zastosowana, za pomocą obiektu wejściowego fieldIDs:

{
  field1: [ID11, ID12, ...],
  field2: [ID21, ID22, ...],
  ...
  fieldN: [IDN1, IDN2, ...],
}

Zmienna fieldIDs jest unikalna dla każdej dyrektywy, a każda dyrektywa może modyfikować instancję fieldIDs dla wszystkich dyrektyw na późniejszych etapach. Zatem skipExecution może być wykonywane szczegółowo, ID po ID, po prostu usuwając ID z fieldIDs dla wszystkich nadchodzących dyrektyw w stosie.

Potok wygląda teraz tak:

Przekazywanie ID na pole do każdej dyrektywy

Zastosowane do poprzedniego przykładu, podczas wykonywania pierwszej query tłumaczącej 2 rekordy, potok wygląda tak:

Przekazywanie ID na pole do każdej dyrektywy dla 1. query

Podczas wykonywania drugiej query tłumaczącej 4 rekordy, dyrektywa @getCache otrzymuje ID wszystkich 4 rekordów, ale zarówno @resolveValueAndMerge, jak i @cache otrzymają tylko ID ostatnich 2 rekordów (które nie są w pamięci podręcznej):

Przekazywanie ID na pole do każdej dyrektywy dla 2. query

Łącząc wszystko razem

To jest ostateczny projekt potoku dyrektyw:

Ostateczny projekt potoku dyrektyw

Podsumowując, oto jego cechy charakterystyczne:

  • Resolvery pola są wywoływane z wnętrza potoku dyrektyw, za pomocą dyrektyw @validate i @resolveValueAndMerge
  • Dyrektywy mogą być umieszczone w dowolnym z 5 slotów: "beginning", "before-validate", "middle", "after-validate" i "end"
  • Dyrektywy rozwiązują wiele pól w jednym wywołaniu
  • Jeden potok zawiera wszystkie dyrektywy zaangażowane w query
  • Każda dyrektywa otrzymuje własny zestaw ID do rozwiązania na pole za pomocą zmiennej fieldIDs
  • Dyrektywy mogą modyfikować zmienną fieldIDs dla wszystkich dyrektyw na późniejszym etapie potoku