Timee Product Team Blog

タイミー開発者ブログ

Rails+Next.jsでGraphQLを導入する時に考えたこと

こんにちは、タイミー開発プラットフォームチームで業務委託として働いている宮城です。

タイミーはリリースから4年が経過したプロダクトで、2022年の前半から一部領域でGraphQLを利用し始め現在導入を進めています。 本記事では、GraphQLをプロダクトに導入する上で判断に迷った箇所や課題に対して、タイミーでの意思決定とその理由を紹介します。参考にしていただければ幸いです。

GraphQLの選定理由についてはこの記事では触れませんが、CTOの@kameike が以下のイベントで詳しく紹介する予定です。まだ参加申し込みは可能ですので、興味がある方はぜひ合わせてご覧ください。

timeedev.connpass.com

なお、本記事のタイトルはソウゾウさんの以下の記事にインスパイアされています。

engineering.mercari.com

GraphQLの「Getting Startedの次にぶつかる壁」について多く言及しており、プロダクトに導入する上で非常に有用な記事でした。合わせて一読することをオススメします。

前提となる技術スタック

「タイミー」の技術スタックを紹介します。バックエンドはモノリシックなRuby on Railsで構築されており、働き手となるワーカー向けモバイルアプリ(Swift/Kotlin)・雇用主となる企業向けWebアプリケーション(Rails SSR/Next.js)・社内メンバー向けWebアプリケーション(Active Admin/Next.js)の3つのアプリケーションを提供しています。

このうちWebアプリケーションはそれぞれNext.jsとRails APIを利用した構成への移行を進めており、その領域で利用する技術としてGraphQLを導入することにしました。

技術選定において採用したライブラリ

GraphQLを導入する上で、最終的に採用したライブラリは以下です。

  • バックエンド: graphql-ruby
  • クライアント: Apollo Client
  • クライアントコード生成: @graphql-codegen/cli
  • dataloader: graphql-batch
  • linter: rubocop-graphql, graphql-eslint

一つひとつ選定理由を紹介していきます。

graphql-ruby

github.com

RubyでGraphQLサーバーを構築する上でデファクトスタンダードとなっているライブラリです。ShopifyやGitHubで長期的に利用されておりメンテナンスの継続可能性は高いと判断しています。タイミーではメンテナであるRobert Mosolgoさん*1のスポンサーもしています。

GraphQLの仕様やベストプラクティスに従った実装がしやすいことが特徴的で、例えばページネーションのベストプラクティスであるCursor Connections*2を追加実装なしで利用可能です。その他にも、ドキュメントの手厚さやテストの書きやすさなどから非常に使いやすいライブラリだと思います。

Apollo Client

github.com

フロントエンドのGraphQLクライアントにはApollo Clientを選定しました。ReactにおけるGraphQLクライアントの他の選択肢はRelay*3, urql*4, graphql-request*5などがありますが、以下の観点からApolloを選択しました。

  • コミュニティが活発であり、利用者・インターネットの情報・関連ライブラリの種類等それぞれ大きいため、これから数年は利用が可能と想定できる
  • graphql-rubyでフルサポートされているためApollo/graphql-rubyそれぞれで特に設定なしに利用可能であり、統合時に詰まるポイントが少なそう

とはいえ、Apolloを選択する上で気になるポイントはいくつかあります。

  • 正規化されたキャッシュがプロダクトのユースケースに即しているのか
  • React Suspenseに対応していない*6

