4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Rails6】GraphQLを使用したAPI開発(Query編)

Last updated at Posted at 2020-09-28

はじめに

rails6でGraphQLを用いた開発を行ったので、導入方法や使い方をまとめてみました。
今回はQuery/アソシエーション/N+1問題を取り上げて、ご紹介します。

開発環境

ruby2.7.1
rails6.0.3
GraphQL

1. GraphQLって何?

GraphQLとはAPIリクエストのためクエリ言語で下記の特徴があります。

  • エンドポイントは/graphql1つのみ
  • Query: データの取得(Get)
  • Mutation: データの作成、更新、削除(Create, Update, Delete)

RESTとの大きな違い一つ目のエンドポイントが一つということです。
RESTの場合、/sign_up, /users, /users/1など複数のエンドポイントが存在しますが、
GraphQLの場合は、エンドポイントは/graphqlのみです。

RESTでは複数のリソースで必要な場合、複数のAPIリクエストが必要ですが、
GraphQLはエンドポイントが一つなので、必要なデータを一回で取得できコードがシンプルになります。

2. 今回使用するテーブル

親のUserテーブルと子のPostテーブルの二つで実装します。
Userが複数のPostを投稿できる1対多の関係とします。

ターミナル
$ rails g model User name:string email:string
$ rails g model Post title:string description:string user:references
$ rails db:migrate

Userテーブル

カラム
name string
email string
user.rb
class User < ApplicationRecord
  has_many :posts, dependent: :destroy
end

Postテーブル

カラム
title string
description string
post.rb
class Post < ApplicationRecord
  belongs_to :user
end

3. railsにGraphQLを導入する

gemをインストールします。

Gemgile
gem 'graphql'    #追加

group :development, :test do
  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
  gem 'graphiql-rails'   #開発環境に追加
end
config/application.rb
require "sprockets/railtie"    #コメントアウトを外す
ターミナル
$ bundle install
$ rails generate graphql:install   #GraphQLに関するファイルが作成されます

routes.rbに下記を追加
エンドポイントが開発環境では/graphiql, 本番環境では/graphqlとなります。

routes.rb
Rails.application.routes.draw do
  if Rails.env.development?
    # add the url of your end-point to graphql_path.
    mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql" 
  end
  post '/graphql', to: 'graphql#execute'  #ここはrails generate graphql:installで自動生成される
end

詳細は下記にまとめましたので参考にしてください。
Rails6のAPIモードでGraphQLを使う方法(エラー対策も含む)

4. User一覧を取得するQuery

GraphQLではType(modelごとに定義する)を持っており、そのTypeに従い、Queryを実行してデータを取得します。

UserのObjectTypeを作成

下記のコマンドでUserのTypeを作成します。
!をつけるとnull:falseが追加されます。

ターミナル
$ rails g graphql:object User id:ID! name:String! email:String!

下記のファイルが生成されます。

user_type.rb
module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false  # `!`をつけると`null:false`が追加されます。
    field :name, String, null: false
    field :email, String, null: false
  end
end

UserのQueryを作成

先ほど作成したuser_typeを元にqueryを作成します。

query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :users, [Types::UserType], null: false # userを配列で定義する
    def users
      User.all # user一覧を取得
    end
  end
end

Userのデータをコンソールで作成します。

$ rails c
$ > User.create(name: "user1", email: "user-1@test.com")
$ > User.create(name: "user2", email: "user-2@test.com")

Queryを実行する

