wevox事業部エンジニアのタガミショウゴ(@HOWABOUTUP)です。普段は主にwevoxのReact, TypeScript, Railsを書いてます。
先日wevoxでは権限システムをアップデートし、RBAC(Role Based Access Control)ベースへと作り替えました。これにより、ユーザーが自身の組織にあわせた柔軟な権限をユーザーが自由に付与することができるようになりました。
そもそもwevoxはRESTfulなアプリケーションですが、そこにRBACの箇所だけGraphQLを部分的に導入しました。今回はこのGraphQLの導入についてお話ししていきます。
「いまRESTful APIだけど、GraphQLどうなの?」「いきなりGraphQL全振りは難しくない?」 とお思いの方に、段階的にGraphQLを導入している一例として参考になれば幸いです。
現状の課題
(1) 増加・複雑化するModel
wevoxというサービスは、導入される企業の従業員の方々へサーベイを配信し、その回答内容を様々な観点から分析・解析するものです。そして、そこから個人・組織のエンゲージメントを観測し、日々の組織開発などへ活かすことができます。
要はデータを取得・分析して、その結果を表示し、インサイトを得るようにするということなのですが、そこには多種多様なデータが入り混じります。wevoxでいうと企業の組織情報、階層情報、配信情報、また解析されたスコアやカテゴリ などなど。これらを1画面で操作できるようにならないといけません。イメージとしてはGoogle Analyticsが近いかもしれません。
これまでは多くのデータModelをRailsのREST APIで非同期に取得、あるいは古いところだと画面描画時にフロントエンド渡していました。しかし、データ種類が増えるたびに叩くAPIは増えます。対応策として、APIを叩く回数を減らす代わりに各画面で必要なデータを提供するRails BFF APIを生やして対応しています。しかし、画面側の変更にAPI側が追いつかないという事態も懸念としてあります。
(2) フロント/サーバー間の共通型定義
wevoxのフロントエンドは2年前ほどからTypeScriptを導入しています。フロントエンドでは期待するAPIレスポンスを型定義して、API URLと一対一になるように用意した関数でこの型定義を参照するようにしています。
こうすることでフロントエンドで必要なレスポンスが増え、APIレスポンスの型も修正するとなった場合も変更範囲はなるべく少なく済んでいました。
しかし、これはあくまでフロントエンドエンジニアが把握する範囲の変更にしか耐えられません。API側で何かしら変更があったとしても、フロントエンドはそれを知ることなく古い型を正として保持してしまいます。
なぜGraphQLだったのか?
こうした課題を解決する手段としてGraphQLが選ばれました。
- 増加・複雑化するModelへの対応 => これらをフロントエンド都合で"ある程度まとまったかたまりで"取得できるエンドポイントを用意できる
- フロント/サーバー間の共通型定義が欲しい => graphql-ruby, graphql-codegenで型共有できる
ただし、「よっしゃ!いまから全部書き換えるぞ!」となるのは少なくともwevoxにおいては時期尚早で、"段階的に"GraphQLを導入するに至りました。その対象が今回はRBACだったということです。
具体的なディレクトリ構成、コード例
まずサーバーサイドではgraphql-rubyを採用しました。おそらくRailsアプリケーションでGraphQLを採用するとなったら本記事執筆現在、最有力候補ではないかと思います。
READMEにも記載の通り、
Gemfile
gem 'graphql'
を追加して、
$ bundle install
$ rails generate graphql:install
とするだけです。こうすることで、必要なディレクトリとファイル、そしてルーティングが追加されます。なので、これに沿って必要な範囲のqueryやmutationを追加していきます。
以下は自動生成されるTypes
モジュールの一つです。
/app/graphql/types/query_type.rb
module Types class QueryType < Types::BaseObject # Add `node(id: ID!) and `nodes(ids: [ID!]!)` include GraphQL::Types::Relay::HasNodeField include GraphQL::Types::Relay::HasNodesField # Add root-level fields here. # They will be entry points for queries on your schema. # TODO: remove me field :test_field, String, null: false, description: "An example field added by the generator" def test_field "Hello World!" end end end
これを今回使う用途に置き換えます。このときにresolver
を指定するとQueryType
をいい感じに分割できました。
参考:
github.com
/app/graphql/types/query_type.rb
module Types class QueryType < Types::BaseObject field :fuga, resolver: Hoge::Fuga, description: 'ここに説明文' end end
/app/graphql/hoge/fugas.rb
module Hoge class Fuga type [Types::FugaType], null: false def resolve # 具体的な処理を記述 end end end
/app/graphql/types/fuga_types.rb
module Types class CompanyRolesType < Types::BaseObject implements Interfaces::RoleField description "会社のロールオブジェクト" end end
/app/graphql/interface/fuga_field.rb
module Interfaces::FugaField include Types::BaseInterface description "fuga のフィールド" field :id, Int, null: false field :name, String, null: false end
また、GraphQLから先のRoleをCRUDする部分は/usecase
ディレクトリの中に分けています。この中には元々GraphQL以外で使っていた
こうすることで、既存Railsアプリケーションの処理を汚すことなく、GraphQLエンドポイントを追加することができました。もし仮にGraphQLを剥がすということになっても、移行がしやすいようになっています。
型の共有
GraphQL APIとフるロントエンドの型の共有は、初期リリースでは暫定的に手動で行いました。
まずrakeタスクを定義しました。
/lib/tasks/graphql.rake
namespace :graphql do desc 'グラフQLのスキーマを更新する' task dump_schema: :environment do # Get a string containing the definition in GraphQL IDL: schema_defn = HogeSchema.to_definition # Choose a place to write the schema dump: schema_path = "app/graphql/schema.graphql" # Write the schema dump to that file: File.write(Rails.root.join(schema_path), schema_defn) puts "Updated #{schema_path}" end end
で、これをlocalで実行すると、
$ bundle exec rake graphql:dump_schema
サーバー側で定義されたschemaをダンプファイルに落とすことができます。
app/graphql/schema.graphql
type HogeType { id: Int! name: String! }
wevoxではサーバー/フロントでリポジトリを分割していることから、このダンプファイルをフロントエンド側でも読み込んで、GraphQL Code Generatorを使ってtsの型定義ファイルを生成しています。
Github Actionなどを使うことで、ダンプファイルの生成からGithubでのPRの作成までを自動的に行うことで効率化を図ることができます。
フロントエンド
// 型は省略 export const AxiosUseJwt = axios.default.create({ headers: commonHeader }); export function axiosUseQuery<T>(query: string): axios.AxiosPromise<T> { return AxiosUseJwt.post('/graphql', JSON.stringify({ query })); } // ここでqueryを生成 export function getHogeQuery(params: HogeParams): string { const input = `id: ${params.id}`; return `{ hoge(${input}) { fuga { id name } } }`; } export function getHogeFuction(params: HogeParams): AxiosPromise<QueryResponse<RoleGroupsResult>> { return axiosUseQuery<QueryResponse<RoleGroupsResult>>(getRoleGroupsQql(params)); }
フロントではGraphQLを叩くモジュールはコンポーネント層とは分離しており(その他のREST APIも同様)、上記のような関数をsrc/apis/graphql/getHogeFuction.ts
の層に置いています。コンポーネント層からは直接GraphQLなどのAPIを叩かず、このモジュールを介してアクセスするようにしています。
引数にparams
を受け取ってGraphQLのqueryを生成する関数と、axiosを使って/graphql
を叩きます。とてもシンプルですね。
また今回はApolloやRelayなどのクライアントライブラリの採用を見送っています。その理由としてはGraphQL自体の導入が"前向きな実験的導入"であり、最悪GraphQLを剥がすとなったときに例えばApolloなどを入れていることでその工数が増えることを懸念しているためです。
GraphQL導入してよかった?
まだGraphQLを導入して間もなかったため、初期段階での所感では開発に携わったサーバーサイド・フロントエンドエンジニア共に満足しています。具体的な理由は世間一般で言われているGraphQLのメリットと同じことなので、今回は改めて説明はしません。
一方で、今回はRailsアプリケーションへの導入だったため、Rubyに型がないことが実装時のネックではありました。また、一部導入によりREST or GraphQLの判断が都度求められるのと、若干の複雑さが生まれる点も、段階的導入のデメリットだと思います。またパフォーマンスの考慮なども今後問題としてあがってくる可能性はあります。
それらのデメリットを考慮しても、今回のように小さく・薄くGraphQLを導入することのメリットは大きかったと思います。あとはこれをどう拡大するのか、または不要と判断して縮小あるいは剥がすのかをチームで議論しながら育てていくといいと思います。
今後の発展方向性
wevoxはモノリシックアプリケーションを徐々にマイクロサービスへ寄せています。その中で、BFF APIを全てRailsあるいはNext上で全てGraphQLに置き換えるという選択肢も検討しています。
今回はその第一歩として一部機能をGraphQLで置き換えました。今回は採用していないApolloをフロントエンドに導入できるように、GraphQLとの接点も"隠蔽"してたりします。実際に運用する中でここを拡大あるいは縮小させる余地を残しつつ、いまのチームにあった設計を見つけていきます。
最後に、個人的にはGraphQLは"銀の弾丸ではない"ということを念頭において開発しています。例えばフロントエンドの都合に合わせて一度にデータをとってこれるもの、と考えていると見えないところでN+1の発生などを引き起こしかねません。また実際REST APIでもGraphQLと同様のメリットを享受することも可能で、GraphQLに置き換えたからといって"みんなハッピー"ということでもありません。
今後はまずは入れてみて考える、という実験的導入をするなかで冷静に「あり/なし」をチームで話し合っていくのが良いと思っています。
アトラエでは新しいエンジニアメンバーが毎月増えており、開発スピードが加速しています。そんなアトラエに少しでも興味を持ってくださったら、是非カジュアル面談でお話しましょう!詳細は下記のスライドや採用サイトで確認できます!