🔍

マイベストにおいて graphql-ruby はどう使われているか

2022/06/08に公開

こんにちは、マイベストに中途で入社して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 ディレクトリに mutationresolver 以外のものを全て突っ込んでいますが、マイベストではタイプごとにディレクトリを作成して分かりやすくしています。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.rbquery_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 の定義があります。
  • mutaitions ディレクトリ & resolver ディレクトリ

    • 2つとも基本的にはプロダクトの画面の数とほぼ同じ数だけ定義が存在します。管理画面の構造はDBの構造と似ているので、スキーマ自体はバックエンドで定義してもクライアント側で欲しい仕様にそのままなっていることも多いです。
      • また、バックエンドとフロントエンドで両方ともに同時作業するときは先にRails側でスキーマ定義を出力してスキーマが要件を満たしているかの確認をフロントエンドと行ってから、お互いに中身を作り始めます
      • ちょっとした機能改修ならポジションに関わらずバックンド側とフロントエンド側を両方とも実装することもあります
  • interfacesディレクトリ

    Interface自体は用途に応じて複数用意していますが、その中でも画像データを扱うObjectTypeなら継承するという ImageInterface というInterfaceがあります。

    マイベストの画像アップロード用のgemとして Shrine を使っているのですが、Shrine を include したクラスには remove_imageimage_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に当てはめることができます。
https://zenn.dev/teppeita/articles/ba6bef95775f49

# (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を選びコードファーストで作っていくことが多いでしょう。スキーマファーストとは違い、どうしてもスキーマがバックエンド側の所有物になってしまい、フロントエンド側としては「こういうスキーマが欲しいのに…」ということが起こり易くなると思います。そういったところで対話を怠らず、プロダクトとしてどうあるべきかを考えることが好きな方、是非ともお話できればと思います。

詳しくは弊社の採用ページをご覧ください。

Discussion