💎

Rails×GraphQL (gem graphql-ruby) で工夫したこと 5選

2024/07/02に公開

はじめに

Railsプロジェクトにgraphql-rubyのgemを導入したうえで、工夫した点をまとめました。主にプロジェクト内のルール(ディレクトリ構成、クラスの分割粒度・命名)についてです。

graphql-rubyの公式サイト

https://graphql-ruby.org/

https://github.com/rmosolgo/graphql-ruby

注意点

  • gemの紹介や導入方法などは、この記事では触れないため、公式サイトを参照してください。
  • モバイルアプリからリクエストされるAPIの作成を想定しています。Backend(Rails)の実装にフォーカスし、モバイルアプリ側の実装については今回触れません。
  • この記事に出てくるコードは、注目したい部分以外を省略するなど 加工をしているため、動かないコードを含んでいます。

Railsプロジェクトにgraphql-rubyのgemを導入したうえで、工夫した点をまとめました。

工夫①:クラスの種類ごとにディレクトリを分ける

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クラスで行う

公式ドキュメント
https://graphql-ruby.org/fields/resolvers.html

プロジェクト内のルールとして、クエリの実装(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を定義しました。

公式ドキュメント
https://graphql-ruby.org/pagination/connection_concepts.html
https://graphql-ruby.org/pagination/custom_connections.html

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