代替として比較したのがurqlで、Suspenseが使える・シンプルなドキュメントキャッシュ・軽量なバンドルサイズなど利点はかなり多いものの、以下の理由から選択せずにいます。

  • コミュニティの小ささと利用者の少なさの点でApolloに劣り、新しくGraphQLのキャッチアップを始めるタイミーにおいてはマッチしないと考えた。運営母体が小さいのも気になる。
    • Apollo, Relayは思想が違うためそれぞれ残り続けるだろうが、urqlが残り続けるかどうかについては不確実性がまだ高いと判断した
  • あなたのプロダクトにApollo Clientは必要ないかもしれない*7 という一休さんの記事でApolloの向き不向きに関する言及があるが、(Suspense以外で)urqlで実現可能でApolloで実現不可能なことはないため、問題が出てきてから乗り換える形で問題ないと考えた
    • 後述するgraphql-codegenApolloとurqlの両方に対応しているため、Apollo Link*8やキャッシュストラテジーの複雑なカスタマイズなどのようなApollo特有の機能を多用しなければ乗り換えは難しくない

しかしGraphQLに精通したメンバーが多ければおそらくurqlを選択していたかなとも思います。クライアントライブラリについては運用を続けながら検討したいと考えています。

@graphql-codegen/cli

https://www.the-guild.dev/graphql/codegenwww.the-guild.dev

GraphQLサーバーから提供されるスキーマを基にReactのカスタムフックやTypeScriptの型を生成してくれるライブラリです。個人的にGraphQLを利用する大きな理由の1つであり、これのあるなしで開発体験が大きく変わるほどだと思っています。

各種設定などは後述のフロントエンド周りの実装方針の項で詳しく述べます。

graphql-batch

github.com

DataLoaderをRubyで実装するためのライブラリとしてgraphql-batchを選択しました。

GraphQLのクエリでは取得したいデータのノードを辿って必要なデータを一度に取得できるため、しばしばN+1が起きてしまいます。しかしどのフィールドの組み合わせが要求されるかはクエリによって異なるため、ActiveRecordモデルのassociationsの取得に対して単純にpreloadやeager_loadを行うのは無駄な読み込みが増えてしまい良い解決策とは言えません。

そのための解決策としてDataLoaderを利用した遅延読み込みを実装します。Rubyでの実装方法としては、今回選択したgraphql-batchかgraphql-ruby同梱のdataloaderの2つが選択肢として上がりそうです。

どちらも動かしてみた上で、今回はgraphql-batchを選択することにしました。

  • graphql-batch
    • Shopifyがメンテナンスしており、ある程度枯れているといえる。ShopifyがGraphQLを利用し続ける限りはメンテナンスが続くと想定できる
    • 大元のnode実装のdataloaderのAPIに近く、他言語での実装経験がある人は理解しやすい
      • とはいえgraphql-rubyのfield extensionなどの複雑なことをしようとする場合、Promise.rbのキャッチアップが必要なのはデメリットか
  • graphql-ruby同梱のdataloader
    • 後発のためAPIが直感的で使いやすい印象
      • graphql-batchではPromiseオブジェクトをresolveしなければオブジェクトが手に入らない場面があったが、直接ActiveRecordオブジェクトが返ってくるため理解しやすい
    • 2021年リリースで比較的新しい。内部的にはRubyのFiberを利用しているが、Fiberはデバッグが難しく問題が出てきた際の解決は難しい可能性が高い

今回は安全を取ってgraphql-batchを選択しましたが、何かあった際のgraphql-ruby同梱のdataloaderへの移行(またはその逆)は難しくないというのもあり暫定で意思決定しています。運用しながら判断をする予定です。

rubocop-graphql, graphql-eslint

github.com

github.com

新しくGraphQLを学ぶメンバーが多い環境のため、GraphQLの思想やベストプラクティスを学ぶためにも初期段階でLinterを用意しておくのは良いと判断し、上記2つを導入しました。

graphql-eslintについてはparserとしての役割も担い、VSCode上でgraphqlファイルを書く際の補完も有効になるのが便利です。

バックエンドで考えたこと

ここからは実装を進める上でぶつかった課題についてそれぞれ述べていきます。まずはバックエンドから。

graphql-rubyのデフォルトのディレクトリ構造を変更

graphql-rubyではgeneratorが付属しており、rails generate graphql:install で必要なファイル群が生成され以下のようなディレクトリ構成になります。

