見出し画像

Active Jobをレイヤードアーキテクチャにどう取り入れるか

※本記事は、PharmaX Advent Calendar 2022の 5日目の記事です。

PharmaX株式会社、エンジニアの江田(@asamuzakE)です。
普段はSM(セルフメディケーション)事業のLINE bot、管理画面の開発をしています。

今年は、PharmaXでアドベントカレンダーをやることになりました!
というわけで僕は、SM事業でRailsのActive Jobをレイヤードアーキテクチャにうまく取り入れるためにしている工夫を紹介しようと思います!


SM事業のアーキテクチャ

Active Jobの話の前に、まずSM事業のバックエンドアーキテクチャについてざっくり解説したいと思います。
Railsを採用しており、レイヤードアーキテクチャになっています。

基本的には業務ロジックをLogicに書き、その業務ロジックを実現するためのDBアクセスなどをRepositoryで行います。
また、複数の業務ロジックが合わさった大きな業務ロジックはCoordinatorに書き、Coordinatorから複数Logicを呼び出すことで実現しています。

ルールとして、

  • ControllerからはCoordinator、Logicのみを呼び出し可能

  • CoordinatorからはLogicのみを呼び出し可能

  • LogicからはRepositoryのみを呼び出し可能

  • Repositoryからはhas-aの関係にあるRepositoryのみ呼び出し可能

となっています。

Active Jobとは

Active JobはRailsでジョブやキューイングを扱うためのインターフェースです。アダプターとしてSidekiqを使用することにより、Railsとは別プロセスで非同期処理を行うことができます。

Railsのデフォルトではapp/jobs配下にファイルを作成し、Application Jobを継承したJobクラスを定義することになっています。

# app/jobs/hoge_job.rb
class HogeJob < ApplicationJob
  queue_as :hoge

  def perform()
    # 実行したい処理
  end
end

はじめはSM事業でもjobs配下にjobを追加していきました。
しかし、数が増えてくるにつれて課題が見つかりました。

jobs配下にJobクラスを作っていくと徐々にアーキテクチャ違反が起こるようになった

jobsディレクトリに多様な処理が混ざる

非同期処理は色々な場面で使用したくなります。
例えばリソースを食う大規模な処理を別プロセスで処理したい場合や、比較的時間のかかる処理を非同期にしてレスポンス速度を改善したい場合、失敗後にリトライをするような制御をしたい場合などです。

それに伴い、jobで実行する処理の単位も場面によってさまざまです。
例えば、サブスクの一括更新処理は決済する・配送を準備するなど複数の業務を扱うためCoordinatorの単位ですし、単純なLogテーブルのレコード追加などはRepositoryの単位です。

これらがすべてjobs配下に配置されていると、Jobの中でどの層の処理が使われているのかわかりません。

例えば以下のようなJobがあったとします。
app/jobs/extend_subscription_job.rb

名前からサブスクの更新をしていそうだということはわかりますが、これが大きな業務としてのサブスク更新(Coordinator)なのか、サブスクテーブルのレコードを更新するだけのものなのかは判断がつきません。


実際に起こったアーキテクチャ違反

以下のHogeCoordinatorのhoge_exampleメソッドを見ただけでは、Jobの中でどの層の処理が呼ばれているのかわからず、アーキテクチャが守られているのかわかりません。

class HogeCoordinator
 class << self
    
    def hoge_example
            # 業務処理を実行

            # 最後に非同期処理
      FugaJob.perform_later # これどこの層?
    end
  end
end


そして、hoge_exampleの処理を追っていくと以下のようになっており、実はJobクラスを経由してFugaCoordinatorのメソッドが呼ばれていました、みたいなことが起こったのです!
CoordinatorからCoordinatorを呼んでしまっているのでアーキテクチャ違反ですね……。

class FugaJob < ApplicationJob
  queue_as :fuga

  def perform()
    FugaCoordinator.fuga_example # 中ではCoordinatorの処理が呼ばれていた
  end
end
class FugaCoordinator
  class << self
      
    def fuga_example
      # jobで実行される処理
    end
  end
