💎

らくしふのRailsAPIアプリケーション設計

2022/12/01に公開

らくしふの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レスポンスを返すまでの、大まかな流れは以下の通りです。

  1. ControllerがHTTPリクエストを受け取る。
  2. Parameterクラスでリクエストパラメータ(query string or request body)のバリデーションおよび整形を行う。
  3. 必要に応じてFinderクラスでActiveRecord_Relationを取得する。
  4. 必要に応じてServiceクラスでDB操作を行う。
  5. 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_namesapi/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(…) を書く必要がなくなり、結果的に見通しが良くなります。
  • バリデーションと整形だけを行う
    • 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管理プラットフォームを開発しています。一緒に開発を行ってくれる各ポジションのエンジニアを募集中です。

https://x-bit.co.jp/recruit/
https://herp.careers/v1/xbit
https://note.com/xbit_recruit

クロスビットテックブログ

Discussion