カスタムコップでCQSの運用を改善した話

こんにちは、YOUTRUST Webエンジニアの寺井(YOUTRUST/X)です。

今回は、RuboCopのカスタムコップを使って、YOUTRUST独自の開発ルールの運用を改善した話を書こうと思います。

YOUTRUSTで運用しているルール

先日行われたKaigi on Rails 2023で「Fat Modelを解消するためのCQRSアーキテクチャ」というタイトルで、YOUTRUSTで運用しているCQSアーキテクチャについて発表しました。

speakerdeck.com

発表後、懇親会やブログ記事などで「一般的なRailsの規約ではないYOUTRUST独自のルールをどのようにして運用しているのか?」という質問をいただきました。

しんくうさんのブログ記事より引用

改めて、YOUTRUSTで運用しているCQSに関するルールを簡単に説明すると、更新系において以下のような決まりを設けています。

  • ControllerからはUseCaseを呼び出す
  • UseCaseからはCommandを呼び出す

ControllerとUseCaseとCommandの階層構造

CQSに関するルールのこれまでと現状

しかし、rails newしたタイミングからCQSアーキテクチャを適用していたわけではないことや、サービスの成長につれて設計は常に見直しているという背景もあり、上記のルールに従っていない箇所もあります。(2024年1月時点)

ルールに従っていない箇所は、大きく次の3通りに分類できます。

  1. ControllerからUseCaseとCommandの呼び出しが行われておらず、直接ロジックがかかれている
  2. ControllerからUseCaseの呼び出しが行われておらず、Commandが呼び出されている
  3. Commandから別のCommandが呼び出されている

パターン1については、CQSを導入する前の初期に書かれたコードであることが多いです。

普段のスプリントの開発中で、UseCaseやCommandへの切り出しが行われていない箇所の機能実装をすることになった際は、まずUseCaseとCommandへの切り出しを行ってから機能の修正を行うことが当たり前の文化となっており、日頃からコードをより良くしていっています。

パターン2とパターン3については、「ControllerからはCommandを直接呼び出さず、UseCaseを間に挟む」「Commandから別のCommandの呼び出しは行わない」というルールを定めたのが比較的最近(2023年8月頃)であることが原因で、ルールに従っていない箇所が残っています。

これに対しては、人数がまだ少ない現在はコミュニケーションによって解決できていますが、今後のことを考えると仕組みで解決しておけると良さそうだと考えました。

カスタムコップによる解決

そこで、RuboCopのカスタムコップを使って、開発時の静的解析でチェックするようにしました。

具体的には、パターン2とパターン3の発生防止に役立つ計3つのカスタムコップを作成しました。

① Controller内のCommand呼び出しを禁止するカスタムコップ

まず、パターン2の状態のときに警告を行うカスタムコップを作成しました。

module RuboCop
  module Cop
    module Lint
      class NoCommandInvocationInController < Base
        MSG = "Don't invoke Command inside Controller".freeze

        def on_class(node) # クラスノードごとに呼び出される
          class_name_node, _base_class_node, _body_node = *node
          class_name = class_name_node.const_name # クラス名を取得

          if class_name&.end_with?('Controller') # 末尾がControllerの場合はCommandの呼び出しをチェック
            inspect_command_class(node)
          end
        end

        private

        def inspect_command_class(node)
          node.each_descendant(:const) do |descendant| # すべての子孫ノードをループ
            if descendant.const_name&.end_with?('Command') # 末尾がCommandの場合は警告を行う
              add_offense(descendant, message: MSG)
            end
          end
        end
      end
    end
  end
end

このカスタムコップでは、ASTのクラスノードごとに呼び出されるon_classメソッドでクラス名を取得し、クラス名の末尾がControllerである場合はinspect_command_classメソッドを実行してCommandの呼び出しが行われていないかを検査しています。

inspect_command_classメソッドでは、クラスノードのすべての子孫ノードに対して名前の末尾がCommandであるかどうかをチェックし、該当する場合は警告を行っています。

YOUTRUSTでは複雑なメタプログラミングは使っていないため、名前の末尾を見てノードを判別しています。

下記のようなソースコードの場合、カスタムコップによって警告が行われます。

class HogeController
  def create
    # Controllerから直接Commandを呼び出しているので、RuboCopによって警告される
    FugaCommand.run
  end
end

カスタムコップによる警告

② Command内のUseCase呼び出しを禁止するカスタムコップ

①と同じ方法で2つ目のカスタムコップを作成しました。

これは、Controller → UseCase → Command という流れのうち、UseCase ← Commandの逆流を防止するためのカスタムコップです。

