GraphQL, jak każda technologia, ma swoje problemy, niektóre z nich wynikają bezpośrednio z architektury, a niektóre są identyczne z tym, co widzimy w każdej innej aplikacji. Rozwiązania są jednak zupełnie inne.
Aby przedstawić problem, załóżmy następującą architekturę aplikacji:
A tutaj odpowiadające zapytanie w GraphQL aby pobrać dane. Pobieramy wszystkie linki, wraz z plakatem i jego linkami dodanymi do systemu,
{
allLinks {
id
url
opis
createdAt
postedBy {
id
nazwa
links {
id
}
}
}
}
Jak pokazano poniżej, widzimy tutaj klasyczny problem n + 1 z relacjami.
Link Load (0.4ms) SELECT "links".* FROM "links" ORDER BY created_at DESC
app/controllers/graphql_controller.rb:5:in `execute'
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users". "id" = ? LIMIT ? [["id", 40], ["LIMIT", 1]]
↳ app/controllers/graphql_controller.rb:5:in `execute'
Link Load (0.3ms) SELECT "links".* FROM "links" WHERE "links". "user_id" = ? [["user_id", 40]]
↳ app/controllers/graphql_controller.rb:5:in `execute'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users". "id" = ? LIMIT ? [["id", 38], ["LIMIT", 1]]
↳ app/controllers/graphql_controller.rb:5:in `execute'
Link Load (0.1ms) SELECT "links".* FROM "links" WHERE "links". "user_id" = ? [["user_id", 38]]
↳ app/controllers/graphql_controller.rb:5:in `execute'
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users". "id" = ? LIMIT ? [["id", 36], ["LIMIT", 1]]
↳ app/controllers/graphql_controller.rb:5:in `execute'
Link Load (0.1ms) SELECT "links".* FROM "links" WHERE "links". "user_id" = ? [["user_id", 36]]
↳ app/controllers/graphql_controller.rb:5:in `execute'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users". "id" = ? LIMIT ? [["id", 34], ["LIMIT", 1]]
↳ app/controllers/graphql_controller.rb:5:in `execute'
Link Load (0.2ms) SELECT "links".* FROM "links" WHERE "links". "user_id" = ? [["user_id", 34]]
↳ app/controllers/graphql_controller.rb:5:in `execute'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users". "id" = ? LIMIT ? [["id", 32], ["LIMIT", 1]]
W tym przypadku działa to dokładnie tak, jak ten kawałek kod: Link.all.map(&:user).map(&:links).
Wydaje się, że znamy rozwiązanie tego problemu: Link.includes(user: :links).map(&:user).map(&:links)ale czy to naprawdę działa? Sprawdźmy to!
Aby zweryfikować poprawkę, zmieniłem GraphQL zapytanie, aby użyć tylko kilku pól i żadnych relacji.
{
allLinks {
id
url
opis
createdAt
}
}
Niestety wynik pokazuje, że pomimo braku powiązań w stosunku do użytkownika i jego linków, nadal dołączamy te dane do zapytania bazodanowego. Niestety są one nadmiarowe, a przy jeszcze bardziej skomplikowanej strukturze okazuje się to po prostu nieefektywne.
W GraphQLTakie problemy rozwiązuje się inaczej, po prostu ładując dane partiami, zakładając, że dane są potrzebne w momencie umieszczania ich w zapytaniu. Jest to takie leniwe ładowanie. Jedną z najpopularniejszych bibliotek jest https://github.com/Shopify/graphql-batch/.
Niestety jego instalacja nie jest tak bezproblemowa, jak mogłoby się wydawać. Programy ładujące dane są dostępne tutaj: https://github.com/Shopify/graphql-batch/tree/master/examples, mam na myśli plik RecordLoader klasa iAssociationLoader class. Klasycznie zainstalujmy klasę gem 'graphql-batch' a następnie dodać ją do naszego schematu, a także programów ładujących:
# graphql-ruby/app/graphql/graphql_tutorial_schema.rb
class GraphqlTutorialSchema < GraphQL::Schema
query Types::QueryType
mutation Types::MutationType
use GraphQL::Batch
...
end
I nasze typy:
# graphql-ruby/app/graphql/types/link_type.rb
moduł Typy
class LinkType < BaseNode
field :created_at, DateTimeType, null: false
field :url, String, null: false
field :description, String, null: false
field :posted_by, UserType, null: false, method: :user
field :votes, [Types::VoteType], null: false
def user
Loaders::RecordLoader.for(User).load(object.user_id)
end
end
end
# graphql-ruby/app/graphql/types/user_type.rb
moduł Types
class UserType < BaseNode
field :created_at, DateTimeType, null: false
field :name, String, null: false
field :email, String, null: false
field :votes, [VoteType], null: false
pole :links, [LinkType], null: false
def links
Loaders::AssociationLoader.for(User, :links).load(object)
end
end
end
W wyniku korzystania z loaderów, dane są wsadowe, a zapytania o dane są składane w dwóch prostych zapytaniach sql:
Istnieją również inne rozwiązania, które rozwiązują ten problem, np:
Złożoność zapytań
N + 1 zapytań to nie wszystko, w GraphQL możemy swobodnie przenosić kolejne atrybuty. Domyślnie jest on ustawiony na 1. Może to być czasem zbyt dużo dla serwera, szczególnie w sytuacji, gdy możemy dowolnie zagnieżdżać dane. Jak sobie z tym poradzić? Możemy ograniczyć złożoność zapytań, ale w tym celu musimy również określić ich koszt w atrybutach. Domyślnie jest on ustawiony na 1. Koszt ten ustawiamy za pomocą atrybutu złożoność: atrybut, w którym możemy wprowadzić dane: field: links, [LinkType], null: false, complexity: 101. Jeśli ograniczenie ma faktycznie działać, nadal musisz wprowadzić maksymalny limit do swojego programu:
class GraphqlTutorialSchema < GraphQL::Schema
query Types::QueryType
mutation Types::MutationType
use GraphQL::Batch
max_complexity 100
...
end
Śledzenie
GraphQL SQL przetwarza zapytania w różny sposób, a śledzenie nie jest takie proste w porównaniu do tego, co możemy zrobić lokalnie. Niestety rack mini profiler czy zwykły log SQL nie powie nam wszystkiego i nie wskaże, która część zapytania odpowiada za dany wycinek czasu. W przypadku GraphQL-Ruby możemy skorzystać z komercyjnych rozwiązań dostępnych tutaj: https://graphql-ruby.org/queries/tracinglub spróbować przygotować własny tracing. Poniżej, snippet wygląda jak lokalny tracer.
# lib/my_custom_tracer.rb
class MyCustomTracer 'graphql.lex',
'parse' => 'graphql.parse',
'validate' => 'graphql.validate',
'analyze_query' => 'graphql.analyze_query',
'analyze_multiplex' => 'graphql.analyze_multiplex',
'execute_multiplex' => 'graphql.execute_multiplex',
'execute_query' => 'graphql.execute_query',
'execute_query_lazy' => 'graphql.execute_query_lazy'
}
def platform_trace(platform_key, key, _data, &block)
start = ::Process.clock_gettime ::Process::CLOCK_MONOTONIC
result = block.call
duration = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start
observe(platform_key, key, duration)
result
end
def platform_field_key(type, field)
"graphql.#{type.graphql_name}.#{field.graphql_name}"
end
def platform_authorized_key(type)
"graphql.authorized.#{type.graphql_name}"
end
def platform_resolve_type_key(type)
"graphql.resolve_type.#{type.graphql_name}"
end
def observe(platform_key, key, duration)
return if key == 'authorized'
puts "platform_key: #{platform_key}, key: #{key}, duration: #{(duration * 1000).round(5)} ms".yellow
end
end
Instalacja jest również niezwykle prosta, należy dołączyć informacje o znaczniku do schematu tracer (MyCustomTracer.new) konfiguracja. Jak w poniższym przykładzie:
# graphql-ruby/app/graphql/graphql_tutorial_schema.rb
class GraphqlTutorialSchema < GraphQL::Schema
query Types::QueryType
mutation Types::MutationType
use GraphQL::Batch
tracer(MyCustomTracer.new)
...
end
Dane wyjściowe z takiego śledzenia wyglądają następująco:
Rozpoczęto POST "/graphql" dla ::1 o 2021-06-17 22:02:44 +0200
(0.1ms) SELECT sqlite_version(*)
Przetwarzanie przez GraphqlController#execute jako */*
Parametry: {"query"=>"{n allLinks {n idn urln descriptionn createdAtn postedBy {n idn namen links {n idn }n }}", "graphql"=>{"query"=>"{n allLinks {n idn urln descriptionn createdAtn postedBy {n idn namen links {n idn }n }}"}}.
platform_key: graphql.lex, key: lex, duration: 0.156 ms
platform_key: graphql.parse, klucz: parse, czas trwania: 0.108 ms
platform_key: graphql.validate, klucz: validate, czas trwania: 0.537 ms
platform_key: graphql.analyze_query, klucz: analyze_query, czas trwania: 0.123 ms
platform_key: graphql.analyze_multiplex, klucz: analyze_multiplex, czas trwania: 0.159 ms
Obciążenie łącza (0,4 ms) SELECT "links".* FROM "links"
↳ app/graphql/graphql_tutorial_schema.rb:21:in `platform_trace'
platform_key: graphql.execute_query, klucz: execute_query, czas trwania: 15.562 ms
↳ app/graphql/loaders/record_loader.rb:12:in `perform'
↳ app/graphql/loaders/association_loader.rb:46:in `preload_association'
platform_key: graphql.execute_query_lazy, klucz: execute_query_lazy, czas trwania: 14.12 ms
platform_key: graphql.execute_multiplex, klucz: execute_multiplex, czas trwania: 31.11 ms
Ukończono 200 OK w 48 ms (Views: 1.2 ms | ActiveRecord: 2.0 ms | Allocations: 40128)
Podsumowanie
GraphQL nie jest już nową technologią, ale rozwiązania jej problemów nie są w pełni ustandaryzowane, jeśli nie są częścią biblioteki. Wdrożenie tej technologii w projekt daje wiele możliwości interakcji z frontendem i osobiście uważam to za nową jakość w stosunku do tego, co oferuje REST API.