Rails on YOUTRUST <ロジックどこ置く?編>

こんにちは、YOUTRUSTのやまでぃ(YOUTRUST/Twitter)です。

前回の記事より約4ヶ月振りの登場です。 前回の記事ではたくさんの反響ありがとうございました。まだ未読の方は是非読んでみてください。(スケーラブルなリスティングロジックについてです)

最近のわたくし事ですが

ONE PIECEの連載が最終章に突入して熱いとの噂をキャッチし、8月に入ってから漫画を最初から全巻読み直しています。 僕のマイサウナである国立温泉 湯楽の里に全巻置いてあり、毎回1000円弱でサウナに水風呂に漫画まで読めて最高です。深夜1時まで営業しており、最近土日の大半はここにいます。(何なら平日も昼間からいるときも?)

マイサウナの湯楽の里。食堂で毎回サバ注文してます。

今回は何書くの?

Railsのロジックのクラス分けについて書きます。

弊社ではキャリアSNSとHR SaaSの2つのプロダクトを提供しており、共にサーバーサイドはRuby on Railsにて開発・運用しております。

今なお根強い人気のRuby on Railsは、そのお作法に乗る(on Rails)ことにより、特に初期の段階では素晴らしい生産性を生み出してくれるフルスタックWebフレームワークですが、何も考えずにサービスの運用を続けていると段々辛くなってきます。

そう、Fat Model(Controller)問題ですね!

想定外の仕様変更や度重なる機能追加や試行錯誤のためのロジックを、目の前にあるModelやControllerに「後で直す…後で直す…」とつぶやきながら継ぎ足していった結果生まれてしまう、密結合で見通しが悪くテストも書きづらい悲しきスパゲッティコード

どうしてこうなったのか分からないまま、取り敢えず精神削って頑張って読み込んで変更してみたものの、これで良いのかイマイチ安心できない、本番に反映しても大丈夫なものだろうか?もしバグが起きてしまっていたら?未来の仲間に申し訳ない等と不安で眠れない夜もあったのではないでしょうか。

そんな経験ありませんか?

僕はあります。

そこで、今回の記事ではYOUTRUSTにおけるRailsの使い方<ロジックどこ置く?編>をご紹介します。

もし「そんなことは無い」というスーパーなエンジニアの方でしたら、もうこの記事の続きは読まなくて大丈夫なので、こちらに進んでください。

難しそうなので結論から言って?

YOUTRUSTではロジックを下記のように用途別にクラスを分けています。(2022年9月現在)

  • 初期の段階
    • Controller or Model
  • 最近のYOUTRUST
    • 更新系ロジック→Command (app/commands/)
    • 参照系ロジック→Query (app/queries/)
    • 通知系ロジック→Model (app/models/notifications/)
    • ロジックを組み合わせるもの→UseCase (app/use_cases/)

ここですべてを悟ったスーパーなエンジニアの方も続きを読まなくて大丈夫なので、こちらにお進みください。

ふ〜ん、てかそもそもRails on YOUTRUSTの規模ってどれくらいなの?

サービスのリリースは2018年4月20日。運用歴4年と4ヶ月以上になります。

ディレクトリ内のクラス数(2022年9月現在)は以下の通りです。

  • Controllers: 258
  • Models: 347
    • ちなみにDBテーブル数は168
  • Commands: 137
  • Queries: 34
  • UseCases: 5

※まだControllerやModelがFatになっている箇所があり、完全にCommandやUseCase層に移行しきっている訳ではありません。

rails statsの結果

本題だよ

YOUTRUSTではロジックを用途別に下記のように分類して、ディレクトリを分けて管理しています(再掲)

  • 更新系ロジック→Command (app/commands/)
  • 参照系ロジック→Query (app/queries/)
  • 通知系ロジック→Model (app/models/notifications/)
    • 弊サービスにおいて通知は重大な関心事なので app/notifications/ に移行することを検討していたりします。
  • ロジックを組み合わせるもの→UseCase (app/use_cases/)

それぞれの層の依存関係としては下記の通りです。

  • Controller→UseCase、Query、Command
    • 処理内容の複雑度に応じてUseCaseを作ったり作らなかったり、直接Commandを呼んだりと、少しゆる〜くしています(ガチガチにやりすぎると疲れちゃうからね)
  • UseCase→Command、通知Model
  • Query→(他の)Query
  • Command→なし(末端)

