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

開発部 水戸
こんにちは、全国タクシーSREチームの水戸です。

インフラからアプリケーションまでサービスの安定性向上のための改善を日々行っています。

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

  • クエリをパースする
  • クエリをスキーマを元に検証する
  • クエリを実行する
GraphQL採用の背景やGraphQLの簡単な紹介については先日のJapanTaxi x MedPeer勉強会で発表した資料をご覧ください。

スキーマ定義

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

# 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"}}}
view raw execute.rb hosted with ❤ by GitHub

スキーマ定義には大きく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://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に完全に移行するようなのでこのような処理はいずれなくなっていくと思われます。

さて、同様にしてQueryTypeto_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
view raw multiplex.rb hosted with ❤ by GitHub

スキーマ定義が完了すると次はクエリが実行されます。GraphQL::Schema.executeからたどっていくとGraphQL::Execution::Multiplex.run_allでクエリ文字列がGraphQL::Queryクラスのインスタンスに変換されます。
次にGraphQL::Execution::Multiplex.run_queriesの中でGraphQL.parseが呼ばれ、そこでRagelによるトークナイザとRaccによるパーサによってクエリがパースされASTを構築します。

トークナイザやパーサを直接使いたい場合はそれぞれGraphQL.scan(query_string)、GraphQL.parse(query_string)で呼び出すことができます。

また、同時に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://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
view raw multiplex.rb hosted with ❤ by GitHub

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

JapanTaxiに興味を持ったら、まずはお話しませんか?