最近全国タクシーチームでは次期バージョンの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_queryGraphQL::Execution::Execute::ExecutionFunctions.lazy_resolve_root_selectionGraphQL::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は日進月歩で進化しており、それは言い換えるとまだ解決策の定まっていない課題が多々あるということでもあります。この記事によって内部構造の理解が進みそういった問題を解決するプラグインを開発するといったきっかけになれば幸いです。
インフラからアプリケーションまでサービスの安定性向上のための改善を日々行っています。