はじめに
こんにちは。giftee Loyalty Platform(GLP)の開発をしている安達です。GLPはギフティの中では新規事業という性質もあり、Railsのサーバーサイドの設計方針が定まっておらず、実装者によって書き方がバラバラになっている状態でした。そのようなプロダクトに設計方針の導入を試みるリファクタリング対応を私の方で継続的に行って来た経緯があり、その中で得られた知見について記事にしてみたいと思いました。
Rails Wayを活かした設計方針について
Railsは"Rails"の名が示す通り、MVCモデルを中心とした強力な設計方針(Rails Way)を提供するフレームワークです。その一方で、実際の業務で扱う複雑な要件に応える際に、Railsの機能を素直に使うだけでは足りない部分が出てくるというのは多くのRailsプログラマを悩ませる問題かと思います。
プレーンなRailsを補う設計方針について、これまでWebや書籍等で様々な観点の議論が展開されて来たと思いますが、大きく分けると、RailsのMVCモデルでは足りない部分をDDDなどの考え方を導入して積極的に補う考え方と、極力Railsのやり方に沿う考え方の2つの方向性があると思います。
- Sustainable Web Development with Ruby on Rails
- 『Sustainable Web Development with Ruby on Rails』はRails使ってるなら絶対面白いと思う
- 『Sustainable Web Development with Ruby on Rails』を読んだ
後者のRails Wayに極力沿う考え方については、上掲の"Sustainable Web Development with Ruby on Rails"という書籍とその書評記事によく表れていると思います。
今回私がリファクタリングしたプロダクトは、比較的小規模で、JSONを返すタイプのREST API(フロントエンドはRails のviewでなく、React)です。こういったプロダクトの性質も踏まえて、Rails Wayを極力活かす形で、シンプルな設計方針を導入するのが望ましいと考えました。
- ソースコードを見るだけで設計方針を理解できる
- 多様なスキル感のメンバーがチームに参加した際にもコードの品質が揃う
チームの状況も踏まえて、上のような観点を重視しました。
Railsアプリケーションの設計の観点は多岐にわたりますが、この記事ではREST APIのエンドポイント設計と、JSONレスポンスのフォーマットに関連する部分に絞って書きたいと思います。
REST APIのエンドポイント設計
リファクタ前の課題
特に指針のないエンドポイント定義に基づく、雑多なcontrollerとactionが乱立しているような状態でした。この問題について、下記のような指針の導入を試みました。
CRUDの基本actionの使用を優先し、適宜リソースを分割する
コントローラが元々持っているRESTアクションやデフォルトの5つの機能にはないメソッドを付け加えたいと思ったら、いつだって新しいコントローラを作る。それだけでいいのです。
上の記事でRails作者のDHHが述べているように、index、show、new、edit、create、update、destroyの基本actionの利用を優先し、独自actionの定義を避けることは、リソースベースのエンドポイント設計の方針を明確化し、controllerの処理の見通しを良くします。
その一方で、DBのテーブルと1対1でcontrollerのリソースを定義している場合は、基本actionのみで現実のビジネス要件に応えることはすぐに難しくなるでしょう。DHHは、DBのテーブルとの対応にこだわらずにcontrollerのリソースを分割することで、基本actionのみで実装できるとしています。
これは優れた指針である一方で、実際にルールとしての導入を試みるとなると難しい部分もある印象です。上の記事でDHHの挙げた実例について、引用者のDalbertさんがそのユースケースではcontrollerを分ける必要はないと述べているように、modelと1対1に対応しないリソース分けをどうするかは、設計者のセンスがそれなりに高度に求められる部分です。チーム開発で自分一人がエンドポイント設計する訳ではないということを前提とした際に、「独自action禁止」をルール化して逐一指摘する運用を考えると、コスト対効果やルールの継続性(属人化したルールになってしまわないか)の観点で懸念があると判断して、この方針は参考にしつつもルール化はしないという形で採用しています。リソース分けの良し悪しについては、引き続き業務を通して詰めて考えていきたい点です。
resourcesメソッドの利用
上の基本actionの利用と関連して、ルーティングの設定にresourcesメソッドを利用するとroutes.rbの記述がシンプルで見通しが良くなり、かつ実装者の恣意性によってエンドポイント定義のルールの一貫性が損なわれることがありません。
Nested ResourceとNamespaceでControllerを分割する
上の記事で解説されているような、注目するリレーションに応じてresourcesメソッドをネストして、controllerを別立てする手法も便利だと思います。
namespaceの分離
resourcesメソッドと同じくルーティング設定で利用できるnamespaceメソッドを利用することで、同じリソースについて完全に分離された複数のエンドポイント定義、controllerの実装を持つことができます。
例えば私がリファクタしたプロダクトはユーザーアプリのバックエンドと管理画面アプリのバックエンドを兼ねているのですが、管理画面側のAPIには dashboard
のnamespaceを設定することで、それぞれの影響範囲を分離して、個別のアプリケーションの性質に最適化された処理を書くことができるようになりました。
リファクタ後どうなったか
新規に追加するエンドポイントについては、Rails Wayに沿ったリソースベースの規則性に従って定義できるようになりました。その一方で、既存のエンドポイントについては、フロントエンド連携の観点で移行にコストがかかることもあり、リファクタ後のルールに移行し切れていないのが実態です。controllerのリソース分けについては、チームメンバーと議論しつつ、あるべき形を模索しています。
JSONレスポンスのフォーマットを統一する
リファクタ前の課題
JSONレスポンスのフォーマットについて、主にjbuilderで実装されていましたが、特にモデル間のアソシエーションが関わる部分について、実装者の恣意性によってフォーマットに規則性がなく、どのように JSONレスポンスのフォーマットを定義すれば良いか、ソースコードを見てもプロダクトとして従うべきルールが分からない状態でした。この問題について主にActiveModelSerializersを利用することで解決を試みました。
ActiveModelSerializersのメリット
jbuilderの記法の自由度が高すぎる課題感を解決するために、ActiveModelSerializersというgemを導入してみたところ、非常に便利に活用できている感触です。
class PostSerializer < ActiveModel::Serializer attributes :title, :body has_many :comments end
class CommentSerializer < ActiveModel::Serializer attributes :name, :body belongs_to :post end
class PostsController def show post = Post.find(params[:id]) render json: post, serializer: PostSerializer end end
ActiveModelSerializersは上のサンプル実装例のように、Railsのmodelに対応するserializerを用意して、Railsのmodelと同じように has_many
や belongs_to
でアソシエーションを定義できます。このserializerをcontrollerで指定するだけで、モデル間のアソシエーションを含むレスポンスについても、ActiveModelSerializers側で決まったフォーマットで返すことができるようになります。
また、初見の印象として複雑なビジネス要件に対する柔軟性が低そうなイメージがあったのですが、実際には
- 1つのRails modelに対して、複数のserializer classを定義して、任意に使い分けることができる
- 例えばnamespaceごとに別のserializerにするなど
- controllerでincludeオプションを指定することで、定義されたアソシエーションのうち、どの範囲をレスポンスに含めるか、エンドポイントごとに任意に指定できる
このような使い方が可能で、jbuilderと比べて柔軟性が足りず要件を実現できないということもありませんでした。
逆に苦手なユースケースとしては、配列形式のJSONレスポンスの件数が非常に多い(1万件以上など)場合には、ActiveModelSerializersを普通に使うとパフォーマンスに問題があり、そのような大規模データを扱う要件がある場合には別の手段を検討する必要があるかも知れません。
エラー時のフォーマットの統一
render json: { message: e.message, code: e.class.to_s.split('::').last.underscore }, status: :bad_request
リファクタ前は準正常系のエラーハンドリングで返すレスポンスフォーマットもバラバラだったのですが、上のような形式に揃えました。エラーのclass名をコード値に変換しています。
バックエンドのエラーのレスポンスフォーマットが不定だった際には、それと関連してフロントエンドのエラーハンドリングもサーバー側のメッセージを用いていない部分があるなど、全体的にエラーハンドリングへの意識が低くなってしまっている問題がありました。エラーのレスポンスフォーマットを一定にして、フロントエンドでも決まった書き方でサーバーサイドのエラーメッセージを利用できるようにすることは、Webアプリ開発の基本的な部分として大事だと、リファクタ対応を通じて改めて認識しました。
リファクタ後どうなったか
一部の特殊なレスポンスフォーマットの既存エンドポイントを除いて、jbuilderをActiveModelSerializersに置き換えました。ActiveModelSerializersのメリットとして、レスポンスフォーマットが揃うことに加えて、serializerを変更するだけで複数のエンドポイントに跨る変更をまとめて行える実装コストの低減と、特定エンドポイントの変更反映漏れを回避する効果もありました。
エラーレスポンスのフォーマットの統一については、フロントエンドでサーバーサイドのメッセージを利用する対応と併せて各エンドポイントで変更対応を行いました。
おわりに
実際にはリファクタ対応の中で、DB設計の改善なども非常に重要な観点だったのですが、今回はブログ記事としての体裁上REST API周りの話題に絞って書きました。
リファクタ対応を通じて感じたのは、「プロダクトに一貫した設計方針があり、その根拠と共に開発チームに共有されていること」が大事だということです。仮にその方針がベストでなかったとしても、現状どういう方針になっているかと、その根拠があれば、後々チームで議論してその方針を変更することも可能だからです。
また、今回RailsのWebアプリのREST APIをどのように設計するかを詰めて考える過程で、個別のビジネス要件に対応する柔軟性や、フロントエンド連携のしやすさの観点で、GraphQLの利用が注目されている背景についても理解が深まった気がします。REST APIでエンドポイントごとに柔軟性を持たせる場合、多重実装や個別のオプション管理の煩雑さを一定許容する必要が出てくる印象です。
逆に言うと、REST APIの強みとしては、この記事に書いたように、Rails Wayとの相性が良く、Railsの機能を活用してシンプルに見通し良く実装できる点と、スキル感によらずWebエンジニアであれば誰でもREST APIの設計思想は一定理解していると思われる、共通前提としての優秀さが挙げられるのではないでしょうか。
この記事がRailsでのAPI実装の一助になれば幸いです。