Rails×GraphQL (gem graphql-ruby) で工夫したこと 5選
はじめに
Railsプロジェクトにgraphql-ruby
のgemを導入したうえで、工夫した点をまとめました。主にプロジェクト内のルール(ディレクトリ構成、クラスの分割粒度・命名)についてです。
graphql-rubyの公式サイト
注意点
- gemの紹介や導入方法などは、この記事では触れないため、公式サイトを参照してください。
- モバイルアプリからリクエストされるAPIの作成を想定しています。Backend(Rails)の実装にフォーカスし、モバイルアプリ側の実装については今回触れません。
- この記事に出てくるコードは、注目したい部分以外を省略するなど 加工をしているため、動かないコードを含んでいます。
Railsプロジェクトにgraphql-ruby
のgemを導入したうえで、工夫した点をまとめました。
- 工夫①:クラスの種類ごとにディレクトリを分ける
- 工夫②:クエリの実装はすべてResolverクラスで行う
- 工夫③:GraphQLでしか使わない処理はObjectModelへ
- 工夫④:ActiveRecord用のカスタムConnectionを定義する
- 工夫⑤:GraphQLスキーマの管理方法
工夫①:クラスの種類ごとにディレクトリを分ける
rails g graphql:install
で生成されるディレクトリは「app/graphql」配下に「mutations」と「types」だけです。そこにクラスの種類ごとにディレクトリを作成して、コードを管理しています。最終的に下記の図のようなディレクトリ構成にしました。
# ディレクトリ構成
app
└── graphql
├── resolvers
│ ├── {モデル名}
│ │ └── {子オブジェクト名}.rb
│ └── {クエリ名}.rb
│ # 「工夫②:クエリの実装はすべてResolverクラスで行う」で解説します。
│
├── object_models
│ └── ◯◯_model.rb
│ # 「工夫③:GraphQLでしか使わない処理はObjectModelへ」で解説します。
│
├── object_types
│ └── ◯◯_type.rb
│
├── connections
│ └── ◯◯_connection.rb
│ # 「工夫④:ActiveRecord用のカスタムConnectionを定義する」で解説します。
│
├── enum_types
│ └── ◯◯_type.rb
│
├── input_types
│ └── ◯◯_attribute.rb
│
├── loaders
│ └── association_loader.rb
│ # N+1を回避するために gem graphql-batchを使用してます。
│
├── mutations
│ └── ◯◯_mutation.rb
│
├── types
│ ├── base_◯◯.rb
│ ├── mutation_type.rb
│ └── query_type.rb
│
└── schema.graphql
# 「工夫⑤:GraphQLスキーマの管理方法」で解説します。
工夫②:クエリの実装はすべてResolverクラスで行う
公式ドキュメント
プロジェクト内のルールとして、クエリの実装(ActiveRecordの操作)はすべてResolverクラスで行うようにしています。QueryTypeに直接処理を書くことを禁止しています。
下記のように責務を分けるためです。
- QueryType:クエリのインターフェイスを定義する場所
- Resolver:クエリの実装をする場所
Resolverクラスは2種類に分けて実装する
- パターン1:ルートオブジェクトを取得する場合
- 例)
currentAccount
クエリのデータ取得処理は、Resolvers::CurrentAccount
クラスに実装する。
- 例)
- パターン2:子オブジェクトを取得する場合
- 例)
currentAccount { users }
クエリのデータ取得処理は、Resolvers::Account::Users
クラスに実装する。
- 例)
# ディレクトリ構成
app
└── graphql
├── resolvers
│ ├── current_account.rb # クエリ名.rb → currentAccountクエリを実装する
│ ├── account
│ │ └── users.rb # モデル名/子オブジェクト名.rb → currentAccount { users }クエリを実装する
パターン1:ルートオブジェクトを取得する場合
例)
currentAccount
クエリの実装は、Resolvers::CurrentAccount
クラスで行う。
# ディレクトリ構成
app
└── graphql
├── resolvers
│ ├── current_account.rb # クエリ名.rb → currentAccountクエリを実装する
# クエリ定義
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
...省略
# クエリの定義にResolverを指定する
field :current_account, resolver: Resolvers::CurrentAccount
end
end
# Resolverの実装
# app/graphql/resolvers/current_account.rb
class Resolvers::CurrentAccount < Types::BaseResolver
type ObjectTypes::AccountType, null: true
...省略
def resolve
current_account
end
end
パターン2:子オブジェクトを取得する場合
例)
currentAccount { users }
クエリの実装は、Resolvers::Account::Users
クラスで行う。
# ディレクトリ構成
app
└── graphql
├── resolvers
│ ├── account
│ │ └── users.rb # モデル名/子オブジェクト名.rb → currentAccount { users }クエリを実装する
# オブジェクト
# app/graphql/object_types/account_type.rb
module ObjectTypes
class AccountType < Types::BaseObject
...省略
# オブジェクトにResolverを指定する
field :users, ObjectTypes::UserType.connection_type, resolver: Resolvers::Account::Users
end
end
# Resolverの実装
# app/graphql/resolvers/account/users.rb
class Resolvers::Account::Users < Types::BaseResolver
type [ObjectTypes::UserType], null: false
...省略
def resolve
# objectは、Accountのインスタンス変数
object.users
end
end
工夫③:GraphQLでしか使わない処理はObjectModelへ
ObjectModelは独自で定義した層です。GraphQL Rubyのライブラリには無い概念です。
プロジェクト内のルールとして、Objectクラスに直接処理を書くことを禁止しています。Objectクラスの責務を、フィールドの定義だけにしたいからです。
Objectクラスに処理を追加したい場合は、すべてObjectModelクラスで行うようにしています。
まず前提として、Objectの処理は できるだけModelへ
プロジェクト内のルールとして、特に理由がなければ、ApplicationRecordのModelクラスに処理を実装することを推奨しています。GraphQL以外でも同じ処理が再利用できるためです。Modelに定義するメソッド名 と Objectに定義するフィールド名 を一致しておけば、勝手にModelのメソッドが実行されます。
GraphQL独自の処理は、ObjectModelへ
GraphQLだけでしか使わない独自処理(例えば、GraphQLのN+1対策としてLoaderの処理を実装する
など)の場合はObjectModelに定義するようにしています。
ObjectModelの実装例
# オブジェクト
# app/graphql/object_types/user_type.rb
module ObjectTypes
class UserType < Types::BaseObject
field :name, String # 推奨) User#nameが実行される
field :image_url, String # 独自処理が必要な場合のみ) ObjectModels::UserModel#image_urlが実行される
# ObjectModels::UserModel に移譲する
delegate :image_url, to: :model
end
end
# オブジェクトモデル
# app/graphql/object_models/user_model.rb
module ObjectModels
class UserModel < BaseModel
def image_url
# 例えば、LoaderでN+1対策する処理など GraphQL限定の処理
Loaders::AssociationLoader.for(User, :image).load(object).then do |image|
object.image_url(image: image)
end
end
end
end
ObjectModelの仕組み(内部実装)
BaseObject#initialize
を拡張して、ObjectModelクラスと紐づけをしています。
module Types
class BaseObject < GraphQL::Schema::Object
..省略
def initialize(object, context)
super(object, context)
# ObjectTypesクラスか判定する
class_name = self.class.name
return if class_name.blank?
return unless class_name.start_with?("ObjectTypes::")
# 対応するObjectModelクラスを探す
# 例) 「ObjectTypes::UserType」の場合、「ObjectModels::UserModel」クラスを探す
model_class_name = class_name.gsub(/\AObjectTypes/, "ObjectModels").gsub(/Type\z/, "Model")
model_class = model_class_name.safe_constantize
return if model_class.blank?
# ObjectModelクラスを生成
@model = model_class.new(object, context)
end
end
end
工夫④:ActiveRecord用のカスタムConnectionを定義する
Connectionは、配列データの取得時にページネーションをする仕組みのことです。ActiveRecordデータに対応するために、カスタムConnectionを定義しました。
公式ドキュメント
GraphQLのクエリでActiveRecordの差分取得がしたい
例えば、id:1〜10のUserデータがある場合に
- users(first: 3)と指定したら、先頭の3件のユーザー情報を[昇順]で取得する
- users(last: 3)と指定したら、末尾の3件のユーザー情報を[降順]で取得する
と返却できるような仕様にしたい。
# 先頭の3件を取得する
query {
users(first: 3) {
edges {
cursor,
node {
id
}
}
}
}
# -> idの昇順で3件返す[1, 2, 3]
# 前回の続きを3件取得する
query {
users(first: 3, after: "#{id:3のcursor}" {
edges {
cursor,
node {
id
}
}
}
}
# -> idの昇順で3件返す[4, 5, 6]
# 末尾の3件を取得する
query {
users(last: 3) {
edges {
cursor,
node {
id
}
}
}
}
# -> idの降順で3件返す[10, 9, 8]
# 前回の続きを3件取得する
query {
users(last: 3, before: "#{id:8のcursor}") {
edges {
cursor,
node {
id
}
}
}
}
# -> idの降順で3件返す[7, 6, 5]
カスタムConnectionを定義して、実現する
ActiveRecordのページネーションを行うカスタムConnection(ActiveRecordRelationSortedConnection)を定義する。
# app/graphql/connections/active_record_relation_sorted_connection.rb
class Connections::ActiveRecordRelationSortedConnection < GraphQL::Pagination::ActiveRecordRelationConnection
def nodes
relations = super
if @last.present?
# @lastにオプションの指定内容が格納されている
# lastを指定した場合は、並び順を反転させる
relations.reverse
else
# first or 未指定(all) の場合は、昇順のまま
relations
end
end
end
ActiveRecord::Relationを返却する場合は、カスタムConnectionの「ActiveRecordRelationSortedConnection」を使用する。
# app/graphql/app_schema.rb
class AppSchema < GraphQL::Schema
..省略
# Connectionのソートをする(firstは昇順。lastは降順にソートする。)
connections.add(ActiveRecord::Relation, Connections::ActiveRecordRelationSortedConnection)
カスタムConnectionを使ったクエリを定義する
クエリの定義にConnectionを指定します。これで指定したクエリに対して「first」「last」のオプションが指定できるようになります。
# オブジェクト
# app/graphql/types/query_type.rb
module Types
class QueryType < Types::BaseObject
...省略
# Connectionを指定すると、このクエリで「first」「last」のオプションが使えるようになる。
field :users, ObjectTypes::UserType.connection_type, resolver: Resolvers::Users
end
end
# Resolver
# app/graphql/resolvers/users.rb
class Resolvers::Users < Types::BaseResolver
...省略
def resolve
User.all.order(:id) # idの昇順にする。lastを指定した場合は、reverseされてidの降順になる。
end
end
工夫⑤:GraphQLスキーマの管理方法
実態とズレのないGraphQLスキーマをモバイルチームへ共有するために、下記の運用をしています。
1. スキーマファイルを生成して、git管理する
スキーマを変更した場合は、bundle exec rake graphql:dump_schema
を実行し、自動生成されたスキーマファイルをgitにコミットする運用にしています。
namespace 'graphql' do
desc 'GraphQLのスキーマを出力する'
task dump_schema: :environment do
# Get a string containing the definition in GraphQL IDL:
schema_defn = AppSchema.to_definition
# Choose a place to write the schema dump:
schema_path = "app/graphql/schema.graphql"
# Write the schema dump to that file:
File.write(Rails.root.join(schema_path), schema_defn)
puts "Updated #{schema_path}"
end
end
2. CIでスキーマファイルと実装内容が一致していることを確認する
下記のようなspecを追加し、スキーマファイルと実装内容(app/graphql配下の実装)がズレた場合は、CIでエラーとなります。これでスキーマファイルの実態とのズレを検知できます。
# spec/graphql/app_schema_spec.rb
require 'rails_helper'
RSpec.describe AppSchema do
it 'GraphQLのスキーマに変更がないこと' do
# スキーマを変更する場合は、rakeを実行してスキーマファイルを更新してください
# bundle exec rake graphql:dump_schema
schema_file = 'app/graphql/schema.graphql'
schema = File.read(schema_file)
expect(schema).to eq AppSchema.to_definition
end
end
3. スキーマファイルを開発環境に公開する
開発環境の場合は、/schema.graphql
にアクセスするとスキーマファイルを出力するようにする。
モバイルアプリのビルド時に このURLからスキーマファイルを取得しています。
class GraphqlSchemaController < ApplicationController
def show
render plain: AppSchema.to_definition
end
end
おわりに
GraphQLを導入して1年半ほど経ちますが、特に大きな問題はありませんでした。
導入前は、クライアント側の処理で動的なGraphQLクエリが発行できることに漠然と不安を持っていたのですが、実際に導入してみると1つ1つのクエリはシンプルになっていて、速度面やメンテナンス性などで問題になることはありませんでした。
RESTのJSON APIを使っていたころよりも、必要なデータの組み合わせごとにAPIを作らなくて良くなったのが良いですね。仕様もシンプル・実装もシンプル。しかも毎回API用のControllerを作る必要がなくなって、実装するコード量が減りました。
ある程度フレームワーク側でAPIのインターフェイスも定められているので、実装者によって APIのインターフェイスが変わったりしないのも良きです。
個人的にGraphQL結構好きで、おすすめです!
Discussion