まっしろけっけ

めもてきなやーつ

Rails + GraphQL で REST じゃない API を作る

はじめに

ここに書いている内容は僕が仕事で開発を行なっている minneAPI に GraphQL を導入するにあたり
gist に雑にまとめてメンバーに共有した内容で公開できない部分をアレしたやつです。
(minne の API は現状オープンなものではないです。

GraphQL #とは

メリット

  • クライアントが必要とするデータを 1 回のリクエストで取得できるようになる
    • REST に近い API だとモバイルアプリで 1 画面を表示するために複数回のリクエストを投げる必要がある
  • どのデータが必要なのかは投げられたクエリから判断するので無駄なデータを返す必要がなくなる
    • 古い version で使っているので消せない json の key どうする?とか悩まなくていい
    • 無駄なデータを返す必要がないのでほんの少しだけ通信コストを抑えられる(高速化)
    • 無駄な JSON を parse する必要がなくなる(高速化)
  • どのデータが必要なのかは投げられたクエリから判断するので大きなロジックの変化がない場合は server 側の実装が不要
    • 投げたクエリによって返すデータが変わるだけなので、デザインの変更でこのテーブルのこのカラムの情報が必要ってなった場合もアプリ側でクエリを修正するだけ
  • クエリには型が存在する
    • ruby では厳密に型を気にすることはない
    • しかしアプリで使用している言語は静的型付けである為、型は重要
  • graphql-ruby ではカーソル形式のページネーションをデフォで提供してくれている

デメリット

  • server 側の実装はそれなりに大変
    • クエリを覚えたり、 N+1 対応したり
      • 補助するための GraphiQL が優秀なのである程度はツールに頼れる
      • N+1 に関しては graphql-batch gem を使うとスマートに解決できそう
  • クライアント側で、何のデータが必要なのかを「すべて」明示する必要がある
    • コードを書く量が増える
    • クエリを覚えなければならない
      • どのデータを使用しているか明示されるため、後で見た時にわかりやすいかもしれない

実際に使ってみた

使う model は下記 (実際は login_id, password とかもあるけど割愛

今回は users#show products#index 相当の処理を実装する

$ rails g model User name:string
$ rails g model Product user_id:integer  name:string 
$ rails g model Love user_id:integer  product_id:integer 

model 周りの実装も割愛

初期設定

# Gemfile
+gem 'graphql'
+gem 'graphql-batch'

Gemfile に常軌を追加して bundle install する
その後諸々の初期ファイルを作ってくれる下記を実行

$ bundle exec rails generate graphql:install
Running via Spring preloader in process 58662
      create  app/graphql/types
      create  app/graphql/types/.keep
      create  app/graphql/ponica_schema.rb
      create  app/graphql/types/query_type.rb
add_root_type  query
      create  app/graphql/mutations
      create  app/graphql/mutations/.keep
      create  app/graphql/types/mutation_type.rb
add_root_type  mutation
      create  app/controllers/graphql_controller.rb
       route  post "/graphql", to: "graphql#execute"
     gemfile  graphiql-rails
       route  graphiql-rails

Gemfile に graphiql-rails が追加されたり routes.rb に graphql の endpoint や graphiql-rails の mount が追加されたりしている。

graphiql-rails の認証周りの設定

graphiql-rails から graphql の API を呼ぶ際にヘッダーにデフォで○○設定したい〜とかいう場合は下記のように initializer で設定する

# config/initializers/graphiql.rb
GraphiQL::Rails.config.headers["X-Hoge"] = -> (context) { "Hoge" }

Github みたいに v4 にしてみる

まずは routes.rb 編集

# routes.rb
  scope 'v4', module: 'v4' do
    post "/graphql", to: "graphql#execute"
  end

controller を移動
mv app/controllers/graphql_controller.rb app/controllers/v4/

# app/controllers/v4/graphql_controller.rb
- class GraphqlController < ApplicationController
+ class V4::GraphqlController < ApplicationController
...

graphiql で使う path も変更

-    mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql"
+    mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/v4/graphql"

この状態で rails s で server を立ち上げる。

http://localhost:3000/graphiql にアクセスすると GraphiQL の画面が表示される

# request
query {
  testField
}

# response
{
  "data": {
    "testField": "Hello World!"
  }
}

みんな大好き Hello World が出来た

認証周り

ログイン認証とかは今あるものをそのまま使えば ok で GraphQL の処理の中で current_user を使えるように context に格納しておく

# app/controllers/v4/graphql_controller.rb
     context = {
-      # Query context goes here, for example:
-      # current_user: current_user,
+      current_user: current_user
     }

schema 設定

初期設定 の時に app/graphql 以下に諸々のファイルが生成されるのでそこら辺をいじる

app/graphql/ponica_schema.rb というのができているので ponica 部分をいい感じの名前に変える
mv app/graphql/ponica_schema.rb app/graphql/minne_schema.rb とか、で下記のように編集する

# app/graphql/minne_schema.rb
-PonicaSchema = GraphQL::Schema.define do
+ MinneSchema = GraphQL::Schema.define do

# app/controllers/v4/graphql_controller.rb
-    result = PonicaSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
+    result = MinneSchema.execute(query, variables: variables, context: context, operation_name: operation_name)

type を定義していく

順番的には type を定義した後に graphql-batch の処理を実装した方がわかりやすいと思いますが長くなるのでまとめてます。

# app/graphql/minne_schema.rb
MinneSchema = GraphQL::Schema.define do
+ use GraphQL::Batch
# app/graphql/types/user_type.rb
Types::UserType = GraphQL::ObjectType.define do
  name "User"
  global_id_field :id

  field :id, !types.ID
  field :name, !types.String
  connection :products, Types::ProductType.connection_type # 先で述べたページネーションを実装してくれる
end

# app/graphql/types/product_type.rb
Types::ProductType = GraphQL::ObjectType.define do
  name "Product"
  global_id_field :id

  field :id, !types.ID
  field :name, !types.String

  field :user, !Types::UserType do
    resolve -> (obj, args, context) { RecordLoader.for(User).load(obj.user_id) } # N+1 解消用の処理
  end

  field :loved, !types.Boolean do
    resolve ->(obj, args, ctx) {
      LovedLoader.for(ctx[:current_user]).load(obj.id) # いいねしたかどうかをまとめて判定
    }
  end
end

# app/graphql/record_loader.rb
class RecordLoader < GraphQL::Batch::Loader
  def initialize(model)
    @model = model
  end

  def perform(ids)
    @model.where(id: ids).each { |record| fulfill(record.id, record) }
    ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
  end
end

# app/graphql/loved_loader.rb
class LovedLoader < GraphQL::Batch::Loader
  def initialize(user)
    @user = user
  end

  def perform(ids)
    love_product_ids = @user.loves.where(product_id: ids).pluck(:product_id)
    ids.each { |id| fulfill(id, love_product_ids.include?(id)) }
  end
end

コメントで書いた通り graphql-ruby はページネーション用の処理も簡単に実装してくれる。

また graphql-batch を使えば N+1 の問題も解消可能

loved に関しても 1 レコード毎いいねをしたかを判定するのではなくまとめて判定している

query を定義する

 Types::QueryType = GraphQL::ObjectType.define do
   name "Query"
-  # Add root-level fields here.
-  # They will be entry points for queries on your schema.

-  # TODO: remove me
-  field :testField, types.String do
-    description "An example field added by the generator"
+  field :user do
+    type Types::UserType
+
+    argument :id, !types.String
+
+    description "Find a User By account"
+    resolve ->(obj, args, ctx) {
+      User.find(args[:id])
+    }
+  end
+
+  connection :products, Types::ProductType.connection_type do
+    description "all Product"
     resolve ->(obj, args, ctx) {
-      "Hello World!"
+      Product.all  # 雑に all とかしてるので実際はごにょごにょする必要があります
     }
   end
 end

実際に使ってみる

直接 id を指定してもいいが variables を使うこともできる

__typename とかで type を見たり__hogeで色々見れる

# users#show っぽいやつ
# request
query GetUser($id: String!) { 
  user(id: $id) { 
    __typename
    name
    products(first: 2, after: "Mg==") { # first が件数 after がカーソル
      edges {
      	node {
          id
          name
        }
      }
      pageInfo {
        startCursor
        endCursor
      }
    }
  }
}

# variables
{
  "id": "2"
}

# response
{
  "data": {
    "user": {
      "__typename": "User",
      "name": "test user",
      "products": {
        "edges": [
          {
            "node": {
              "id": "25",
              "name": "hoge"
            }
          },
          {
            "node": {
              "id": "24",
              "name": "fuga"
            }
          }
        ],
        "pageInfo": {
          "startCursor": "MQ==",
          "endCursor": "Mg=="
        }
      }
    }
  }
}
# products#index っぽいやつ
# request
query {
  products(first: 2) { 
    edges {
    	node {
        id
        name
        loved
        user {
          name
        }
      }
    }
    pageInfo {
      startCursor
      endCursor
    }
  }
}

# response
{
  "data": {
    "products": {
      "edges": [
        {
          "node": {
            "id": "1",
            "name": "hoge",
            "loved": true,
            "user": {
              "name": "hoge user"
            }
          }
        },
        {
          "node": {
            "id": "2",
            "name": "fuga",
            "loved": false,
            "user": {
              "name": "fuga user"
            }
          }
        }
      ],
      "pageInfo": {
        "startCursor": "MQ==",
        "endCursor": "Mg=="
      }
    }
  }
}

わかりやすいようにバラバラで呼んでますがクエリを変更すれば上記の 2 つを一回のリクエストで取得することが可能です。
Rails のログを見ればわかりますが N+1 も発生していません。

mutation に関して

mutation に関しては REST から移行するメリットが見出せていないという感じが今のステータスです。
なので処理を記述するのは割愛(あとで別記事として書くかも

エラーハンドリング周り等は下記あたりが参考になりました。
GraphQL -Mutation Query Implementation - Ruby on Rails - Rails Kitchen
GraphQL Ruby Error Handling - Rails Kitchen

最後に

まだ query をどう定義するのがベストなのか? + cache 周りの検証が出来ていないのでその辺り頑張る + mutation メリットが見出せてないのでそこら辺知見がある人と話してみたい。 REST に引っ張られがちになるので考え方を変えるのが大変だったりする。

個人的には GraphQL すごくいいなと感じていて、graphql-ruby, graphql-batch, graphiql-rails もすごく良く出来ているという感想なんで導入進めるぞという気持ち

下記のページを参考にしました。(英語のページを見ながら実装したんでなんか間違ってたら指摘を〜

GraphQL | A query language for your API
GraphQL - Welcome

書いてて疲れたんで最後雑になっちゃったな