❯ tree app/graphql
app/graphql
├── mutations
│   └── base_mutation.rb
├── timee_schema.rb # Railsアプリケーション名から自動で命名される
└── types
    ├── base_argument.rb
    ├── base_connection.rb
    ├── base_edge.rb
    ├── base_enum.rb
    ├── base_field.rb
    ├── base_input_object.rb
    ├── base_interface.rb
    ├── base_object.rb
    ├── base_scalar.rb
    ├── base_union.rb
    ├── mutation_type.rb
    ├── node_type.rb
    └── query_type.rb

このコード群のうち気になる点がいくつかありました。

  1. TimeeSchemaや追加された全てのクラスにおいて、名前空間がグローバルに設定されている
  2. typesディレクトリのクラスは Types::BaseArgument となり、TypesモジュールはGraphQL以外でもよく使われるはずで名前空間の範囲が広すぎる
  3. たくさんのbaseクラスがtypesディレクトリにヒラ出しになっている。それぞれのbaseクラスを継承した具象クラスをこのディレクトリに追加していく場合すぐ見通しが悪くなることが見込まれる

そのため以下のようにディレクトリ構成を変更しました。

❯ tree app/graphql
app/graphql
└── graphql_schema
    ├── arguments
    │   └── base.rb
    ├── connections
    │   └── base.rb
    ├── edges
    │   └── base.rb
    ├── enums
    │   └── base.rb
    ├── fields
    │   └── base.rb
    ├── input_objects
    │   └── base.rb
    ├── interfaces
    │   └── base.rb
    ├── mutations
    │   └── base.rb
    ├── objects
    │   ├── user_type.rb ... 具象クラスの例
    │   ├── base.rb
    │   ├── mutation_type.rb
    │   └── query_type.rb
    ├── scalars
    │   └── base.rb
    ├── timee_schema.rb
    └── unions
        └── base.rb

graphql_schemaディレクトリにラップし、それぞれのtypeごとにディレクトリを用意しています。 GraphqlSchema::TimeeSchema などのクラス名になり、GraphQL関連のクラスは全てGraphqlSchemaネームスペース下に含まれることになります。 これにより外からこれらのクラスを参照することがもしあったとしたら何かおかしいと気づけるはずです。 private_constant を利用して機械的に可視性の制限もできます。

このディレクトリ構成で困ったことはほぼなかったのですが、 rails generate graphql:object などのgraphql-rubyが提供するScaffoldジェネレーターがそのまま利用できない問題がありました。ActiveRecordモデルに対応するオブジェクト型クラスを作る場合、モデルのattributesからフィールドの型を類推しコード生成してくれるため非常に便利で、どうにか活用したいです。

ジェネレーターのテンプレートで Types モジュール下にクラスがある想定なのが原因で*9、ジェネレータークラスを継承したカスタムジェネレーターを作ることで解決できました。

# lib/generators/timee/graphql/object/object_generator.rb

require 'generators/graphql/object_generator'

module Timee
  module Graphql
    class ObjectGenerator < ::Graphql::Generators::ObjectGenerator
      source_root File.expand_path('templates', __dir__)

      def create_type_file
        template 'object.erb', "#{options[:directory]}/graphql_schema/objects/#{subdirectory}/#{type_file_name}.rb"
      end

      # idフィールドはGraphQL::Types::Relay::Nodeで実装するため除外する
      def normalized_fields
        super
        @normalized_fields.reject! { _1.instance_variable_get(:@name) == 'id' }
      end
    end
  end
end

# lib/generators/timee/graphql/object/templates/object.erb

<% module_namespacing_when_supported do -%>
module GraphqlSchema
  module Objects
    class <%= ruby_class_name %> < Base
      implements GraphQL::Types::Relay::Node

    <% normalized_fields.each do |f| %>  <%= f.to_object_field %>
    <% end %>end
  end
end
<% end -%>

