graphql-ruby コードリーディング

https://cdn-ak.f.st-hatena.com/images/fotolife/g/go_dev/20241030/20241030152455.jpg

最近全国タクシーチームでは次期バージョンのAPIにGraphQLを採用しリリースに向けて開発しています。 全国タクシーのサーバサイドはRuby on Railsのため実装にはgraphql-ruby gemを使用しています。 普通にこのgemを使用する分にはドキュメントを見ながら実装すれば問題なく動くのですが、今後より深くGraphQLを理解していくためにこのgemの内部構造を把握してみることにしてみました。 このエントリではgraphql-ruby v1.8.1のコードを元に、graphql-rubyがGraphQLのクエリを受け取って結果を返すまでの以下の各段階がどこでどのように処理されているのか調べていきたいと思います。

  • クエリをパースする
  • クエリをスキーマを元に検証する
  • クエリを実行する

GraphQL採用の背景やGraphQLの簡単な紹介については先日のJapanTaxi x MedPeer勉強会で発表した資料をご覧ください。

https://speakerdeck.com/y310/graphql-q-and-a

スキーマ定義

この記事では以下のようなシンプルなスキーマを例にクエリの実行がどのように進んでいくか追っていきたいと思います。

https://gist.github.com/y310/37bff4f8b025753e782b9a279ae30778#file-execute-rb

スキーマ定義には大きく2つの段階があります。1つ目はExampleSchemaUserTypeなどの定数が参照された時、もう1つはExampleSchema.executeなどのメソッドが呼び出されたときです。

まず最初にUserTypeQueryTypeが読み込まれるとそれぞれのクラスインスタンス変数にフィールドの情報(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を導入したことで互換性のために内部がややこしい状態になっています。 QueryTypeGraphQL::Schema::Objectを継承しているのですが、内部で保持しているインスタンスはこのクラスのものではなく、Class-based APIを導入する前からあったGraphQL::ObjectTypeインスタンスです。

https://gist.github.com/y310/bcfd57a881397da501d2dc899e591785#file-cached_graphql_definition-rb

スキーマを定義する主要なクラスはどれもgraphql_definitionを呼ぶと内部でto_graphqlが実行され、新クラスから旧クラスへの変換をする形で互換性を保っています。ただし将来的にはClass-based APIに完全に移行するようなのでこのような処理はいずれなくなっていくと思われます。

さて、同様にしてQueryTypeto_graphql実行時に内部のfieldでも同様にto_graphqlが実行されます。ここでも新クラスであるGraphQL::Schema::Fieldから旧クラスであるGraphQL::Fieldへの変換が行われます。このようにして参照されているすべてのTypeとfieldが順番に読み込まれていき、最終的に一つのツリーが構成されます。この情報は今後graphql-rubyの中でschemaという変数で随所で参照できるようになっています。

クエリのパース

https://gist.github.com/y310/1a0a03c5c5b644876a81917b59277e7e#file-multiplex-rb

スキーマ定義が完了すると次はクエリが実行されます。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_complexitymax_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://gist.github.com/y310/8df81539f90cd9d1e597297ba444a600#file-multiplex-rb

まずbegin_queryGraphQL::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は日進月歩で進化しており、それは言い換えるとまだ解決策の定まっていない課題が多々あるということでもあります。この記事によって内部構造の理解が進みそういった問題を解決するプラグインを開発するといったきっかけになれば幸いです。