マイベストにおいて graphql-ruby はどう使われているか
こんにちは、マイベストに中途で入社して1年が経ったegamiTaです。
本記事ではマイベストにおけるRails内でのGraphQLの運用について紹介していきたいと思います。
マイベストではコンテンツの作成・管理をする社内向けのプロダクト(いわゆる管理画面)のAPIにGraphQLを採用しています。
自分が入社したときには既にGraphQLの導入が済んでいましたが、最初は事業規模も小さく運用する人も少なかったので、Rails一本で開発していました。
なので、そこまで厳密にRESTfulなAPIなんかも作らずとも大丈夫でしたが、いよいよ事業的に規模が拡大するとともに求められる機能と開発人員が増え、そのため管理画面のSPA化が図られ、その際にAPIをどうするか問題が出ててきた…という流れを聞いています。
(ちなみに現在では150人(?)ぐらいのコンテンツ製作チームの方々が管理画面を利用しています)
素直にRESTful なAPIでも良かったらしいのですが、下記の理由で決めたことがマイベスト内のドキュメントに残っています。
-
規約・書き方が統一できる
- REST APIには制約がほぼ無いので毎回仕様書を作って共有しないといけない
-
仕様書の運用が(比較的)楽そう
- Swagger(OpenAPI)は結構運用が面倒だと聞く
⇒ ※ 自分の経験ですがこれはその通り(APIが増えれば増えるほど記述するのが本当に辛くなってくる)で、しかしバックエンドとフロントエンドのコミュニケーション練度が高い(お互いに柔軟に変更に適用できる等々)と良い感じに練られたインターフェイスができ、スキーマ駆動の良い開発体験が得られた経験があります
-
クエリがクライアント(フロントエンド)側で柔軟に調整できる
- 依存関係が1方向にできて良さげ(サーバーサイドがフロントエンドの実装を気にしなくて良い)
このような経緯でGraphQLを採用して3年が経ち、マイベスト内でもGraphQLが良い感じに育ってきました。
既にRailsでGraphQLを使ったチュートリアル的なものはいくらでもネット上にあるので、この記事では実際のプロダクトのRails内でのディレクトリやファイル構成からどのような技術を組み合わせて運用しているのかを紹介していきたいと思います
ファイル・ディレクトリ構成
詳細を除く構成は下記のツリー図になっています。hogehogeファイルの数でディレクトリ毎の規模感を把握していただけると幸いです。
app
└── graphql
├── enum_types
│ ├── base_enum.rb
│ └── hogehoge_enum.rb × 5
├── input_types
│ ├── base_input_object.rb
│ └── hogehoge_input_type.rb × 8
├── interfaces
│ ├── base_interface.rb
│ └── hogehoge_interface.rb × 5
├── loaders
│ └── association_loader.rb
├── mutations
│ ├── base_mutation.rb
│ └── do_hogehoge.rb × 45
├── object_types
│ ├── hogehoge_type.rb × 100
│ ├── base_object.rb
│ ├── error_type.rb
│ ├── mutation_type.rb
│ └── query_type.rb
├── resolvers
│ └── hogehoge_resolver.rb × 40
├── types
│ ├── base_connection.rb
│ ├── base_field.rb
│ ├── base_scalar.rb
│ └── base_union.rb
├── offset_extension.rb
└── mybest_admin_schema.rb
構成のベースはgraphql-rubyのチュートリアルですね
graphql-ruby/app/graphql at master · howtographql/graphql-ruby
-
types
ディレクトリ-
チュートリアルでは
types
ディレクトリにmutation
とresolver
以外のものを全て突っ込んでいますが、マイベストではタイプごとにディレクトリを作成して分かりやすくしています。types
に入っているのはまだ派生クラスがなかったり、専用で作成する使い方をされていないbaseクラスを格納しています。base_connection.rb
. とbase_field.rb
は後述するページネーションで(base_field.rb
はN+1対策のデータローダーでも)触れたいと思います。
-
-
object_types
ディレクトリ-
BaseObject
を継承したものをこちらに配置しています - 基本的にはモデルの数だけスキーマ定義があり、一部ユースケースに特化したスキーマ定義が加わります。
- RailsでGraphQLを導入するとなるとほぼgraphql-rubyを採用することになりコードファーストでAPIの開発をすることになりますが、その結果モデルの数だけスキーマが生まれることが多いと思います。
- QueryとMutationをファイルごとに分割するためにそれぞれの一覧を定義している
mutation_type.rb
とquery_type.rb
もこちらに配置しています(BaseObject
を継承しているため) -
base_object.rb
には下記のようになっており
module ObjectTypes class BaseObject < GraphQL::Schema::Object include GraphQL::FragmentCache::Object connection_type_class Types::BaseConnection field_class Types::BaseField def load_assoc(loader_for:) Loaders::AssociationLoader.for(object.class, loader_for).load(object) end def h ApplicationController.helpers end end end
- 現在、おすすめ比較ページなどがあるサービス側でもGraphQLの導入を進めているため、 また管理画面でも作成した記事のプレビュー時にはキャッシュが効いてないと重い処理があるため
GraphQL::FragmentCache::Object
をincludeして特定のresolverだけにキャッシュを効かせて使っています
# 使い方 field :popular_contents, [ObjectTypes::ContentType], null: true, cache_fragment: { expires_in: 12.hours }
-
ページネーションで利用する
Types::BaseConnection
-
total_count
(合計件数)を取得できるクラスを追加しています
class Types::BaseConnection < GraphQL::Types::Relay::BaseConnection field :total_count, Int, null: false, description: '合計件数' def total_count object.items.unscope(:offset).size end end
-
-
N+1対応のために利用する
Types::BaseField
と#load_assoc
※後述 -
何気に使うことが多いので
ApplicationController.helpers
をQueryとMutation内で呼べるようにしている#h
-
-
input_types
ディレクトリ- こちらも「管理画面から更新するモデルの数」+ 「Resolever で使う引数」の数を合わせた
InputObject
の定義があります。
- こちらも「管理画面から更新するモデルの数」+ 「Resolever で使う引数」の数を合わせた
-
mutaitions
ディレクトリ &resolver
ディレクトリ- 2つとも基本的にはプロダクトの画面の数とほぼ同じ数だけ定義が存在します。管理画面の構造はDBの構造と似ているので、スキーマ自体はバックエンドで定義してもクライアント側で欲しい仕様にそのままなっていることも多いです。
- また、バックエンドとフロントエンドで両方ともに同時作業するときは先にRails側でスキーマ定義を出力してスキーマが要件を満たしているかの確認をフロントエンドと行ってから、お互いに中身を作り始めます
- ちょっとした機能改修ならポジションに関わらずバックンド側とフロントエンド側を両方とも実装することもあります
- 2つとも基本的にはプロダクトの画面の数とほぼ同じ数だけ定義が存在します。管理画面の構造はDBの構造と似ているので、スキーマ自体はバックエンドで定義してもクライアント側で欲しい仕様にそのままなっていることも多いです。
-
interfaces
ディレクトリInterface自体は用途に応じて複数用意していますが、その中でも画像データを扱うObjectTypeなら継承するという
ImageInterface
というInterfaceがあります。マイベストの画像アップロード用のgemとして Shrine を使っているのですが、Shrine を include したクラスには
remove_image
とimage_remote_url
,image_data_uri
,image_source
が属性として生えてきます。接頭語を見てurlなのか(
image_remote_url
)、データなのか(image_data_uri
)の判断はそれをそのまま graphql-ruby の Interface に書いても良いのですが、なるべくならモデル側でしたいところです。そこで モデル側で shrine を利用するモジュールに要素代入関数 を使って
upload_image=
というメソッドを定義することで、graphql-ruby側でupload_image
に対応する属性として扱わせることができます(=filed の対象にすることができます)module Interfaces module ImageInterface include BaseInterface # 読み取り用field field :image_source, String, null: true # 編集用field field :upload_image, String, null: true field :remove_image, Boolean, null: true end end # モデル側でinlude module ImageUploadable # ~~ shrine の設定 ~~~ # included do def upload_image=(value) if value.to_s.start_with?('http') self.image_remote_url = value else self.image_data_uri = value end end end end
-
enum_types
は特別なことはしていないので割愛します
ページネーション
当初はgraphql-ruby のデファクトスタンダードであるカーソル方式で実装していましたが、管理画面の仕様的にページ指定が欲しいのでオフセットを利用しています。ただ、今後もカーソル方式も使う可能性があるので、実装的には両対応になっています。
GraphQL::Schema::Field::ConnectionExtension
を拡張しBaseFieldに読み込ませるすることでオフセットによるページネーションを可能にしています
offset_extension.rb
class OffsetExtension < GraphQL::Schema::Field::ConnectionExtension
def apply
super
field.argument :offset, 'Int', required: false
end
# NOTE: resolverのresolveメソッドにページネーション関連の引数を渡さないとエラーになるのを回避している
def resolve(object:, arguments:, context:)
next_args = arguments.dup
next_args.delete(:first)
next_args.delete(:last)
next_args.delete(:before)
next_args.delete(:after)
next_args.delete(:offset) # 追加したoffsetを削除するために全体をオーバーライドしている
yield(object, next_args, arguments)
end
def after_resolve(**args)
offset = args[:memo][:offset]
new_args = offset ? args.merge(value: args[:value].offset(offset)) : args
super(**new_args)
end
end
- オフセットを指定したいので
offset
を追加し、追加したoffsetを削除する必要があるためresolve
をオーバライドして削除処理を追記しています
参考
https://github.com/rmosolgo/graphql-ruby/blob/master/lib/graphql/schema/field/connection_extension.rb#L15
エラーハンドリング
おもにバリデーションエラーに対するハンドリングになります。
object_types
ディレクトリ 内にある ErrorType
をMutationのfieldに設定して、エラー発生時に定義されているエラー内容を格納して返します。
フロントエンド側ではエラー文言は管理せず、エラー文言をそのまま表示しています(一部例外はあり)
module ObjectTypes
class ErrorType < BaseObject
field :message, String, null: false
end
end
graphql-batchのloaderによるN+1対応
マイベストではこちら記事で行った対応によってなんと一行でN+1対応を該当ObjectTypeに当てはめることができます。
# (load: true をつけるだけ)
field :products, [ObjectTypes::ProductType], null: false, load: true
Rails側のスキーマ定義からフロントエンド用定義の自動生成
マイベストではRailsで追加・変更したスキーマ定義をコマンド一つでフロントエンド側の定義ファイルとして出力できるように開発環境を整えています。
graphql-rubyの機能でスキーマ定義をjsonとして出力し、フロントエンド側はそのjsonを元にnpmライブラリであるgraphql-codegenを利用してTypeScriptで作られた定義ファイルを出力します。
graphql-rubyの機能でjsonを出力するためにはgraphql-rubyのライブラリを利用してrake taskを作成します。
require File.expand_path('config/application', __dir__)
require 'graphql/rake_task'
Rails.application.load_tasks
GraphQL::RakeTask.new(namespace: 'graphql:admin', schema_name: 'MybestAdminSchema')
これで bundle exec rails graphql:admin:schema:json
のコマンドで スキーマ定義が出力された schema.json
がリポジトリのルートディリクトリに作成されます。
そしてcodegenの実行に必要な codegen.yml
に出力された schema.json
をschemaフィールドに記述します。
-
schema.json
からの自動生成の型以外にも、プロダクトで使っているQuery, Mutationsからその操作専用の方も欲しいので documentsフィールドにそれらを読み込むように記述します(その場合は plugins にtypescript-operations
を指定) - plugins に
typescript-react-apollo
を指定するとquery, mutation 等から対応する hooks を生成します
overwrite: true
generates:
src/graphql/generatedTypes.ts:
schema: '../schema.json'
documents: './src/graphql/**/*.graphql'
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
src/graphql/generatedFront.ts:
schema: '../front_schema.json'
documents: './src/react/kesa/**/*.graphql'
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
上記の流れをnpmのscriptsプロパティにまとめて記述して npm run
で実行できるようにしています。
DatadogでGraphQLのリクエストを細かく見れるように
GraphQLは、リクエストが全て同一のエンドポイントになるのでQuery/Mutaationごとのパフォーマンス計測ができません。
そこでマイベストではサーバー監視ツールの Datadog を利用してQuery/Mutaationごとのパフォーマンスを計測しています。
https://docs.datadoghq.com/ja/tracing/setup_overview/setup/ruby/#graphql
GraphQLを使ってみて
自分はマイベストに来てから初めてGraphQLを触りましたが、すでに環境やクエリは揃っていたのでキャッチアップ自体は特に問題なく開発に入っていけました。この敷居の低さは良いことでもありますが、ただ前例を踏襲するだけではスキーマの設計思想などは置いてけぼりになるのでそこはそこでキャッチアップが必要だと思います
(マイベストでは社内勉強会で Production Ready GraphQL を輪読したりしてリテラシー向上に努めています)
このあたりのAPIの設計のしやすさは、HTTPメソッドでAPIごとの役割がある程度決められているRESTに分があるかなと思いました。
しかしRESTではフロントエンド側と細かく詰めて定義しないといけないスキーマごとのフィールドを、GraphQLはクエリ一本で解決できてしまうのはとても魅力的です。オーバーフェッチ・アンダーフェッチはもちろん処理に時間が余計に掛かることもそうですが、設計の負担の軽減やちょっとした改修に対しても柔軟にフロントエンド側で対応できるのが頼もしいです。
最後に
以上で簡単にですが、マイベスト内で graphql-ruby がどのように使われているかを紹介いたしました。
RailsでのGlaphQL導入は、graphql-rubyを選びコードファーストで作っていくことが多いでしょう。スキーマファーストとは違い、どうしてもスキーマがバックエンド側の所有物になってしまい、フロントエンド側としては「こういうスキーマが欲しいのに…」ということが起こり易くなると思います。そういったところで対話を怠らず、プロダクトとしてどうあるべきかを考えることが好きな方、是非ともお話できればと思います。
詳しくは弊社の採用ページをご覧ください。
株式会社マイベストのテックブログです! 採用情報はこちら > notion.so/mybestcom/mybest-information-for-Engineers-8beadd9c91ef4dc2b21171d48a4b0c49
Discussion