これにより rails generate timee:graphql:object MyNamespace::ModelName でオブジェクト型クラスをScaffoldingできます。

バックエンドのテスト方針

チーム開発でアーキテクチャを安全にスケールさせるためには、定義したレイヤーに対応したテスト方針を用意しておくことは開発メンバー間での認識を揃えるために有用と考えています。graphql-rubyのドキュメントには以下の3つのレイヤーとテスト方針が紹介されており*10、それを基にタイミーでのテスト方針を定めました。

Application-level

Interface-level

  • GraphQLの各query/mutationのレイヤーです。
  • 各query/mutation単位で期待するフィールドが返るかを検証するテストを書くのが良いでしょう。以下はgraphql-rubyのドキュメントに記載されている参考例です。
it "loads posts by ID" do
  query_string = <<-GRAPHQL
    query($id: ID!){
      node(id: $id) {
        ... on Post {
          title
          id
          isDraft
          comments(first: 5) {
            nodes {
              body
            }
          }
        }
      }
    }
  GRAPHQL
  post = create(:post_with_comments, title: "My Cool Thoughts")
  post_id = MySchema.id_from_object(post, Types::Post, {})
  result = MySchema.execute(query_string, variables: { id: post_id })
  post_result = result["data"]["node"]
  # Make sure the query worked
  assert_equal post_id, post_result["id"]
  assert_equal "My Cool Thoughts", post_result["title"]end

MySchema.execute に対してクエリ文字列やvariablesを渡し、期待するレスポンスが返るかどうかを検証しています。

API実装時には基本的にこのテストを書くことが多くなるはずです。

Transport-level

  • GraphQLサーバーはHTTP(Railsのrouting)で提供しています。
  • 疎通確認のために1件だけrequest specを用意しました。個別のquery/mutationについてテストする必要はありません。

nullability

graphql-rubyで生成したschema.graphqlを見ると、connection_typeのnodesフィールドなどほとんどのフィールドがnullableで定義されていることに気づきます。自身でフィールドを定義する場合もデフォルトではnullableであり、non-nullにしたければ明示的に null: false を付与する必要があります。

この仕様を知った時、なぜnullableなのか?と違和感がありました。nullableではフロントエンドの多くの箇所で存在チェックをしなければならなくなり、無駄にコードを複雑にしてしまいます。

GraphQL公式ドキュメントのベストプラクティス*11によると「データベースやその他のサービスに支えられたネットワークサービスでは、うまくいかないことが度々あるからだ」と述べられています。またWhen To Use GraphQL Non-Null Fields*12というブログ記事では「GraphQLはバージョンレスAPIの思想を持ち、多くのチームが一つのAPIに依存するため破壊的変更が難しくなりやすく、スキーマの進化を困難にさせる」と述べられています。

チームでの議論の上、そうした背景も踏まえつつもREST APIでのリソース設計と同様で「理由がない限りnon-nullのフィールドとする」方針で進めることにしました。理由は以下です。

  • non-nullのフィールドに対してDBから返却されたデータがnullであった場合、graphql-rubyがそのクエリ自体をエラーとしてハンドリングしレスポンスを組み立ててくれること
    • そもそもそのような状態はビジネスロジックの要求を満たせていないことが多いはずで、nullableにしてしまうことで問題に気づけない状況を防ぎたい
  • 一般的にnon-nullからnullableに変更する方が簡単であり、その逆は困難を伴うか不可能な場合が多い
  • GitHubAPIでは、connection typeのほとんどがnon-nullとなっている
  • プロダクトの特性上、破壊的変更に対処することが難しいわけではない
    • GraphQL APIを外部に公開する予定がなく、ステークホルダーの調整も社内で完結する
    • クライアントは現状Webのみのため、オンデマンドなリリースが可能

graphql-rubyの場合、non-nullにオプトインする設定が用意されているためそれを利用すれば良いです。

https://graphql-ruby.org/api-doc/2.0.14/GraphQL/Types/Relay/ConnectionBehaviors/ClassMethods#node_nullable-instance_method