準備は整いましたので、サーバーを立ち上げてGraphiqlで確認します。(http://localhost:3000/graphiql)

$ rails s

下記のqueryを実行します。

query{
  users{
    id
    name
    email
  }
}

するとjson形式のレスポンスが返ってきます。
usersのTypeを配列にしているので配列となっています。

{
  "data": {
    "users": [
      {
        "id": "1",
        "name": "user1",
        "email": "user-1@test.com"
      },
      {
        "id": "2",
        "name": "user2",
        "email": "user-2@test.com"
      }
    ]
  }
}

http://localhost:3000/graphiql に接続して、実行した結果です。
スクリーンショット 2020-09-27 20.32.34.png

必要なデータだけリクエストし受け取る場合

すべてカラムのデータが必要でない場合はqueryを変更します。
例えばuserのidだけ取得することもできます。

query{
  users{
    id
  }
}

レスポンス

{
  "data": {
    "users": [
      {
        "id": "1",
      },
      {
        "id": "2",
      }
    ]
  }
}

5. アソシエーション

次にUserに紐づくPostを取得してみます。
Userを同じようにObjectTypeとQueryを作成します。

PostのObjectTypeを生成

ターミナル
$ rails g graphql:object Post id:ID! title:String! description:String!

下記のファイルが生成されます。
Userのデータを取得するためにfield :user, Types::UserType, null: falseを追加します。

post_type.rb
module Types
  class PostType < Types::BaseObject
    field :id, ID, null: false
    field :title, String, null: false
    field :description, String, null: false
    field :user, Types::UserType, null: false # この一文を追加。belongs_to :userのようなもの
  end
end

UserTypeにはfield :posts, [Types::PostType], null: falseを追加します。
こちらは複数データが紐づくので配列で定義します。

user_type.rb
module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :email, String, null: false
    field :posts, [Types::PostType], null: false # この一文を追加。has_many :postsのようなもの
  end
end
ターミナル
$ rails c
$ > Post.create(title: "title", description: "description", user_id: 1)

PostのQueryを生成する

query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :users, [Types::UserType], null: false
    def users
      User.all
    end

    # 下記を追加
    field :posts, [Types::PostType], null: false
    def posts
      Post.all
    end
  end
end

Queryを実行する

実行するqueryです。
usersにpostをネストさせてリクエストします。

query{
  users{
    id
    name
    email
    post{
      id
      title
      description
    }
  }
}

実行するとネストしたPostのデータが返ってきます。
RESTの場合でいうuser.postsのデータを取得できます。

スクリーンショット 2020-09-27 21.00.34.png

post.userのデータが必要な場合は、下記のようなqueryで取得できます。

query{
  posts{
    id
    title
    description
    user{
      id
    }
  }
}

スクリーンショット 2020-09-28 9.54.16.png

6. N+1を検知するBulletを導入する

GraphQLのクエリは木構造になっているので、アソシエーションがあるとN+1問題が発生しやすいです。
そこで、N+1を検知するgemであるBulletを導入することをおすすめします。

Bulletをインストール

Gemfile
group :development do
  gem 'bullet'
end
ターミナル
$ bundle install

config/environments/development.rbに下記の設定を追加

config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true
  Bullet.bullet_logger = true
  Bullet.console = true
  Bullet.rails_logger = true
end

N+1を確認してみる

Bulletの導入が完了したら、N+1が発生していないか確認してみましょう

query{
  users{
    id
    name
    email
    posts{
      id
      title
      description
    }
  }
}

先ほどと同様のqueryを実行すると下記のようなログが出ます。

ターミナル
Processing by GraphqlController#execute as */*
  Parameters: {"query"=>"query{\n  users{\n    id\n    name\n    email\n    posts{\n      id\n      title\n      description\n    }\n  }\n}", "variables"=>nil, "graphql"=>{"query"=>"query{\n  users{\n    id\n    name\n    email\n    posts{\n      id\n      title\n      description\n    }\n  }\n}", "variables"=>nil}}
  User Load (0.2ms)  SELECT "users".* FROM "users"
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
  Post Load (0.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ?  [["user_id", 1]]
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
  Post Load (0.1ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ?  [["user_id", 2]]
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
Completed 200 OK in 36ms (Views: 0.3ms | ActiveRecord: 1.7ms | Allocations: 18427)


POST /graphql
USE eager loading detected
  User => [:posts]
  Add to your query: .includes([:posts])
Call stack

Add to your query: .includes([:posts])と言われているのでN+1が発生しています。
SQLも三回発行されています。

ターミナル
User Load (0.2ms)  SELECT "users".* FROM "users"
Post Load (0.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ?  [["user_id", 1]]
Post Load (0.1ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = ?  [["user_id", 2]]

N+1を解消する方法

N+1を解消するには通常通り、includesすれば大丈夫です。
dataloaderというものでも解消できますが、今回はincludesを使用します。
User一覧を取得している部分を下記のように変更します。

query_type.rb
def users
  # User.all  # 変更前
  User.includes(:posts).all # 変更後
end

では、N+1が解消されたかログで確認してみましょう。
警告がなくなっています。

ターミナル
Processing by GraphqlController#execute as */*
  Parameters: {"query"=>"query{\n  users{\n    id\n    name\n    email\n    posts{\n      id\n      title\n      description\n    }\n  }\n}", "variables"=>nil, "graphql"=>{"query"=>"query{\n  users{\n    id\n    name\n    email\n    posts{\n      id\n      title\n      description\n    }\n  }\n}", "variables"=>nil}}
  User Load (0.7ms)  SELECT "users".* FROM "users"
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
  Post Load (1.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (?, ?)  [["user_id", 1], ["user_id", 2]]
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
Completed 200 OK in 57ms (Views: 0.2ms | ActiveRecord: 1.9ms | Allocations: 15965)

SQL文も2つに減ったので無事にN+1が解消されました。

ターミナル
User Load (0.7ms)  SELECT "users".* FROM "users"
Post Load (1.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (?, ?)  [["user_id", 1], ["user_id", 2]]

7. resolverの切り出し

通常のQuery

普通にコードを書いていくと、モデルに関係なく、fieldsとメソッドがquery_typeに追加されるのでquery_typeがどんどん肥大化します。

query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :users, [Types::UserType], null: false
    def users
      User.includes(:posts).all
    end

    field :posts, [Types::PostType], null: false
    def posts
      Post.all
    end
  end
end

Resolverを使ったQuery

GitHubのissueでは、Resolverを使用することで、query_type.rbの肥大化を回避するベストプラクティスが紹介されています。
https://github.com/rmosolgo/graphql-ruby/issues/1825#issuecomment-441306410

query_typeにはfieldのみを定義します。

query_type.rb
module Types
  class QueryType < BaseObject
    field :users, resolver: Resolvers::QueryTypes::UsersResolver
    field :posts, resolver: Resolvers::QueryTypes::PostsResolver
  end
end

そして、メソッドの部分はObjectTypeごとにResolverに切り出します。(新たにresolversディレクトリを作成しました。)
GraphQL::Schema::Resolverの記載を忘れるとエラーが出るので忘れないように注意してください。

resolvers/query_types/users_resolver.rb
module Resolvers::QueryTypes
  class UsersResolver < GraphQL::Schema::Resolver
    type [Types::UserType], null: false
    def resolve
      User.includes(:posts).all
    end
  end
end
resolvers/query_types/posts_resolver.rb
module Resolvers::QueryTypes
  class PostsResolver < GraphQL::Schema::Resolver
    type [Types::PostType], null: false
    def resolve
      Post.all
    end
  end
end

終わりに

GraphQLついて、自分の復習もかねてまとめていたら思いの外、長文となってしまいました。
rspecやmutationについても書きたかったですが、次回にしたいと思います。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?