シンクロ・フード エンジニアブログ

飲食店ドットコムを運営する、株式会社シンクロ・フードの技術ブログです

Railsアプリケーションで使われているモデルをRSpecを使って自動的に機能ごと一覧化する

こんにちは。開発部の竹内です。
飲食店ドットコムモビマルといったサービスで Ruby on Rails での開発に携わっています。

Railsアプリケーションで使われているモデルをRSpecを使って自動的に機能ごと一覧化する対応を行いましたので、それについてお話ししたいと思います。

背景

コードを修正するにあたり、既存の機能に影響がないか・整合性があるかを調べるのは重要です。ですが私が所属する会員企画開発チームでは影響範囲の確認が漏れてバグや仕様上の不整合が発生することが多く、課題となっていました。
そのため機能ごとにどのモデルが使用されているかが分かるドキュメントを整備し、影響範囲の調査やサービスの理解に役立てようという提案がありました。ドキュメントは人力で作成していたのですが、作成に時間がかかりメンテナンスも大変であることから、自動的に出力する仕組みを作りたいと思いました。弊社には業務時間の10%でサービス改善のためにエンジニアの好きなことをできる制度があるので利用することにしました。

方針

Ruby の言語の特性上、静的に解析を行うのは難しそうです。
そのため動的に情報を得る方針を取りました。
弊社が運営しているモビマルでは Request Spec がほぼ全機能で書かれているのでこれを利用できないかと考えました。
カバレッジツールやプロファイラでも使用されている TracePoint を用いればメソッド呼び出しをフックできるので、これを記録すればどのモデルのメソッドが呼ばれているかが分かります。
また、どの機能が実行されているかは Active Support Instrumentation によって分かるためこれも利用することにしました。

実装

まず、TracePoint を使ってメソッドが呼ばれたクラスを記録するクラスを定義します。

# TracePoint を使ってメソッド呼び出すを記録するためのクラス
class Tracer
  attr_reader :used_classes

  # @param [Proc] filter_proc
  #  TracePoint で記録するメソッド呼び出しを絞り込むためのブロック
  #  引数に TracePoint を受け取り、true を返した場合のみ記録する
  def initialize(&filter_proc)
    @used_classes = Set.new
    @filter_proc = filter_proc || proc { true }
  end

  def trace(&block)
    tr = TracePoint.new(:call, :c_call) do |tp|
      next unless @filter_proc.call(tp)

      @used_classes << tp.self.class
      @used_classes << tp.self if tp.self.is_a?(Class) # クラスメソッドが呼ばれた場合
    rescue NoMethodError, ArgumentError # rubocop:disable Lint/SuppressedException
      # tp.self.class 取得時エラーになることがあるので、それは無視する
    end
    tr.enable(&block)
    self
  end
end

Tracer#trace をブロック付きで呼び出すと、ブロック中で呼ばれたメソッドのレシーバのクラス、またはレシーバがクラスの場合(クラスメソッドが呼ばれた場合)はレシーバ自身を @used_classes に記録します。
コンストラクタにブロックを渡すことができ、それによりクラスを記録するかどうかを判定します。こちらは後ほど説明します。

これを使ってアクションごとに使用されているモデルを取得します。

# frozen_string_literal: true

require 'csv'

MODEL_ANALYZER_TMP_FILE = 'coverage/model_analyzer_tmp.txt'
MODEL_ANALYZER_RESULT_FILE = 'coverage/model_analyzer.tsv'
MODEL_ANALYZER_EXCLUDED_CLASS_NAMES = Set.new([
  'ApplicationRecord',
])