ページネーション

graphql-rubyでは標準のページネーションとして cursor-based paginationの仕組みが導入されています。

cursor-based paginationでは前後のページのカーソルのみ手に入るため、Googleの検索結果にあるようなページ番号を選択して直接ページに飛ぶことができません。

タイミーでGraphQLを導入するのは主に管理画面であり、管理画面のようなプロダクトでページ番号による操作が行えないことは困る場合が多いため、もう1つのページングアルゴリズムであるoffset-based paginantionを自前で実装しています。

主にこれらの記事を参考にしています。

Generic page number / per-page pagination with GraphQL-Ruby · GitHub

ichikawa-dev.hatenablog.jp

query/mutationによって接続するDBのreader/writerインスタンスを切り替える

タイミーではRailsのマルチDB機能を利用しているため、GraphQLでも活用するためにリクエストに含まれているquery/mutationを識別してDBインスタンスを切り替える仕組みをgraphql-rubyのtracerを使って実装しました。詳しくはこちらに記載しています。

zenn.dev

Datadogによるリクエストの監視

GraphQLは単一のPOSTエンドポイントに全てのリクエストが送信されるため、通常のAPMを利用したエンドポイントの監視では適切な監視を行えません。そのためgraphql-rubyでは、リクエストをトレースしAPMとの統合を行える仕組みが用意されています。タイミーではDataDogを利用しており、graphql-rubyがデフォルトでDataDogとの統合が提供されていました。 これにより、GraphQLのoperation name単位で分類しつつ監視できます。

フロントエンドで考えたこと

GraphiQL

GraphiQLは、GUIでGraphQLを操作するための統合開発環境です。コード補完・シンタックスハイライト・APIドキュメントの閲覧と、実際にquery/mutationも実行可能です。

graphql-rubyでは https://github.com/rmosolgo/graphiql-rails が初回セットアップ時に追加されるためそれを使うのが一般的かもしれませんが、graphiql-railsを使うとなるとタイミーの場合APIリクエストをする際の認可プロセスをNext.js用に用意しているものとは別で用意しなければならず、理想とはいえませんでした。

npmで公開されている https://www.npmjs.com/package/graphiql が提供しているReactコンポーネントが利用できることに気づき、そちらをNext.js上で利用することで解決しました。どんなクエリでも実行できる自由さは危険なため、Production環境では使用できないようにしています。

Fragment Colocation

今回はプロジェクト開始時点でFragment Colocationの方針で進めることにしました。Colocationは「一緒に配置する」という意味とのことで、GraphQLのFragment定義とコンポーネントを近い場所に置き、コンポーネントに必要なデータを宣言的に定義する方針を取っています。

より具体的な概念や実現方法はこのスクラップで詳細にまとめられています。

zenn.dev

RelayではFragment Colocationが “most important principle” とされていて厳格な運用を強制されますが、Apolloではそこまで堅い設計になっていません。実際の運用としてはgraphql-codegenのnear-operation-file*13プラグインを利用することで運用が可能です。graphqlファイルの同階層にコード・型を生成したファイルを配置してくれます。以下がcodegen.ymlの設定例です。

overwrite: true
schema: 'src/apis/graphql/schema.graphql'
documents:
  - "src/**/*.graphql"
generates:
  src/:
    preset: near-operation-file
    presetConfig:
      extension: .generated.ts
      baseTypesPath: ~~/apis/graphql/types.generated
    plugins:
      - 'typescript-operations'
      - 'typescript-react-apollo'
    config:
      immutableTypes: true
      nonOptionalTypename: true
      avoidOptionals: true
 ~~~ 省略 ~~~

