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 Ruby deweloperzy 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
To best understand the problem and its solution, let’s create an example. It would be good if the example was both original and fairly down-to-earth. One that each of us can encounter someday. How about… animals? Yes! Great idea!
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