RSpec.configure do |config|
  # テストスイート実行開始時、一時ファイルを削除する。存在しなければ何もしない
  config.before(:suite) do
    FileUtils.rm_f(MODEL_ANALYZER_TMP_FILE)
  end

  config.around do |ex|
    # controller の action が呼ばれた時にその controller と action を記録する
    controller = nil
    action = nil
    subscriber = ActiveSupport::Notifications.subscribe('process_action.action_controller') do |_, _, _, _, payload|
      controller = payload[:controller]
      action = payload[:action]
    end
    tracer = Tracer.new { RequestDurationMiddleware.in_request? }
    tracer.trace { ex.run }
    ActiveSupport::Notifications.unsubscribe(subscriber)

    # 使われたクラスを行ごとの JSON 形式で 一時ファイルに出力する
    # ディレクトリが存在しない場合は作成する
    FileUtils.mkdir_p(File.dirname(MODEL_ANALYZER_TMP_FILE))
    File.open(MODEL_ANALYZER_TMP_FILE, 'a') do |f|
      break unless controller && action

      f.puts JSON.generate({
        controller: controller,
        action: action,
        classes: tracer.used_classes.map { |klass|
          next if klass.name.nil? || MODEL_ANALYZER_EXCLUDED_CLASS_NAMES.include?(klass.name)

          source_location =
            begin
              Object.const_source_location(klass.name).first
            rescue NameError
              nil
            end

          # models 下で定義されたものが対象
          next unless source_location && source_location.start_with?('/mobimaru/rails/app/models/')

          klass.name
        }.compact,
      })
    end
  end
end

RSpec の config.around で、各ケース実行時に処理を追加します。
Active Support Instrumentation によりテスト対象のコントローラとアクションを取得します。
Tracer#trace に渡したブロック内でケースを実行し、使用されたクラスを記録します。
Tracer.new に渡しているブロック内の RequestDurationMiddleware.in_request? は実行タイミングがリクエスト中かを判定します。詳しくは後述します。
ケース実行後は、coverage/model_analyzer_tmp.txt にコントローラ・アクション・使用クラスの情報を行ごとの JSON 形式で追加します。この時 /mobimaru/rails/app/models/ 下以外のクラスやトップの ApplicationRecord は除外しています。

RequestDurationMiddleware の定義は以下となります。

# frozen_string_literal: true

# リクエスト中かを保持するためのミドルウェア
class RequestDurationMiddleware
  class << self
    def in_request?
      Thread.current[:request_duration_middleware_in_request]
    end
  end

  def initialize(app)
    @app = app
  end

  def call(env)
    self.in_request = true
    @app.call(env)
  ensure
    self.in_request = false
  end

  def in_request=(value)
    Thread.current[:request_duration_middleware_in_request] = value
  end
end

Rails.application.config.middleware.use RequestDurationMiddleware

Rack ミドルウェアでリクエスト中かを判定します。
RSpec のケース実行中には準備段階でのデータ作成などがあり、機能内で使われないモデルのメソッドが呼ばれることがあるので、除外するために行なっています。

全てのケース実行後は結果をファイルに出力します。

# ... 省略

RSpec.configure do |config|
  # ... 省略

  # テストスイート終了時、request spec で使用されたモデル一覧を集計する
  config.after(:suite) do
    controller_and_actions = {}

    File.open(MODEL_ANALYZER_TMP_FILE) do |f|
      f.each_line do |line|
        used = JSON.parse(line)
        key = [used['controller'], used['action']]
        (controller_and_actions[key] ||= Set.new).merge(used['classes'])
      end
    end

    # controller_and_actions から、| controller | action | used_classes(カンマ区切り) | の形式のTSVを作成する
    # controller と action の昇順でソートする
    File.open(MODEL_ANALYZER_RESULT_FILE, 'w') do |f|
      # ヘッダを出力
      f.puts(%w[controller action used_classes].to_csv(col_sep: "\t"))

      controller_and_actions.sort_by(&:first).each do |(controller, action), used_classes|
        f.puts([controller, action, used_classes.to_a.sort.join(',')].to_csv(col_sep: "\t"))
      end
    end
  end
end

一時ファイルに保存した内容を読み取った後、コントローラ・アクションごとに集計し、TSVで出力します。

実行

spec_helper.rbrequest_spec_model_analyzer_helper.rb を require し、RSpec を実行すると TSV が出力されます(一部抜粋)。

controller    action  used_classes
# ...
Home::MagazinesController   index   Contact,Magazine,MagazineCategory
# ...
Home::NewsController    show    Contact,News,NewsTarget,NewsTargetManage
# ...

まとめ

Request Spec を用い、機能ごとに使われているモデルを一覧化することができました。
これを使ってチーム内でドキュメントを作成するのに役立てています。
普段の Web 開発では馴染みの薄い TracePoint や Active Support Instrumentation を使うことができ、作っていて面白い取り組みになりました。
シンクロ・フードでは他にも自動化など改善の余地がある箇所がたくさんあるので、関わってみたい!という方をお待ちしています。