end


そこで

結論

次のようにしてみました!

class UserRepository
  # JobをCoordinator, Logic, Repository内に定義
  class UpdateDisplayNameJob < ApplicationJob
    queue_as :update_display_name

    def perform(user_id:, display_name:)
      UserRepository.update_display_name(user_id: user_id, display_name: display_name)
    end
  end

  class << self

    # ジョブで実行される処理
    def update_display_name(user_id:, display_name:)
      User.find(user_id).update!(display_name: display_name)
    end

    # 非同期で上記処理を呼び出したい場合はこの処理を呼ぶ
    def update_display_name_later(user_id:, display_name:)
      UpdateDisplayNameJob.perform_later(user_id: user_id, display_name: display_name)
    end
  end
end


ポイント① Jobの定義場所を各レイヤーの中にした

Jobクラスは、そのJobが呼び出す処理のあるCoordinator, Logic, Repositoryのクラス内に定義するようにしました。
これにより、Jobクラスの場所を見ればどの層の処理かわかるだけでなく、具体的な処理内容もそのファイル内をみることでわかります!


ポイント② Jobを定義元のクラスメソッドでラップした

ジョブで実行されるメソッドのすぐ下に、同じメソッド名_laterという名前のメソッドを定義し内部でJobを呼び出すようにします。
Jobを使用したい場合は上の層からこのメソッドを使用して呼び出します。

UserRepository.update_display_name_later(user_id: user_id, display_name: display_name)

以下のように使用するため、ぱっと見でLogicからRepositoryが呼び出されている(アーキテクチャが正しい)ことがわかります!

class UserLogic
  class << self

    # ジョブで実行される処理
    def update_display_name(user_id:, display_name:)
      UserRepository.update_display_name_later(user_id: user_id, display_name: display_name)
    end
  end
end

また、すぐ下に~laterメソッドが定義されている=非同期処理として呼ばれる可能性がある、ということなので非同期を考慮した処理にしなければならないことも一目でわかります!

# 下にlaterのメソッドが定義されているので、このメソッドは非同期で呼ばれる可能性がある
def update_display_name(user_id:, display_name:)
  User.find(user_id).update!(display_name: display_name)
end

def update_display_name_later(user_id:, display_name:)
  UpdateDisplayNameJob.perform_later(user_id: user_id, display_name: display_name)
end


ポイント③ 別ファイルから直接Jobクラスを呼び出した場合、どの層のJobかすぐにわかる

この書き方であっても、~laterメソッドを使用せずに直接外部からJobクラスを呼び出すことは可能です。(その時点で違反ではありますが、、)
しかし、外部クラスからJobを呼び出す場合、Jobが定義されているクラスのネームスペースが必要となるため、アーキテクチャ違反が起こったかどうかがすぐにわかります!

class HogeCoordinator
 class << self
    
    def hoge_example
            # 業務処理を実行
   
      # 定義したクラス名::Jobクラス
      FugaCoordinator::FugaJob.perform_later
    end
  end
end


最後に

今回はJobをレイヤードアーキテクチャに取り込むために模索した方法を紹介しました!個人的にはこの方式になってから非同期処理を含むソースコードの可読性が改善された感じがします。
この方法のデメリットとしてはJobクラスが多く定義されてくるとファイルの行数が多くなるということですかね。

Rubyは動的型付けなので、アーキテクチャの約束事は基本コードを書く人それぞれが気をつけることになります。
しかし、今回のようにアーキテクチャ違反の起こりにくい約束事を作ることで、コードを書く人が気を付けるべきことは結構減らせるのではないかと思いました!


PharmaXでは定期的にテックイベントをオンラインで開催しています!
2023年1月は、金融業界・花き業界・薬局業界という異なる業界でDX推進をリードするスタートアップ企業3社が集まり、それぞれのオペレーションを担っているドメインエキスパートとプロダクト開発チームがどのように連携しながらプロダクトや開発組織を作っているのかについてディスカッションします。
ぜひお気軽にご参加ください。


この記事が気に入ったらサポートをしてみませんか?