らくしふのRailsAPIアプリケーション設計
らくしふのRailsAPIアプリケーション設計
らくしふのバックエンド実装にはRuby on Railsを採用しています。いくつかアプリケーションがありますが、今回はその中から最も歴史が長いメインアプリケーションの今現在において採用しているAPI実装のアプリケーション設計について記載します。
なお今回詳しくは触れませんが、大まかな開発の流れとしては、OpenAPIを使ってAPIインターフェースを定義して、フロントエンド用のクライアントコードを自動生成しつつAPI実装を行なっています。
この記事の前提条件
- Ruby on RailsによるAPI開発が対象です。よって
/app/views
/app/helpers
/app/decorators
などには触れません。 -
/app
ディレクトリ以外、例えば/db
や/lib
などには触れません。
API設計コンセプト
採用しているAPI設計のコンセプトは「リソースベースAPI」になります。
リソースを定義してエンドポイントとして表現する必要があるので、googleが公開している命名規則をベースにアレンジを加えています。
参考)
https://cloud.google.com/apis/design/naming_convention#method_names
また、リソースベースAPIではなく「画面ベースAPI設計」という考え方もあると思います。詳しくは触れませんがらくしふが提供している別サービスでこの考え方をベースにしたアプリケーションもありますので、また機会があれば紹介したいと思います。
アプリケーション設計の概要
HTTPリクエストを受け取ってHTTPレスポンスを返すまでの、大まかな流れは以下の通りです。
- ControllerがHTTPリクエストを受け取る。
- Parameterクラスでリクエストパラメータ(query string or request body)のバリデーションおよび整形を行う。
- 必要に応じてFinderクラスでActiveRecord_Relationを取得する。
- 必要に応じてServiceクラスでDB操作を行う。
- SerializerクラスでHTTPレスポンス定義して、クライアントに返す。
特にパフォーマンスを求められるAPIなどの例外はもちろんありますが、基本的には上記の流れに沿った設計になっています。各クラスの依存関係を整理すると以下の通りになります。
modelsへの依存が多く発生する(DDDにおけるいわゆるドメインオブジェクトを含んだものなので当然ですが)のがよく分かりますね。
大まかな流れを理解したところで、各ディレクトリの責務について書いていきます。
アプリケーション設計の詳細(各ディレクトリの責務)
/controllers
HTTPリクエストを受け取り他の各種クラスを操作して、HTTPレスポンスを返すまでが責務です。
Rails標準(?)の考え方では、1ファイルに複数アクションを定義します。現状のらくしふメインアプリもそうしています。いくつかサンプルコードを見ていきましょう。
GET /api/v1/resource_names
(特定リソースの一覧を返すAPI。Finderクラス未使用バージョン)
module Api::V1
class ResourceNamesController < ::ApiBaseController
def index
params = Api::V1::ResourceNames::IndexParameter.new(params).call
render json: ResourceName.where("paramsを使って検索する"), each_serializer: Api::V1::ResourceNames::IndexSerializer
end
end
end
エラーハンドリングを親クラスでincludeするconcernに集約するなどの工夫により、とてもシンプルになっています。次はFinderクラスを使った例です。
GET /api/v1/resource_names
(特定リソースの一覧を返すAPI。Finderクラス使用バージョン)
module Api::V1
class ResourceNamesController < ::ApiBaseController
def index
params = Api::V1::ResourceNames::IndexParameter.new(params).call
render json: ResourceNamesFinder.new("必要なparamsを渡す").call, each_serializer: Api::V1::ResourceNames::IndexSerializer
end
end
end
Finderクラスについては後述しますが、ほぼ同じ流れの実装になっています。最後にServiceクラスを使ってDB操作するAPIの例です。
POST /api/v1/resource_names
(特定リソースを作成するAPI)
module Api::V1
class ResourceNamesController < ::ApiBaseController
def create
params = Api::V1::ResourceNames::CreateParameter.new(params).call
ActiveRecord::Base.transaction do
ResourceNameCreateService.new("必要なparamsを渡す").call
end
render json: params.resouce_name, serializer: Api::V1::ResourceNames::CreateSerializer
end
end
end
Controllerの実装におけるポイントは以下の通りです。
- 複数アクションを1ファイルに記載するので、Controllerはとにかく薄くなるように意識します。今回紹介しているアーキテクチャに沿って行けば自然と薄くなる(ハズ)とは思っていますが…
- 同じリソースではあるがAPIを分ける場合は、適切なネームスペースを入れることでそれを表現する。違う機能だが扱うリソースが同じ場合に発生するケースです。
- 例)
api/v1/resource_names
とapi/v1/foo/resource_names
- ネームスペースは単数系の単語にしています。ぱっと見でリソースを表しているのかそうではないのかを見分けられるためです。
- 例)
- トランザクションに関しては基本的にControllerで張っています。
/parameters
次にParameterクラスです。基本的にはControllerから渡されたActionParameterインスタンスを使って必要なバリデーションを行うのが主な責務です。シンプルなサンプルコードは以下の通りです。
module Api::V1::ResourceNames
class IndexParameter < BaseParameter
attribute :foo_id, :integer
attribute :bar_id, :integer
with_options presence: true do
validates :foo_id
validates :bar_id
end
validate :validate_foo_id, :validate_bar_id
def initialize(params)
super(params.permit(self.class.attribute_names))
end
private
def validate_foo_id
errors.add(:bar_id, 'error message!') if 'foo_idを使ったバリデーションに失敗'
end
def validate_bar_id
errors.add(:bar_id, 'error message!') if 'bar_idを使ったバリデーションに失敗'
end
end
end
initialize内で params.permit(…)
しています。BaseParameterではActiveModel::ModelとActiveModel::Attributesをincludeしています。これによりピュアなRubyクラスにattributeなどを書けるようになります。
Parameterクラスの実装におけるポイントは以下の通りです。
- Controllerからparams(ActionParameterインスタンス)をそのまま渡す
- 意図的にControllerと密結合させている、という言い方ができると思います。これによってController側で
params.permit(…)
を書く必要がなくなり、結果的に見通しが良くなります。
- 意図的にControllerと密結合させている、という言い方ができると思います。これによってController側で
- バリデーションと整形だけを行う
- DB更新する操作はしないということです。これはServiceクラスの責務(またはControllerに直書きする)となります。
- DBの検索だけは許容しています。これはParameterクラスでバリデーションした値を使って同じクラス内でそのまま検索すると楽だから、という理由です。この処理が膨らむとParamterクラスが肥大化する原因になりやすいので、注意が必要ですね。
- 複数のAPIエンドポイントで共通化しない
- Parameterクラスが増えてくるとこれをやりたくなる気持ちが出てくることもありますが、「現在はたまたま同じだったとしても、APIが異なればリクエストパラメータは違うもの」と捉えた方が安全かなと思っています。
/finders
リクエストパラメータのバリデーションが済んだら、次に必要に応じてFinderクラスでDB検索を行います。とはいえFinderクラスは基本的にはあまり書きません。Modelにscopeを記述するかと思いますが、scopeに書くには複雑すぎる検索処理をFinderとして抜き出す、というイメージになります。
複雑の定義はやや微妙なところですが、
- 複数モデルにまたがった検索かどうか
- 何かしらのパタメータをもとに検索条件が変わるかどうか
- これらを複数書く必要があるかどうか
を基準に考えるとよいと思います。
サンプルコードは以下の通りです。
module
class ResourceNameFinder
def initialize(foo_id, bar_id)
@foo_id = foo_id
@bar_id = bar_id
end
def call
ResourceName.where(id: baz_ids)
end
private
def baz_ids
'@foo_idや@bar_idを使って複雑なwhereを書く'
end
end
end
Finderクラスの実装におけるポイントは以下の通りです。
- callメソッドのみをpublicにする
- 必ずActiveRecord_Relationを返す
- Finder内でResourceName.where(…).map などしないということです。
- これが崩れると、実装の柔軟性が損なわれることになります。Finderを呼んだ後にチェーンしてwhereを書けなくなる。SQLを発行するタイミングはあくまでもFinderの呼び出し元で制御できるようにするのが良いです。
- これはModelに記述するscopeにおいても全く同じことが言えます。Finder内からscopeを呼ぶことも全然あるので、そういった実装ができるようになるのも、常にActiveRecored_Relationを返すようにしているからこそです。
- 決してやりすぎない
- 必ずFinderを作るというルールのプロジェクトを昔見たことがありますが、控えめに言って地獄のようでした(笑)
- いわゆるRepositoryパターン的なものをRailsでやりたい気持ちは分からなくはないですが、ActiveRecordが持つ手軽さを捨てることになるのでやめた方がよいと思っています。(もはやRailsじゃなくていいのでは?と思ってしまいます)
/services
何かしらのDB変更操作を行う場合は、Controllerに直接書くかServiceクラスに書くかの二択になります。(場合によってはModelに書くこともあるかと思いますが)
あるいはDB変更操作は行わない複雑なロジックを書く場合、
- 特定Modelに書くには適切ではない
- かといってControllerに書くと肥大化するし、共通化できない
というときもServiceクラスの出番です。
ここまででお気づきかと思いますが、RailsアプリケーションにおけるServiceクラスは、油断するとただの何でも屋になってしまう可能性を秘めています。
あくまで雰囲気ですが、サンプルコードは以下の通りです。
class ResourceNameCreateService
include BaseService
def initialize(foo, bar)
@foo = foo
@bar = bar
end
def call
resource = build_resource_name
ActiveRecord::Base.transaction do
resource_name.save!
create_resource_name_log(resource)
end
resource
end
private
def build_resource_name
ResourceName.new('@fooや@barなどを使って処理を書く')
end
def create_resource_name_log(resoruce)
ResourceNameLog.create!('resourceを使って処理を書く')
end
end
Serviceクラスを乱用すると本来Modelに書くべき振る舞いをServiceにしてしまったりなど様々なリスクがあるので、最もエンジニアの腕の見せ所かと思っています。
/serializers
最後はAPIレスポンスの整形が責務のSerializerになります。まずそもそもなぜこのレイヤーが必要なのかについてですが、
- まず前提として、あるModelインスタンスの属性をそのままAPIレスポンスとして返すのは、セキュリティ的にもよろしくないし、パフォーマンス的に全部の属性を返したくない場合に困ってしまいます。
- となると属性の絞り込みをするレイヤーが必要になりますが、これをControllerやModelに書いてしまうと、それぞれがFatになる原因に直結します。
- よってこれを専門に行うレイヤーとしてSerializerを作ることが推奨されています。
といったところかなと思っています。Gemは色々選択肢がありますが、らくしふのメインアプリではActiveModelSerializerを採用しているので、その場合のサンプルになります。
module Api::V1::ResourceNames
class IndexSerializer < BaseSerializer
attributes %i(foo bar baz)
end
end
ActiveModelSerializerを使った実装のベストプラクティス的な話は長くなるので今回は割愛しますが、Gemに依存しないSerializerというレイヤーのポイントは以下の通りです。
- APIエンドポイントすなわちControllerのアクション毎に定義する
- アクション共通で定義してしまうと、片方のアクションでのみレスポンスを変えたい場合に困ってしまいます。
- パフォーマンスには常に注意する
- GemによってはRailsのassociation機能を使って手軽に関連モデルの属性も一緒に返すことができますが「該当APIにとって不要な属性まで返していないか」あるいは「どうしてもパフォーマンスが求められるAPIの場合はSerializerを省く」といった判断もありかと思います。
まとめ
らくしふのメインアプリで採用しているアプリケーションアーキテクチャについて、簡単ですが説明させていただきました。今回触れなかった要素として /app/validators や /app/values などもありますが、今回はAPIがリクエストを受けてレスポンスを返すまでの流れにフォーカスしました。
もちろん今回紹介したアーキテクチャが完璧だ!とは全く思っていませんが、少なくとも、
- 一定規模以上のコード量が既に存在するアプリケーションで、(過去のしがらみが存在)
- 一定人数以上のエンジニアが共通認識を持ってスピーディーに開発する
ということは達成できているかなと思っています。過去のしがらみについては、必要に応じてリファクタリングしながら徐々に今回紹介したアーキテクチャに寄せていっている、ということも少しずつ実践しています!
株式会社クロスビットでは、デスクレスワーカーのためのHR管理プラットフォームを開発しています。一緒に開発を行ってくれる各ポジションのエンジニアを募集中です。
Discussion