【GraphQL Ruby】型定義から値解決するまでの道のり
こちらの記事は「MEDLEY Summer Tech Blog Relay」の17日目の記事です🎉
今週はFY25新卒エンジニアの4人が連続して記事をお届けします!
はじめに
株式会社メドレーに2025年4月に新卒入社した、おぎのしきぶと申します。
人材プラットフォームの新規事業開発チームでソフトウェアエンジニアをしています。
担当プロダクトでは、バックエンドに Ruby 、フロントエンドとの通信には GraphQL を用いており、その開発には、GraphQL Ruby という gem を採用しています。
GraphQL Ruby は、Ruby で GraphQL API を構築するための gem であり、サーバーサイドの実装を容易に開発できます。
具体的には、Ruby でスキーマや型(Type)、フィールド(Field)といった GraphQL の構成要素を、直感的なクラスやメソッドとして定義することができます。
例えば、User というモデルに対応する GraphQL の型を定義する場合、以下のように UserType というクラスを作成します。
module Types
class UserType < Types::BaseObject
description "A user"
field :id, ID, null: false
field :name, String, null: false
field :email, String, null: true
end
end
Ruby の世界から出ることなく、ライブラリのクラスを継承し、 field メソッドを使って、クライアントに公開するデータとその型(ID や String など)を宣言的に記述するだけで、APIの仕様を実装できます。
また、単に Ruby で書けるというだけでなく、API のドキュメントと実装の乖離が起こりにくくなるというメリットもあります。
このように便利な GraphQL Ruby ですが、ぱっと見ではライブラリがどのような処理をしているのかわかりません。
本記事では、GraphQL Ruby の field DSL が、どのようにして値解決へ到達するのかについて、ライブラリ実装の対応箇所とともに整理します。
本稿のゴール
- GraphQL の基本的な構成要素である Type、Query、Mutation が、GraphQL Ruby でどのような実装になっているかを理解する
- field DSL がどのクラスに、どのような情報として格納されるかを理解する
- 実行時に どのメソッドが呼ばれるかの決定ロジックを把握する
環境
- GraphQL Ruby: 2.5.11
Type
一般的なTypeの定義方法
GraphQL スキーマの型を作成するには、Types::BaseObject クラスを継承したクラス内で、field メソッドを用いて定義できます。
# 再掲
module Types
class UserType < Types::BaseObject
description "A user"
field :id, ID, null: false
field :name, String, null: false
field :email, String, null: true
end
end
上記コードによって、このようなスキーマが生成されます。
type User {
id: ID!
name: String!
email: String
}
この Types::BaseObject 自体は、install 時に生成されるファイルapp/graphql/types/base_object.rb
のクラスを指しており、このような実装になっています。
module Types
class BaseObject < GraphQL::Schema::Object
edge_type_class(Types::BaseEdge)
connection_type_class(Types::BaseConnection)
field_class Types::BaseField
end
end
ここでは「GraphQL Ruby が提供している GraphQL::Schema::Object を、カスタマイズしたいときに変更する場所」程度の理解で収め、ライブラリの内容に入っていきましょう。
GraphQL::Schema::Objectとfieldメソッドの実態
この BaseObject の継承元である GraphQL::Schema::Object は、lib/graphql/schema/object.rb
にあります。
今回は field メソッドを中心に探っていきましょう。
実装上部を見ると、
module GraphQL
class Schema
class Object < GraphQL::Schema::Member
extend GraphQL::Schema::Member::HasFields
とあり、HasFields が引っ張ってきているようです。
# Add a field to this object or interface with the given definition
# @see {GraphQL::Schema::Field#initialize} for method signature
# @return [GraphQL::Schema::Field]
def field(*args, **kwargs, &block)
field_defn = field_class.from_options(*args, owner: self, **kwargs, &block)
add_field(field_defn)
field_defn
end
ありました。
field_class.from_options(*args, owner: self, **kwargs, &block)
で field 定義を準備し、add_field
でクラスに追加しているようです。
add_field メソッド自体は単純です。
- 定義したフィールド名が、予約語と重複していないかチェックする
- ハッシュ
own_fields
へ適切にセットする
といった処理が、lib/graphql/schema/member/has_fields.rb
内に実装されています。
def add_field(field_defn, method_conflict_warning: field_defn.method_conflict_warning?)
# Check that `field_defn.original_name` equals `resolver_method` and `method_sym` --
# that shows that no override value was given manually.
# 1. 定義したフィールド名が、予約語と重複していないかチェックする
if method_conflict_warning &&
CONFLICT_FIELD_NAMES.include?(field_defn.resolver_method) &&
field_defn.original_name == field_defn.resolver_method &&
field_defn.original_name == field_defn.method_sym &&
field_defn.hash_key == NOT_CONFIGURED &&
field_defn.dig_keys.nil?
warn(conflict_field_name_warning(field_defn))
end
# 2. ハッシュ`own_fields`へ適切にセットする
prev_defn = own_fields[field_defn.name]
case prev_defn
when nil # 新規のフィールド名であればそのまま追
own_fields[field_defn.name] = field_defn
when Array # 既にフィールドあり、それが配列ならば配列内に追加
prev_defn << field_defn
when GraphQL::Schema::Field # 既存にフィードあれば、配列化して両方保持
own_fields[field_defn.name] = [prev_defn, field_defn]
else
raise "Invariant: unexpected previous field definition for #{field_defn.name.inspect}: #{prev_defn.inspect}"
end
nil
end
field_class.from_options(*args, owner: self, **kwargs, &block)
についてはどうでしょうか?
この field_class は、lib/graphql/schema/member/has_fields.rb
内のメソッドで、
# @return [Class] The class to initialize when adding fields to this kind of schema member
def field_class(new_field_class = nil)
if new_field_class
@field_class = new_field_class
elsif defined?(@field_class) && @field_class
@field_class
else
find_inherited_value(:field_class, GraphQL::Schema::Field)
end
end
となっています。
一見 nil 引数が渡されて最後のfind_inherited_value(:field_class, GraphQL::Schema::Field)
によって探されていそうですが、実は先ほどスルーした BaseObject 内で呼び出されています。
# 再掲
module Types
class BaseObject < GraphQL::Schema::Object
edge_type_class(Types::BaseEdge)
connection_type_class(Types::BaseConnection)
field_class Types::BaseField # 👈ココ
end
end
また、この Types::BaseField についても BaseObject 同様、gem の install 時に生成されたファイルapp/graphql/types/base_field.rb
が参照元です。
module Types
class BaseField < GraphQL::Schema::Field
argument_class Types::BaseArgument
end
end
つまり、継承元である GraphQL::Schema::Field がうまくやってくれていそうです。
呼び出している from_options メソッドは以下です。
# Create a field instance from a list of arguments, keyword arguments, and a block.
#
# This method implements prioritization between the `resolver` or `mutation` defaults
# and the local overrides via other keywords.
#
# It also normalizes positional arguments into keywords for {Schema::Field#initialize}.
# @param resolver [Class] A {GraphQL::Schema::Resolver} class to use for field configuration
# @param mutation [Class] A {GraphQL::Schema::Mutation} class to use for field configuration
# @param subscription [Class] A {GraphQL::Schema::Subscription} class to use for field configuration
# @return [GraphQL::Schema::Field] an instance of `self`
# @see {.initialize} for other options
def self.from_options(name = nil, type = nil, desc = nil, comment: nil, resolver: nil, mutation: nil, subscription: nil,**kwargs, &block)
if (resolver_class = resolver || mutation || subscription)
# Add a reference to that parent class
kwargs[:resolver_class] = resolver_class
end
if name
kwargs[:name] = name
end
if comment
kwargs[:comment] = comment
end
if !type.nil?
if desc
if kwargs[:description]
raise ArgumentError, "Provide description as a positional argument or `description:` keyword, but not both (#{desc.inspect}, #{kwargs[:description].inspect})"
end
kwargs[:description] = desc
kwargs[:type] = type
elsif (resolver || mutation) && type.is_a?(String)
# The return type should be copied from the resolver, and the second positional argument is the description
kwargs[:description] = type
else
kwargs[:type] = type
end
if type.is_a?(Class) && type < GraphQL::Schema::Mutation
raise ArgumentError, "Use `field #{name.inspect}, mutation: Mutation, ...` to provide a mutation to this field instead"
end
end
new(**kwargs, &block) # 👈
end
どうやら単なる引数を処理するインターフェースのようなメソッドで、単に new していると思って良さそうです。
ということで、ようやく GraphQL::Schema::Field#initialize が本丸ということがわかりました。
ただこの initialize メソッドは100行超にわたる長い処理であり、さらに各所で多くのメソッドを呼び出しているため、全てを細かく追うのは現実的ではありません。
ひとまず今回は、field :id, ID, null: false
と呼び出したとき、どのような処理になるかを見てみましょう。
Fieldクラスはどのように初期化されるか
field :id, ID, null: false
と3つの引数を渡したとき、それぞれの引数は以下のようにインスタンス変数へ格納されます。
def initialize(...)
# ...
@original_name = name
@return_type_expr = type
@return_type_null = if !null.nil?
null
elsif resolver_class
nil
else
true
end
# ...
end
これらは実行時に参照する変数としてみて良さそうです。
例えば、@return_type_null
は、実際に値がセットされたときに使用されます。
def type(new_type = NOT_CONFIGURED)
# ...
elsif !@return_type_expr.nil?
@type ||= Member::BuildType.parse_type(@return_type_expr, null: @return_type_null)
end
end
単に直接引数を参照している箇所はこの程度ですが、もう一箇所重要な実装があります。
それは、@resolver_method
の設定です。
method_name = method || hash_key || name_s
@method_str = -method_name.to_s
@method_sym = method_name.to_sym
@resolver_method = (resolver_method || name_s).to_sym
ここでは、「このオブジェクトの値解決時、どのメソッド名を参照すべきか」を設定しています。
今回のケースでは、resolver_method を引数に渡していないので、@resolver_method = name_s.to_sym
となります。
これによって、値解決時に「obj.id で取れるんだ!」と認識することができます。
また、これによって我々は、フィールド名と同じメソッド名、「obj.id が値を返す」ようにすれば、あとはGraphQL Rubyが勝手に処理してくれるようになっています。
具体的な値解決処理は、GraphQL::Schema::Field#resolve
に記載されており、該当箇所を見てみましょう。
def resolve(object, args, query_ctx)
# ...
elsif inner_object.respond_to?(@method_sym)
method_to_call = @method_sym
method_receiver = obj.object
if !ruby_kwargs.empty?
inner_object.public_send(@method_sym, **ruby_kwargs)
else
inner_object.public_send(@method_sym)
end
# ...
ここでは、inner_object.public_send(@method_sym)
が呼び出されています。
このpublic_send
は、obj.public_send(method_name)
と同じで、obj の method_name メソッドを呼び出します。
今回のケースでは、obj はUserオブジェクト、method_name は id なので、obj.id
が呼び出されます。
これによって、Userオブジェクトの id メソッドが呼び出され、その結果が返されます。
Typeのまとめ
- Types::BaseObject を継承し、field DSL で公開フィールドを宣言する。
- field メソッドは GraphQL::Schema::Field に変換・登録される。
- 変換時に、フィールド名と同じメソッド名が resolver_method に設定されるため、値解決時にそのメソッドを呼び出すようになる。
Query
一般的なQueryの定義方法
GraphQL Ruby では、Query は以下のように定義します。
module Types
class QueryType < Types::BaseObject
field :users, [UserType], null: false
def users
User.all
end
end
end
これによって、以下のようなスキーマが生成されます。
type Query {
users: [User!]!
}
ここで QueryType についてよく見てみましょう。
継承元を見てみると、Type でも継承していた Types::BaseObject であることがわかります。
つまり、GraphQL Ruby では、Query も Type も全く同じオブジェクトを継承しています。
そのため、Query に対して field メソッドを呼び出すことは、Type に対して field メソッドを呼び出すことと全く同じです。
今回提示した users では、field メソッドの引数自体も同じなため、Type と全く同じように処理されて行きます。
ちょっと意外に感じますが、言われてみれば確かにそうです。
GraphQL において Query は、Graph 上の取得可能な部分グラフを表すものです。
Query のルート型から取得可能なフィールドに辺が伸び、その先にもさらにフィールドがある、というイメージです。
値解決の観点で言えば、Query だけに特別な仕組みは必要ありません。
各フィールドが、自分の子フィールドをどう解決するかを知っていれば、十分に実現できます。
後はリクエストを捌く実行エンジンに Query のルート型を渡しておくだけで、再帰的に必要なフィールドを全て解決することができます。
Queryのまとめ
- GraphQLRuby において、Query と Type は(ほぼ)同じもの
Mutation
一般的なMutationの定義方法
Mutation は、GraphQL Ruby では GraphQL::Schema::Mutation を継承したクラスを定義することで実現できます。
module Mutations
class CreateUser < GraphQL::Schema::Mutation
# 引数(入力)
argument :name, String, required: true
argument :email, String, required: true
# 返却ペイロードのフィールド
field :user, Types::UserType, null: true
field :errors, [String], null: false
def resolve(name:, email:)
user = User.new(name: name, email: email)
if user.save
{ user: user, errors: [] }
else
{ user: nil, errors: user.errors.full_messages }
end
end
end
end
module Types
class MutationType < Types::BaseObject
field :create_user, mutation: Mutations::CreateUser
end
end
これによって、以下のようなスキーマが生成されます。
type Mutation {
create_user(name: String!, email: String!): User
}
Mutation のルートノードである MutationTypeは、Type、Query と同じくGraphQL::Schema::Object を継承していますね。
ベースの仕組みは同じものだと思って良さそうです。
一方、field メソッドに渡す引数が異なっており、mutation 引数を指定していることがわかります。
この差分について詳しく見てみましょう。
fieldメソッドにmutationを渡したときの仕組み
field :create_user, mutation: Mutations::CreateUser
のように mutation 引数を渡すと、GraphQL::Schema::Field.from_options により、そのクラスが resolver_class として Field インスタンスに紐づきます。
if (resolver_class = resolver || mutation || subscription)
kwargs[:resolver_class] = resolver_class
end
これにより、このフィールドは型側のインスタンスメソッドではなく、resolver_class(= Mutation クラス)の resolver_method(通常は resolve)で解決されます。(この解決順序については後述します)
GraphQL の引数は Ruby のキーワード引数として resolve(**kwargs)
に渡ります。
resolver_class を渡すことで、型オブジェクトで値解決していたところを、そのクラスに全て移譲しているわけです。
GraphQL::Schema::Mutationの仕組み
GraphQL::Schema::Mutation は GraphQL::Schema::Resolver を継承しています。
GraphQL::Schema::Resolver は、GraphQL RubyのResolverの基底クラスであり、大まかな仕組みは今まで見てきた Type や Query と同じです。
つまり、継承元との差分:GraphQL::Schema::Mutation へ追加された実装が重要そうです。
差分1. call_resolve
Resolver 実行時に呼ばれるメソッドcall_resolve に、dataloader.clear_cache が追加されており、Resolver 実行前にキャッシュをクリアするようにしています。
class Mutation < GraphQL::Schema::Resolver
# ...
# @api private
def call_resolve(_args_hash)
# Clear any cached values from `loads` or authorization:
dataloader.clear_cache
super
end
# ...
end
これは、Mutation が副作用を伴うことが原因です。
副作用を伴う場合、同じフィールドを複数回実行したときに、前回の実行結果が残ってしまいます。
そのため、Resolver 実行前にキャッシュをクリアするようにしています。
差分2. HasPayloadType
GraphQL::Schema::Mutation は、HasPayloadType と HasFields を extend しています。
これにより、クラス内で宣言した CreateUser 内に記載したフィールドは「返却ペイロード型」のフィールドとして再利用され、CreateUser → CreateUserPayload のようなオブジェクト型が自動生成されます。
class Mutation < GraphQL::Schema::Resolver
extend GraphQL::Schema::Member::HasFields
extend GraphQL::Schema::Resolver::HasPayloadType
# ...
end
これは、clientMutationId や errors などのメタ情報とドメインデータをまとめた「ペイロード型」を返す運用が主流であり、その慣習に合わせるためだと思われます。
(PRやドキュメントを軽く探してみたのですが、明確な背景はわからず。。。)
Mutationのまとめ
- Mutation のルート型は、Type、Queryと同じく GraphQL::Schema::Object を継承している
- 副作用を持つ Mutation を実現するため、GraphQL::Schema::Mutation を継承したクラスを定義する
- 返却型は HasPayloadType により自動生成され、CreateUser 内で宣言した
field :user, ...
やfield :errors, ...
がその型のフィールドになる
フィールド解決の優先順位
最後に、フィールド解決について少しだけ詳しく見てみましょう。
今まで言及していませんでしたが、GraphQL::Schema::Field#resolve 内には、値解決時に呼び出すメソッドの優先順位が設定されています。
フィールド値は次の順に解決されます。
- hash_key / dig が指定されている場合: inner_object(アプリ側の実オブジェクト)に対してハッシュ参照(dig 含む)で値取得。見つからなければ fallback_value があれば返す
- 型インスタンスのメソッド(resolver_method): QueryType や各 Object 内のインスタンスメソッド(デフォルトはフィールド名)を obj.public_send で呼ぶ
- inner_object が Hash の場合のキー参照: dig_keys やシンボル/文字列キーで参照
- アプリオブジェクトのメソッド(method_sym): inner_object.public_send(@method_sym) を呼ぶ
- フォールバック/エラー: fallback_value があれば返す。なければ実装不足エラーを投げる
1. hash_key/digを使う場合
条件: フィールド定義で hash_key: または
dig:` を指定したとき
field :title, String, null: false, hash_key: :title
field :city, String, null: true, dig: [:address, :city]
上は inner_object[:title]
、下は inner_object.dig(:address, :city)
を参照します。
外部APIレスポンスやJSONカラムなど、そのままHashとして扱うデータを素直にマッピングしたい場合に有効です。
2. 型インスタンスのメソッド(resolver_method)を呼ぶ場合
基本的にこのパターンを使うのが最も一般的です。
条件A: 型クラス(例: QueryType / UserType)に、フィールド名と同名(または resolver_method: で指定した)インスタンスメソッドを定義
module Types
class QueryType < Types::BaseObject
field :users, [UserType], null: false
def users
User.all
end
end
end
条件B: フィールドで resolver:
/mutation:
/subscription:
を指定
class QueryType < Types::BaseObject
field :users, resolver: Resolvers::UsersResolver
end
class Resolvers::UsersResolver < GraphQL::Schema::Resolver
type [Types::UserType], null: false
def resolve(**args)
User.all
end
end
3. inner_objectがHashのときのキー参照
条件: inner_object 自体が Hash(親の解決が Hash を返した等)で、1や2に該当しないとき。
class QueryType < Types::BaseObject
field :user_info, UserType, null: false, hash_key: :user_info
# 親で Hash を返す
def user_info
{ id: 1, 'name' => 'Alice' }
end
end
# 子フィールド(id/name)は Hash キーで解決される
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: false
end
4. アプリケーションオブジェクトのメソッド(method_sym)を呼ぶ場合
条件: 1, 2 に該当せず、inner_object がそのメソッドに respond_to?
するとき
# User#name を呼ぶ(フィールド名 = メソッド名)
field :name, String, null: false
# 別名フィールドで User#name を呼ぶ
field :full_name, String, null: false, method: :name # この場合、`User#name` が呼ばれるため、`name`と`full_name`は等価
UserType などのオブジェクト型で型側にメソッドを定義しない場合、モデル(inner_object)の同名メソッド/属性に自然にフォールバックするため、最小の実装で動かしやすいです。
まとめ
- Type、Query、Mutation どれも、GraphQL::Schema::Object を継承している
- field DSL で宣言したフィールドは、GraphQL::Schema::Field に変換され、GraphQL::Schema::Object に追加される
- 実行時に呼び出されるインスタンス変数として定義される
- 値解決時に呼び出すメソッドもこのインスタンス作成時に定義される
- フィールド値は、hash_key / dig → 型インスタンスのメソッド → inner_object が Hash の場合のキー参照 → アプリオブジェクトのメソッド → フォールバック/エラーの順に解決される
感想
普段よくわからず使っているライブラリの中身を知ると、単に業務コード書いているだけでも楽しい気持ちになれますね。
実行エンジン、データローダー、スキーマ生成などなど、まだまだ調べてみたいことがたくさんあり、また記事にしたいと思います。
次は、新卒エンジニア3人目の平林さんです!お楽しみに!!
Discussion