Fragment Colocationを運用することで以下のメリットがあると判断しています。

  • クエリの実行はPageコンポーネント・各コンポーネントで必要なデータはfragmentとして定義するようにルール化すると、初回ページ表示に必要なネットワークリクエストを1回で終えられる
  • 必要なデータがコンポーネントの横に定義されているので見通しが良い・不要な定義の削除漏れに気付きやすい・underfetching/overfetchingに気付きやすい
  • 生成コードを1つのファイルにまとめるデフォルトの方法と比べてファイルチャンクの最適化が可能、ページに必要なコードのみを含めることができる ref: https://blog.hiroppy.me/entry/2021/08/12/092839

課題としてfragmentでvariablesを定義できないことの不都合や、mutationのrefetchQueriesとの相性が悪いなどは考えられますが、それを差し置いてもメリットは大きいと思っています。

Testing, Storybook

コンポーネントのテストはGraphQLがなくても変わらず、Storybookを起点に行なう方針としています。

Fragment Colocationを利用しているため大半のコンポーネントは必要なデータに相当するpropsを渡すだけで問題ないので考えることは少ないですが、ネットワークリクエストが発生するPageコンポーネントではリクエストのモックを検討する必要があります。

モックを実現するためのライブラリとしてはstorybook-addon-apollo-client*14 とMock Service Worker*15 の2つが選択肢として考えられますが、チームの学習負荷が大きい状況だったため一旦は学習コストの少ないstorybook-addon-apollo-clientを選択しました。しかしGraphQLの導入とは別の課題感からmswを導入する動きがチームで始まっており、ゆくゆくはmswに載せ替えていきたいと考えています。

またモックデータを簡単に生成するための仕組みとしてgraphql-codegen-typescript-mock-data*16を導入しています。Railsでよく使うFactoryBotのように簡単にオブジェクトが生成できてかなり便利です。概要はこちらの記事が理解しやすいかと思います。

zenn.dev

オンボーディング

最後に組織へのGraphQLの浸透のためのオンボーディングについて紹介させてください。

タイミーでは組織の拡大に伴って多くの課題が生まれては乗り越えてきました。現在は書籍チームトポロジーを組織内の共通言語として扱う取り組みを進めつつ、チームトポロジーをベースにチームの役割の再定義・分割を進めています。その過程で事業価値を最大化を目指す複数のチーム(チームトポロジーで紹介されているストリームアラインドチーム)の他に、ストリームアラインドチームを支援する開発プラットフォームチームを立ち上げました。*17

開発プラットフォームチームは、ストリームアラインドチームが自身の責任領域に注力しやすくするためにバリューストリームから遠い技術的な領域に関する認知負荷の削減をミッションとしています。 筆者も開発プラットフォームチームに所属しGraphQLの導入に取り組んできましたが、ストリームアラインドチームが解決したい課題を解決するためにGraphQLを利用できて、すぐにバックエンドのquery/mutationを、フロントエンドのGraphiQLでクエリをそれぞれ書き始められる状態を目的としていました。

合わせて弊チームはチームトポロジーでいうイネイブリングチームとしての側面も持ち、GraphQLのスキルをストリームアラインドチームが習得するための短期的な支援も担っています。そのための支援の例としてモブプログラミング会を開催したり、社内記事の充実化を図ったりしています。本記事は社内ドキュメントに書き連ねていた内容からプロダクト固有の内容を省略しまとめたものだったりもします。

社内記事の例

またGraphQLを利用した実装で判断に迷う時の議論を減らすための土台としてコーディングスタイルガイドの制定に取り組んでいます。まだ進行中ではありますが、目次だけ紹介します。

目次

ストリームアラインドチームが実際に機能実装をする過程で出てくる課題によって加筆修正を繰り返していく予定です。本文が読みたい方はぜひ入社を、カジュアル面談もお待ちしています。

終わりに

本記事では、RailsとNext.jsを利用しているプロダクトでGraphQLを導入する際に考えたことを紹介しました。概念等の説明は少なめに、より実践的な内容の紹介を目的としていました。組織でGraphQLを導入を始める際に議論の参考にしていただけると幸いです。

ここまで読んでいただきありがとうございました。