Rails + GraphQL で REST じゃない API を作る
はじめに
ここに書いている内容は僕が仕事で開発を行なっている minne の API に GraphQL を導入するにあたり
gist に雑にまとめてメンバーに共有した内容で公開できない部分をアレしたやつです。
(minne の API は現状オープンなものではないです。
GraphQL #とは
- プログラミング言語ではなく、クエリ言語
- GraphQL というミドルウェアでは無い
- 専用の server を立てるとかも必要ない
- 開発は FB GraphQL 標準化を目指して Draft RFC が公開されている
メリット
- クライアントが必要とするデータを 1 回のリクエストで取得できるようになる
- どのデータが必要なのかは投げられたクエリから判断するので無駄なデータを返す必要がなくなる
- どのデータが必要なのかは投げられたクエリから判断するので大きなロジックの変化がない場合は server 側の実装が不要
- 投げたクエリによって返すデータが変わるだけなので、デザインの変更でこのテーブルのこのカラムの情報が必要ってなった場合もアプリ側でクエリを修正するだけ
- クエリには型が存在する
- ruby では厳密に型を気にすることはない
- しかしアプリで使用している言語は静的型付けである為、型は重要
- graphql-ruby ではカーソル形式のページネーションをデフォで提供してくれている
- GraphQL のドキュメントのベストプラクティスに書いてあるやつ
デメリット
- server 側の実装はそれなりに大変
- クエリを覚えたり、 N+1 対応したり
- 補助するための GraphiQL が優秀なのである程度はツールに頼れる
- N+1 に関しては graphql-batch gem を使うとスマートに解決できそう
- クエリを覚えたり、 N+1 対応したり
- クライアント側で、何のデータが必要なのかを「すべて」明示する必要がある
- コードを書く量が増える
- クエリを覚えなければならない
- どのデータを使用しているか明示されるため、後で見た時にわかりやすいかもしれない
実際に使ってみた
使う 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
書いてて疲れたんで最後雑になっちゃったな