こんなん見せられてもよーわからんってなると思うので、以降は具体的な雰囲気重視のサンプルコードと一緒に各層の詳細をご紹介していきます。

※雰囲気を伝えることが目的なので、定義されていないメソッドが突然現れたりします。ご注意ください。

Controller(/app/controllers/

HTTPの世界とアプリケーションの世界の境界に立つようなイメージの層です。 ここにはあまりロジックを書かずに、UseCaseやQueryやCommandを呼び出します。

基本的には下記の形になります。

  • 更新系アクション:UseCaseを呼び、結果に応じてステータスコードを出し分ける。
  • 参照系アクション:Queryを活用してデータをかき集めてviewに渡す。

※ 「基本的には」とある通り、「更新系アクションには必ずUseCaseを用意する!」等の厳密なルールのもと運用はしていないです。極端な話 create! するだけなら、わざわざUseCase等書かずにControllerにそのまま書いちゃって良いと思います。

class FriendRequestsController < Api::ApplicationController
  def index
    @pending_friend_requests = PendingFriendRequestQuery.run(
      current_user: current_user,
      limit: param_limit,
    )
  end

  def show
    @friend_request = FriendRequest.find(params[:id])
  end

  def update
    friend_request = FriendRequest.find(params[:id])

    result = AcceptFriendRequestUseCase.run(
      operation_user: current_user,
      friend_request: friend_request,
    )

    if result.success?
      @friend_request = result.friend_request
    else
      head :bad_request
    end
  end

  private

  def param_limit
    params[:limit].to_i 
  end
end

UseCase(/app/use_cases/

一連の更新系ロジックや通知ロジックなどを組み合わせて呼び出すものです。 UseCase自体にはあまりロジックはかかず、基本的にはCommandや通知Modelを呼び出すことに専念します。 他にはDBトランザクション管理や排他制御の責務も持たせています。

class AcceptFriendRequestUseCase
  attr_reader :operation_user, :friend_request

  def run
    friend_request.with_lock do
      accept_friend_request!
    end

    if success?
      send_notification
    else
      errors.add(:friend_request, :invalid)
    end
  end

  def initialize(operation_user:, friend_request:)
    @operation_user = operation_user
    @friend_request = friend_request
  end

  private

  def accept_friend_request!
    AcceptFriendRequestCommand.run(
      operation_user: operation_user,
      friend_request: friend_request,
    )
  end

  def send_notification
    NotificationJob.perform_later(
      'accept_friend_request',
      friend_request: friend_request,
      operation_user: operation_user,
    )
  end
end

Command(/app/commands/

更新ロジックの置き場です。

例えばつながり申請関連の更新系処理には「送信時」「承認時」「拒否時」等がありますが、これらにはそれぞれ異なるバリデーションや付随する処理があります。 これらを一つのModel( app/models/friend_request.rb )に全部書いていたらFatになってしまいます

そこで、それぞれのケースをCommandに切り出すことによって見通しを良くしましょうや、という流れで生まれたのがCommand層です。

class AcceptFriendRequestCommand
  attr_reader :operation_user, :friend_request, :from_user, :to_user

  validate :validate_friend_request
  validate :validate_operation_user

  def run
    return if invalid?

    friend_request.status_accepted!

    become_friends
    delete_friend_candidates
  end

  def initialize(operation_user:, friend_request:)
    @operation_user = operation_user
    @friend_request = friend_request
    @from_user = friend_request.from_user
    @to_user = friend_request.to_user
  end

  private

  def become_friends
    UserFriend.create!(user: from_user, friend_user: to_user)
    UserFriend.create!(user: to_user, friend_user: from_user)
  end

  def delete_friend_candidates
    FriendCandidate.where(user: from_user, candidate_user: to_user).delete_all
    FriendCandidate.where(user: to_user, candidate_user: from_user).delete_all
  end

  def validate_friend_request
    errors.add(:friend_request, :invalid) unless friend_request.status_pending?
  end

  def validate_operation_user
    errors.add(:operation_user, :invalid) unless to_user == operation_user
  end
end

Query(/app/queries/

参照系のロジックの置き場です。

ここでもしかしたら参照系ロジックって何?と思われるかもしれません。 まさか User.all のためにわざわざQueryなんて作らないよね? User.where(created_at: 10.days.ago).limit(100) はQuery作る?どこからどこまで?という疑問の声が聞こえてきそうですが、正直ここは「複雑だと思ったらQueryに移そう」くらいな感じで運用しています。

別にActiveRecordクラスにちょっとwhereとかlimitが付いたくらいのものは、わざわざQueryを作らずにControllerに直接書いちゃって良いと思います。見通しが悪くないんだったら。

「最近Contorollerがちょっと太ってきたかな?」とか思ってきたら、そのときにQueryなり何かしらの打ち手を検討すれば良いと思います。

class SearchUsersQuery
  attr_reader :current_user, :sort_type, :keyword

  def run
    filter_user_ids(
      sort_user_ids(
        fetch_population_user_ids
      )
    )
  end

  def initialize(current_user:, sort_type:, keyword:)
    @current_user = current_user
    @sort_type = sort_type
    @keyword = keyword
  end

  private

  def fetch_population_user_ids
    current_user.friend_user_ids + current_user.friend_of_friend_user_ids    
  end

  def sort_user_ids(user_ids)
    SortUserIdsQuery.run(user_ids: user_ids, sort_type: sort_type)
  end

  def filter_user_ids(user_ids)
    FilterUserIdsQuery.run(user_ids: user_ids, keyword: keyword)
  end
end

Notification(/app/models/notifications/

通知系ロジックの置き場です。

基本的に通知ロジックは非同期ジョブ(ActiveJob)経由で呼び出すようにしています。 ものによっては通知対象の計算や配信に時間がかかるので、APIレスポンスが遅延しないようにするためです。

通知ロジック内のディレクトリ構成は下記のようになっています。

  • app/models/notifications/: 直下のクラスがジョブから直接呼ばれる。
    • email_notifications/: 各メール通知の詳細
      • accept_friend_request.rb: つながり申請承認時のメール通知の詳細
      • ...
    • push_notifications/: 各Push通知の詳細
      • accept_friend_request.rb: つながり申請承認時のPush通知の詳細
      • ...
    • slack_notifications/: 各Slack通知の詳細
    • user_notifications/: 各サービス内通知の詳細
    • accept_friend_request.rb: つながり申請承認に関する通知ロジック
    • ...
class Notifications::AcceptFriendRequest
  attr_reader :friend_request, :operation_user, :to_user

  def run
    if !to_user.disabled_email_notifications?(:accept_friend_request)
      send_email_notification(to_user)
    end

    if !to_user.disabled_push_notifications?(:accept_friend_request)
      send_push_notification(to_user)
    end
  end

  def initialize(friend_request:, :operation_user:)
    @friend_request = friend_request
    @operation_user = operation_user
    @to_user = friend_request.to_user
  end

  private

  def send_email_notification
    Notifications::EmailNotifications::AcceptFriendRequest.run(
      friend_request: friend_request,
    )
  end

  def send_push_notification
    Notifications::PushNotifications::AcceptFriendRequst.run(
      friend_request: friend_request,
    )
  end
end

終わりだよ

YOUTRUSTでのRailsの使い方<ロジックどこ置く?編>はいかがだったでしょうか? まだまだ会社もプロダクトも試行錯誤の日々で、我々の未完成なRailsのノウハウではありますが、何か皆さんのRailsライフの参考になる所があればうれしいです。

ここまで読んで完全に理解されたエンジニアの方は是非こちらも併せてお読みになってくださいね。

それでは、また次回の記事でお会いいたしましょう!またね!

Special Thanks

最後に2点。

  1. 先日弊ブログにて発表した通り、株式会社YOUTRUSTは「Kaigi on Rails 2022」の最上位のRubyスポンサーとして協賛させていただきました!
    • 前職を含めると約8年もお世話になっている偉大なプロダクトに少しでも貢献できたのかなとうれしい思いです。
  2. こちらの記事は僕が社内でマジで15回くらいシェアしているものなので、是非こちらもご覧になってみてください(大事なことは何十回でも発信しないとね)

弊Slackの検索結果(一部抜粋)