こんにちは。開発部の竹内です。
飲食店ドットコムやモビマルといったサービスで 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.rb
で request_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 を使うことができ、作っていて面白い取り組みになりました。
シンクロ・フードでは他にも自動化など改善の余地がある箇所がたくさんあるので、関わってみたい!という方をお待ちしています。