module RuboCop
  module Cop
    module Lint
      class NoUseCaseInvocationInCommand < Base
        MSG = "Don't invoke UseCase inside Commands".freeze

        def on_class(node) # クラスノードごとに呼び出される
          class_name_node, _base_class_node, _body_node = *node
          class_name = class_name_node.const_name # クラス名を取得

          if class_name&.end_with?('Command') # 末尾がCommandの場合はCommandの呼び出しをチェック
            inspect_command_class(node)
          end
        end

        private

        def inspect_command_class(node)
          node.each_descendant(:const) do |descendant| # すべての子孫ノードをループ
            if descendant.const_name&.end_with?('UseCase') # 末尾がUseCaseの場合は警告を行う
              add_offense(descendant, message: MSG)
            end
          end
        end
      end
    end
  end
end

こちらのコードは、比較対象の文字列以外は①と同じなので説明は省略します。

③ Command内のCommand呼び出しを禁止するカスタムコップ

最後に、パターン3の状態のときに警告を行うカスタムコップを作成しました。

こちらは、①や②と同じ方法ではうまくいきませんでした。

その理由は、CommandではCommandモジュールをincludeしているため、単純にすべてのノードの名前をチェックしていくと別のCommandを呼び出していなくても誤った警告を行ってしまうためです。

# CommandはCommandモジュールをincludeしている
class HogeCommand
  include Command # Commandモジュールのincludeは違反ではないので警告したくない

  def run
    FugaCommand.run # 別Commandの呼び出しは違反なので警告したい
  end
end

そこで、Commandという定数を探すのではなく、Command.runを探す方針にしました。

module RuboCop
  module Cop
    module Lint
      class NoCommandInvocationInCommand < Base
        MSG = "Don't invoke `run` on other Commands inside Commands".freeze

        def on_send(node) # メソッド呼び出しごとに呼び出される
          receiver_node, method_name = *node # レシーバとメソッド名を取得
          # 呼び出されているメソッド名がrunのときかつ、レシーバが定数のときのみ処理を継続
          return unless method_name == :run
          return unless receiver_node && receiver_node.type == :const

          receiver_class_name = receiver_node.const_name # レシーバのクラス名を取得

          current_class_node = node.each_ancestor(:class, :module).first # 祖先ノードの中で最も近いクラス(またはモジュール)ノードを取得
          current_class_name = current_class_node&.identifier&.const_name # クラス名(またはモジュール名)を取得

          # レシーバクラス名の末尾がCommandである、かつ現在のノードの名前の末尾もCommandである場合は警告を行う
          if receiver_class_name&.end_with?('Command') && current_class_name&.end_with?('Command')
            add_offense(node, message: MSG)
          end
        end
      end
    end
  end
end

このカスタムコップでは、メソッド呼び出しごとに呼び出されるon_sendメソッドで、メソッドが呼び出されているレシーバとメソッド名を取得し、メソッド名がrunでレシーバクラスの末尾がCommandのときに警告を行います。

# current_class_nameとreceiver_class_nameの例
def HogeCommand
  def run
    FugaCommand.run
  end
end

上記のコードの場合、current_class_nameはHogeCommand、receiver_class_nameはFugaCommandとなります。

導入した結果どうなったか

これらのカスタムコップを導入した結果、いくつかの効果がありました。

1つ目は、ルールに違反したコードが新しく追加されることをなくすことができました。

実装した後、PRのレビュー時にルールとは違う書き方になっていることを指摘されて手戻りが発生するなどのケースも起きていません。

2つ目は、現在ルールに違反していて直すべきコードの量と位置を把握することができました。

今回紹介したカスタムコップを導入するときに、既存のコードで違反している箇所はrubocop_todo.rbに記載しました。

その結果、リファクタリングが必要な箇所がどこにどれくらいあるのかをファイルで管理し、全員が見れる状態にすることができました。

今はまだrubocop_todo.rbには未対応の箇所がたくさん残っていますが、KAIZEN Dayの時間などを使って少しずつリファクタリングを進めていきたいと思っています。

youtrust.jp

終わりに

今回は、RuboCopのカスタムコップを作成して、YOUTRUSTで採用しているCQSの運用を改善した話を紹介しました。

私自身、カスタムコップを作成したのは初めてだったのですが、新しい言語を学んでいるような新鮮な気持ちで開発することができました。

他にも静的解析に任せられるような改善アイデアがいくつかあるので、今後もカスタムコップを使った問題解決に挑戦していきたいです。

宣伝

最後に2つ宣伝させてください!

1つ目は、最近YOUTRUSTでは新卒やインターンのエンジニアポジションを新たにオープンしました!

未経験の方も募集対象ですので、興味がある方はぜひご応募ください!

(もちろん中途採用や業務委託も募集しています!)

herp.careers

herp.careers

2つ目は、コラボイベントの募集です!

YOUTRUSTではOPEN CODEというイベントを毎月行っており、コラボして一緒に開催してくださる企業を常に募集しています!

「OPEN CODEは厳しいけど他の形式でならコラボしてみたい」という企業も大歓迎ですので、こちらもご興味がある方はぜひDMでお待ちしております!

(下はmybestさんとのコラボ回です!) youtrust.connpass.com