最近全国タクシーチームでは次期バージョンのAPIにGraphQLを採用しリリースに向けて開発しています。
全国タクシーのサーバサイドはRuby on Railsのため実装にはgraphql-ruby gemを使用しています。
普通にこのgemを使用する分にはドキュメントを見ながら実装すれば問題なく動くのですが、今後より深くGraphQLを理解していくためにこのgemの内部構造を把握してみることにしてみました。
このエントリではgraphql-ruby v1.8.1のコードを元に、graphql-rubyがGraphQLのクエリを受け取って結果を返すまでの以下の各段階がどこでどのように処理されているのか調べていきたいと思います。
- クエリをパースする
- クエリをスキーマを元に検証する
- クエリを実行する
スキーマ定義
この記事では以下のようなシンプルなスキーマを例にクエリの実行がどのように進んでいくか追っていきたいと思います。
# Run "gem install graphql" before executing this file | |
require 'graphql' | |
# Dummy model class | |
class User | |
def self.find_by(id:) | |
OpenStruct.new(id: id, name: 'test user') | |
end | |
end | |
# GraphQL type | |
class UserType < GraphQL::Schema::Object | |
field :name, String, null: false | |
end | |
class QueryType < GraphQL::Schema::Object | |
field :user, UserType, null: true do | |
argument :id, Int, required: true | |
end | |
def user(id:) | |
User.find_by(id: id) | |
end | |
end | |
# GraphQL schema | |
class ExampleSchema < GraphQL::Schema | |
query(QueryType) | |
end | |
# Run GraphQL query | |
query = <<-QUERY | |
{ | |
user(id: 1) { | |
name | |
} | |
} | |
QUERY | |
result = ExampleSchema.execute(query: query) | |
p result.to_h # => {"data"=>{"user"=>{"name"=>"test user"}}} |
スキーマ定義には大きく2つの段階があります。1つ目はExampleSchema
やUserType
などの定数が参照された時、もう1つはExampleSchema.execute
などのメソッドが呼び出されたときです。
まず最初にUserType
、QueryType
が読み込まれるとそれぞれのクラスインスタンス変数にフィールドの情報(GraphQL::Schema::Field
のインスタンス)が保持されます。これはUserType.own_fields
を呼ぶと確認することができます。
次にExampleSchema
が読み込まれるとQueryType
のクラスが内部に保持されます。
ここまでで定数参照時の処理は終わりです。
次にExampleSchema.execute
が呼ばれた際の2段階目の読み込みは、詳細を端折っていますが、イメージとしては以下のような流れになります。
ExampleSchema.execute ExampleSchema.to_graphql QueryType.to_graphql # GraphQL::Schema::Object => GraphQL::ObjectType UserType.to_graphql # GraphQL::Schema::Object => GraphQL::ObjectType field.to_graphql # GraphQL::Schema::Field => GraphQL::Field
まずExampleSchema.execute
が呼ばれるとメソッド呼び出しがgraphql_definition
へとdelegateされ、その中でto_graphql
メソッドによって生成された自身のインスタンスを内部に保持します。この過程でQueryType.graphql_definition
も呼ばれ、同様に内部でto_graphql
によってGraphQL::ObjectType
のインスタンスが保持されます。(同じインスタンスがExampleSchema.query
でも取得できます。)
実はこの部分はgraphql-rubyがv1.8.0でClass-based APIという新しいスキーマのDSLを導入したことで互換性のために内部がややこしい状態になっています。
QueryType
はGraphQL::Schema::Object
を継承しているのですが、内部で保持しているインスタンスはこのクラスのものではなく、Class-based APIを導入する前からあったGraphQL::ObjectType
のインスタンスです。
# https://github.com/rmosolgo/graphql-ruby/blob/v1.8.1/lib/graphql/schema/member/cached_graphql_definition.rb#L14-L16 | |
def graphql_definition | |
@graphql_definition ||= to_graphql | |
end |
スキーマを定義する主要なクラスはどれもgraphql_definition
を呼ぶと内部でto_graphql
が実行され、新クラスから旧クラスへの変換をする形で互換性を保っています。ただし将来的にはClass-based APIに完全に移行するようなのでこのような処理はいずれなくなっていくと思われます。
さて、同様にしてQueryType
のto_graphql
実行時に内部のfieldでも同様にto_graphql
が実行されます。ここでも新クラスであるGraphQL::Schema::Field
から旧クラスであるGraphQL::Field
への変換が行われます。
このようにして参照されているすべてのTypeとfieldが順番に読み込まれていき、最終的に一つのツリーが構成されます。この情報は今後graphql-rubyの中でschema
という変数で随所で参照できるようになっています。
クエリのパース
# https://github.com/rmosolgo/graphql-ruby/blob/v1.8.1/lib/graphql/execution/multiplex.rb#L46-L49 | |
def run_all(schema, query_options, *args) | |
queries = query_options.map { |opts| GraphQL::Query.new(schema, nil, opts) } | |
run_queries(schema, queries, *args) | |
end |
スキーマ定義が完了すると次はクエリが実行されます。GraphQL::Schema.execute
からたどっていくとGraphQL::Execution::Multiplex.run_all
でクエリ文字列がGraphQL::Query
クラスのインスタンスに変換されます。
次にGraphQL::Execution::Multiplex.run_queries
の中でGraphQL.parse
が呼ばれ、そこでRagelによるトークナイザとRaccによるパーサによってクエリがパースされASTを構築します。
また、同時にGraphQL::StaticValidation::Validator
によってGraphQL::Query
インスタンスの中でASTとスキーマの対応付けが行われます。(GraphQL::Schema#irep_selection
がこの情報を持っています)
スキーマとの矛盾があった場合この時点でクエリはinvalidとみなされます。(即結果がリターンされるわけではなくクエリ実行時の処理の大半がスキップされます)
Analyze
パース後にクエリに対してAnalyzeが実行されます。AnalyzeはAST全体を順番にたどっていく処理で、ここでクエリ実行前の追加の検証を実施します。クエリの計算コストやネストの深さを制限するmax_complexity
やmax_depth
などの検証はこの段階で実行されます。またこの部分はAPIが公開されているため、プロダクトに応じた検証項目を追加することもできます。
Analyzer APIのドキュメント
(例えばJapanTaxiではGitHubのGraphQL APIのようにconnectionsフィールドのfirst/lastパラメータのいずれかを必須にする検証を追加しています)
クエリ実行
GraphQL::Execution::Multiplex.run_queries
の中でクエリのパース後、GraphQL::Execution::Multiplex.run_as_multiplex
で大きく以下の3ステップでクエリが実行されます。
GraphQL::Execution::Multiplex.begin_query
GraphQL::Execution::Execute::ExecutionFunctions.lazy_resolve_root_selection
GraphQL::Execution::Multiplex.finish_query
# https://github.com/rmosolgo/graphql-ruby/blob/v1.8.1/lib/graphql/execution/multiplex.rb#L77-L98 | |
def run_as_multiplex(multiplex) | |
queries = multiplex.queries | |
# Do as much eager evaluation of the query as possible | |
results = queries.map do |query| | |
begin_query(query) | |
end | |
# Then, work through lazy results in a breadth-first way | |
GraphQL::Execution::Execute::ExecutionFunctions.lazy_resolve_root_selection(results, { multiplex: multiplex }) | |
# Then, find all errors and assign the result to the query object | |
results.each_with_index.map do |data_result, idx| | |
query = queries[idx] | |
finish_query(data_result, query) | |
# Get the Query::Result, not the Hash | |
query.result | |
end | |
rescue StandardError | |
# Assign values here so that the query's `@executed` becomes true | |
queries.map { |q| q.result_values ||= {} } | |
raise | |
end |
まずbegin_query
でGraphQL::Execution::Execute::ExecutionFunctions.resolve_root_selection
を起点にクエリを順番にたどっていきながら各Typeのresolverを実行していきます。
次にlazy_resolve_root_selection
でbegin_queryにおいてフィールドの戻り値が遅延評価オブジェクトだったフィールドを評価します。この仕組みは公式ドキュメントのLazy Executionで説明されていますがgraphql-batchなどのSQLのバッチ実行などに使用されています。
ここまででクエリの各階層の実行結果はqueryオブジェクトの中に保持されており、最後にfinish_query
の中でGraphQL::Execution::Flatten.call
を呼んでHashに変換したものをquery.result_value
に保存し、GraphQL::Query::Result
オブジェクトとして返します。
まとめ
詳細をかなり省略してではありますが、なんとかスキーマ定義からクエリの実行までを追うことができました。最後にgraphql-rubyのコードを読む上で重要なポイントを再度まとめたいと思います。
- スキーマの情報はスキーマクラス(例の中では
ExampleSchema
)の中にシングルトンインスタンスとして保持されている - クエリの情報は
GraphQL::Query
、実行中の中間情報はGraphQL::Query#context
に保持されている - スキーマは
to_graphql
によって新クラスから旧クラスのインスタンスに変換されている
GraphQLは日進月歩で進化しており、それは言い換えるとまだ解決策の定まっていない課題が多々あるということでもあります。この記事によって内部構造の理解が進みそういった問題を解決するプラグインを開発するといったきっかけになれば幸いです。
インフラからアプリケーションまでサービスの安定性向上のための改善を日々行っています。