W tym artykule przedstawię zastosowanie polimorfizmu w GraphQL. Zanim jednak zacznę, warto przypomnieć czym są polimorfizm i GraphQL.
Polimorfizm
Polimorfizm jest kluczowym składnikiem programowanie obiektowe. Upraszczając, opiera się ona na fakcie, że obiekty różnych klas mają dostęp do tego samego interfejsu, co oznacza, że od każdego z nich możemy oczekiwać tej samej funkcjonalności, ale niekoniecznie zaimplementowanej w ten sam sposób. W Programiści Ruby może uzyskać polimorfizm na trzy sposoby:
Dziedziczenie
Dziedziczenie polega na utworzeniu klasy nadrzędnej i klas podrzędnych (tj. dziedziczących z klasy nadrzędnej). Podklasy otrzymują funkcjonalność klasy nadrzędnej, a także umożliwiają zmianę i dodanie funkcjonalności.
Przykład:
class Document
attr_reader :name
koniec
class PDFDocument < Dokument
def extension
pdf
end
end
class ODTDocument < Document
def extension
odt
end
end
Moduły
Moduły w Ruby mają wiele zastosowań. Jednym z nich są mixiny (przeczytaj więcej o mixinach w Ostateczny podział: Ruby vs. Python). Mixiny w Rubim mogą być używane podobnie do interfejsów w innych językach programowania (np. w Java) można na przykład zdefiniować w nich metody wspólne dla obiektów, które będą zawierały dany mixin. Dobrą praktyką jest umieszczanie w modułach metod tylko do odczytu, czyli takich, które nie będą modyfikować stanu tego obiektu.
Przykład:
moduł Taxable
def tax
cena * 0.23
koniec
koniec
class Samochód
include Taxable
attr_reader :price
end
class Book
include Taxable
attr_reader :price
koniec
Pisanie na klawiaturze
Jest to jedna z kluczowych cech języków dynamicznie typowanych. Nazwa pochodzi od słynnego testu: jeśli wygląda jak kaczka, pływa jak kaczka i kwacze jak kaczka, to prawdopodobnie jest kaczką. Test programista nie musi interesować się, do jakiej klasy należy dany obiekt. Ważne są metody, które można wywołać na tym obiekcie.
Korzystając z klas zdefiniowanych w powyższym przykładzie:
class Samochód
attr_reader :price
def initialize(price)
@price = price
end
end
class Book
attr_reader :price
def initialize(price)
@price = price
end
end
car = Car.new(20.0)
book = Book.new(10.0)
[car, book].map(&:price
GrapQL
GraphQL to stosunkowo nowy język zapytań dla interfejsów API. Jego zalety obejmują fakt, że ma bardzo prostą składnię, a ponadto klient decyduje, co dokładnie chce uzyskać, ponieważ każdy klient otrzymuje dokładnie to, czego chce i nic więcej.
To chyba wszystko, co powinniśmy wiedzieć w tym momencie. Przejdźmy więc do rzeczy.
Przedstawienie problemu
Aby jak najlepiej zrozumieć problem i jego rozwiązanie, stwórzmy przykład. Dobrze by było, gdyby przykład był zarówno oryginalny, jak i dość przyziemny. Taki, z którym każdy z nas może się kiedyś zetknąć. Może... zwierzęta? Tak! Świetny pomysł!
Załóżmy, że mamy aplikację backendową napisaną w języku Ruby on Rails. Jest on już przystosowany do obsługi powyższego schematu. Załóżmy również, że mamy już GraphQL skonfigurowany. Chcemy umożliwić klientowi złożenie zapytania w ramach następującej struktury:
{
allZoos : {
zoo: {
nazwa
miasto
zwierzęta: {
...
}
}
}
}
Co należy wstawić zamiast trzech kropek, aby uzyskać brakujące informacje - dowiemy się później.
Wdrożenie
Poniżej przedstawię kroki potrzebne do osiągnięcia tego celu.
Dodawanie zapytania do QueryType
Najpierw należy zdefiniować, co dokładnie oznacza zapytanie allZoos. Aby to zrobić, musimy odwiedzić plikapp/graphql/types/query_type.rb i zdefiniuj zapytanie:
moduł Typy
class QueryType < Types::BaseObject
field :all_zoos, [Types::ZooType], null: false
def all_zoos
Zoo.all
end
end
end
Zapytanie jest już zdefiniowane. Teraz nadszedł czas, aby zdefiniować typy zwracane.
Definicja typów
Pierwszym wymaganym typem będzie ZooType. Zdefiniujmy go w pliku app/graphql/types/ zoo_type.rb:
moduł Typy
class ZooType < Types::BaseObject
pole :name, String, null: false
field :city, String, null: false
field :animals, [Types::AnimalType], null: false
end
end
Teraz nadszedł czas na zdefiniowanie typu AnimalType:
moduł Typy
class AnimalType < Types::BaseUnion
possible_types ElephantType, CatType, DogType
def self.resolve_type(obj, ctx)
if obj.is_a?(Elephant)
ElephantType
elsif obj.is_a?(Cat)
CatType
elsif obj.is_a?(Dog)
DogType
end
end
end
end
Musimy wymienić wszystkie typy, które mogą tworzyć dany związek. 3.zastępujemy funkcję self.resolve_object(obj, ctx),która musi zwracać typ danego obiektu.
Następnym krokiem jest zdefiniowanie typów zwierząt. Wiemy jednak, że niektóre pola są wspólne dla wszystkich zwierząt. Uwzględnijmy je w typie AnimalInterface:
moduł Typy
moduł AnimalInterface
include Types::BaseInterface
field :name, String, null: false
field :age, Integer, null: false
end
end
Mając ten interfejs, możemy przystąpić do definiowania typów konkretnych zwierząt:
moduł Typy
class ElephantType < Types::BaseObject
implementuje Types::AnimalInterface
field :trunk_length, Float, null: false
end
end
moduł Typy
class CatType < Types::BaseObject
implementuje Types::AnimalInterface
pole :hair_type, String, null: false
end
end
moduł Typy
class DogType < Types::BaseObject
implementuje Types::AnimalInterface
field :breed, String, null: false
end
end
To jest to! Gotowe! Ostatnie pytanie: jak możemy wykorzystać to, co zrobiliśmy od strony klienta?
Tworzenie zapytania
{
allZoos : {
zoo: {
nazwa
miasto
zwierzęta: {
__typename
... on ElephantType {
nazwa
wiek
trunkLength
}
... on CatType {
nazwa
wiek
hairType
}
... on DogType {
nazwa
wiek
rasa
}
}
}
}
}
Możemy tutaj użyć dodatkowego pola __typename, które zwróci dokładny typ danego elementu (np. CatType). Jak będzie wyglądać przykładowa odpowiedź?
Widoczna jest jedna wada tego podejścia. W zapytaniu musimy wpisać nazwę i wiek w każdym typie, mimo że wiemy, że wszystkie zwierzęta mają te pola. Nie jest to uciążliwe, gdy kolekcja zawiera zupełnie różne obiekty. W tym przypadku jednak zwierzęta mają prawie wszystkie pola wspólne. Czy można to jakoś poprawić?
Oczywiście! Dokonujemy pierwszej zmiany w pliku app/graphql/types/zoo_type.rb:
moduł Typy
class ZooType < Types::BaseObject
pole :name, String, null: false
field :city, String, null: false
field :animals, [Types::AnimalInterface], null: false
end
end
Nie potrzebujemy już związku, który zdefiniowaliśmy wcześniej. Zmieniamy się Types::AnimalType do Types::AnimalInterface.
Następnym krokiem jest dodanie funkcji zwracającej typ z pliku Typy :: AnimalInterface a także dodać listę orphan_types, czyli typów, które nigdy nie są bezpośrednio używane:
moduł Typy
moduł AnimalInterface
include Types::BaseInterface
field :name, String, null: false
field :age, Integer, null: false
definition_methods do
def resolve_type(obj, ctx)
if obj.is_a?(Elephant)
ElephantType
elsif obj.is_a?(Cat)
CatType
elsif obj.is_a?(Dog)
DogType
end
end
end
orphan_types Types::ElephantType, Types::CatType, Types::DogType
end
end
Dzięki temu prostemu zabiegowi zapytanie ma mniej skomplikowaną formę:
{
allZoos : {
zoo: {
nazwa
miasto
zwierzęta: {
__typename
nazwa
wiek
... on ElephantType {
trunkLength
}
... on CatType {
hairType
}
... on DogType {
rasa
}
}
}
}
}
Podsumowanie
GraphQL to naprawdę świetne rozwiązanie. Jeśli jeszcze go nie znasz, spróbuj. Zaufaj mi, warto. Świetnie radzi sobie z rozwiązywaniem problemów pojawiających się np. w REST API. Jak pokazałem powyżej, polimorfizm nie jest dla niego prawdziwą przeszkodą. Przedstawiłem dwie metody radzenia sobie z tym problemem. Przypomnienie:
Jeśli operujesz na liście obiektów o wspólnej bazie lub wspólnym interfejsie - użyj interfejsów,
Jeśli operujesz na liście obiektów o innej strukturze, użyj innego interfejsu - użyj unii