こんにちは。Eight事業部の古本です。Eightで"企業向けプレミアム"という企業内で名刺を共有できるサービスのサーバーサイドの開発・運用を主に行っています。
その企業向けプレミアムですが、去る2020年5月にバージョンアップが行われました。
そこでEightの中では初めてGraphQLを取り入れてAPI開発を行いました。
EightではRailsを採用しているためAPIといえばREST形式で書かれています。
そんな中で方式が異なるGraphQLを採用するということは、当初思い描いていた以上に様々な課題がありました。
この記事では、そこで起きた様々な課題とそれに対するアプローチや解決策などサーバーサイドの目線から述べたいと思います。
なぜGraphQLを導入することにしたのか
細かな理由はいくつかありますが、主に解決したかった課題は以下の2つです。
- これ以上エンドポイントを増やしたくなかった
- フロントエンド側とのコミュニケーションコストを下げたかった
Eightは2012年にサービスを開始した8年ほどの歴史があるプロダクトであるため、どんなに設計で気を使ったとしてもAPIのエンドポイントは増え続けていきます。
原則1つのエンドポイントしか持たないGraphQLを採用することで必要以上にエンドポイントを増やしたくない狙いがありました。
また、パフォーマンスやビジネスの都合など大人の事情によって、1つのエンドポイント内で複数のドメインを組み合わせて取得するAPIも珍しくありません。
そういった事情で作られたAPIは仕様が複雑で影響範囲も広いので、修正時にはフロントエンド側、アプリ側との慎重なコミュニケーションが要求されていました。
定義しておけばクエリで関連情報も取得できるGraphQLを採用する事で、そういったコミュニケーションコストを下げれるのではないか?という狙いもありました。
どういった形で導入したのか
Rubyには graphql-ruby というGemがあるので、それを使って導入しました。
github.com
導入前に立ちはだかった課題
フロントエンドとのSchema連携について
GraphQLといえばSchemaです。なのでSchemaファイルを生成して、それを介して開発を行うのはサーバー側、フロント側でも予め期待していました。
graphql-ruby GemでもSchemaファイルを作成するrakeコマンドが提供されています。
RubyでSchema定義を書かなければならないのが若干手間となりますが、負荷はそこまで高くないので今回のプロジェクトでは、
- RubyでSchema定義を書いてSchemaファイルを作成する
- サーバー側・フロント側でレビューを行う
- レビューを通ったSchemaファイルを取り込みそれぞれ開発を進める
- 開発中に問題が出たら定義の修正から行い直す
という流れでフロントエンド側との連携を行って開発を進めました。
どこまでやるか
ここでいう どこまで とは企業向けプレミアムのバージョンアップで使うAPI全てをGraphQLで書くかどうかです。
開発側の思いとしては全て書きたかったのですが、旧バージョンの並行稼動や工数、フロントエンド側やアプリ側との連携、既存資産の活用などを踏まえるとある程度はRESTで書かざるを得ませんでした。
チーム内で何度も議論を重ねましたが、最終的には今回新規で作成する事になった画面に対するAPIはGraphQLで書き、既存の画面で呼ばれる、もしくは呼ばれているAPIはRESTで書く決断をしました。
その結果として今回のバージョンアップ版でも10個を超えるREST APIのエンドポイントが誕生しました。
GraphQLとRESTのAPIが同居してしまったのは将来的な負債に繋がる可能性が高くありやりきれない思いもありました。
しかし、半年近く開発する事になる今回のプロジェクにおいてこの決断は工数的・品質面でかなりポジティブな側面もあったのは事実です。
いま振り返ってみてもとても難しい判断だったと思います。
導入中に立ちはだかった課題
認証問題
Eightはユーザー認証しなければ利用出来ないサービスなため、GraphQL APIでも認証を行う必要があります。
graphql-ruby Gemでは、GraphQLのアクセスは GraphqlController を経由して処理が行われるため、既存の認証処理をcallbackに埋め込むという方法で対応できました。
class GraphqlController < ApplicationController before_action -> { eight_authenticate_logic }
ただし、この方法だと認証失敗時の挙動も共通処理の動作になってしまうので、後述する例外問題にも繋がりますがGraphQLとして共通化された動きとは異なってしまいます。
かといってここに手を入れるのは工数的なインパクトも大きいため、最終的にはチーム内で話し合って認証の場合は共通処理に準じる事としました。
例外問題
GraphQLでは例外が発生した場合もhttp statusでは200を返し、errorsにエラーの詳細情報を持たすことが良いとされています。
ただし、それはあくまで提唱されているプラクティスであり仕組み的に組み込まれている訳ではないので、そう動くよう実装する必要がありました。
対応としては大きく分けると2つの方法、 Globalなエラー と API固有のエラー に分けて対応を行いました。
Globalなエラー はいわばシステムエラーなど共通かつシステムが発する例外です。
これはGraphqlController でハンドリングするようにしました。
class GraphqlController < ApplicationController def execute (中略) rescue => e handle_error_in_development e # <- error情報をjson形式で返す end
API固有のエラー は例外をGraphQLのTypeとして定義し、例外発生時にはその定義情報をerrorに含めて返すようにしました。
# 例外の定義 module Types::Errors class CreateInvitationErrorType < Types::BaseErrorType field :message_key, CreateInvitationMessageKeyType, null: false end end # Queryのerrorsで定義した例外を指定している module Mutations class CreateInvitation field :errors, [::Types::Team::Errors::CreateInvitationErrorType], null: false end end
エラーを定義した事でschemaファイルにも定義情報が渡るようになったので、フロントエンド側と例外についてのやり取りが行いやすくなりました。
一旦はこのやり方で回るようになってきましたが、定義から漏れるとGlobalで拾われてしまったりエラー定義が煩雑になってしまったりとまだまだ課題も残ります。
この問題は開発の初期から終盤まで残り続けた課題であり、まだまだブラッシュアップが必要だと思ってます。
N+1問題
GraphQLは関連も定義しておけば1つのクエリで取得できるので利便性は高い一方で、構造的にN+1問題が発生しやすいという問題があります。
しかし、GraphQLが使われ始めた頃から存在する問題のため、既に解決策も存在します。
ベストプラクティスの中でbatchingと呼ばれる複数のリクエストをまとめて取得する方法が推奨されています。
graphql.org
batchingを実現するために今回の開発では graphql-batch というGemを利用しました。
github.com
使い方としては、現状はまだ混み合った処理が少ないのでLoaderを作り呼び出すというオーソドックスな使い方をしています。
# loader class SummarizedProfileLoader < GraphQL::Batch::Loader def perform(person_ids) person_ids.each { |person_id| fulfill(person_id, nil) unless fulfilled?(person_id) } end end # query field :profile, Types::Team::ProfileType, null: true, resolve: ->(obj, _args, _ctx) do SummarizedProfileLoader.for.load(obj.person_id).then do |profile| profile end end
それでも何もしなければ数十回クエリを発行するような処理でも、クエリの発行を3回に抑える事ができ効果は大きかったです。
GraphQLを導入するプロジェクトでは必須の機能だと思います。
導入後に立ちはだかった(はだかっている)課題
NewRelic問題
Eightではサービスのパフォーマンスを分析するツールの1つとしてNewRelicを利用しています。
graphql-ruby GemはNewRelicに対応しているので、追加で設定を行うことで計測できるようになります。
その際にoptionalの set_transaction_name: true を指定する事で GraphQL#execute に集約する事なくQuery単位で計測を行うことができるようになります。
class EightSchema < GraphQL::Schema use(GraphQL::Tracing::NewRelicTracing, set_transaction_name: true)
200 Internal Server Error
何のことかわかりにくいですが、システム内で発生した500エラーがうまくハンドリングされなかった結果として、http status 200 でerror messageに Internal Server Error と出てしまう事象が発生しました。
こういったレスポンスになってしまうとフロントエンド側でもどう対処したらいいかわからなく、ユーザー側にもよくわからないダイヤログが出てしまう事になります。
また、エラーメッセージが Internal Server Error とだけあっても原因がわかりにくく初動が遅くなる要因にも繋がります。
既に発生した事象自体は対応済みですが、今後も発生しうる問題であり、まだ明確な対処方法も確立していないので課題として残っています。
GraphQLを導入してみて
冒頭で解決したい課題を2つ書きました。
- これ以上エンドポイントを増やしたくなかった
- フロントエンド側とのコミュニケーションコストを下げたかった
REST APIと併用した事でエンドポイントが増えるのは防げませんでした。
しかし、定義が握れていればその先はフロントエンド側の判断で作業を進められるので、コミュニケーションコストを下げることができたと思います。
また、新たな機能の追加・修正があった際にも対象となるクエリに必要項目が定義済であればサーバー側の対応が不要になった時もありました。
開発ツールや事例が思ってた以上に充実しており、工数にインパンクトが出るくらいハマる事は少なく開発体験としても良好でした。
万事期待通りとまではいかない部分もありましたが、GraphQLだからこそ良かった部分も多かったです。
開発メンバーの評判も決して悪くないので、この辺りは頑張って導入してよかったと思います。
まとめ
様々な課題がありましたがそれらを乗り越え無事にGraphQLを導入できました。
リリースしてから約2ヶ月経った今も大きな問題が出ることなく無事に動いています。
現在においてはGraphQL自体そんなに珍しいものではなくなりつつありますが、未だRESTで運用しているプロダクトは多々あると思うので、GraphQLに興味がある開発者の手助けとなれば幸いです。
buildersbox.corp-sansan.com
buildersbox.corp-sansan.com
buildersbox.corp-sansan.com