SmartHRでは開発にRuby on Railsを広く採用しています。
今日は負債解消のために、開発しているサービスでRailsのモデル名をすべて変更した話を紹介します。
既存のモデル構造のつらみ
私達が開発しているサービスでは、モデルの親子構造が分かりやすいということで、モデルをネストした構造にしていました。
例えば、 User
に紐づくプロフィール画像 User::ProfileImage
は、 app/models/user/profile_image.rb
に配置する具合です。
パッと見の構造が分かりやすいのですが、時が経つにつれて次のようなつらさが顕在化してきました。
- Railsの規約(推奨ルールのようなもの)に則っていないので、関連定義が冗長になる
- テーブル名が長くなる。
- 外部キーや関連名が長くなる。
- 関連名と外部キー名が一致せず、カラムを呼び出したいときにDB定義を見ないとカラム名分からない
- 親子構造を表しづらい中間テーブルが無理な命名規則になって直感的ではない
さらに、開発が進んで深いネストのモデルが登場するにつれて、段々と開発メンバーの間で「この構造は厳しい...。なんだか脳が疲れるぞ...。」という空気が漂っていました。
そして、ある時事件は起きました。
世界で我々だけが踏んだRailsのバグ
ネストしたモデルで呼び出していた group + count
のクエリが不自然な挙動をしていることに気づきました。
HogeModel.group( :toooooooo_long_a_id, :toooooooo_long_b_id ).count #=> 挙動がおかしい
調査を進めるうちに分かったのですが、これは長すぎるカラムに対して group + count
した時だけに発生するRailsのバグでした。
調べてもrails/railsにissueは立っていません。
どうやら我々は、不自然なモデル構造にしてしまったばかりに、世界の誰も踏んだことがないであろうバグを発生させてしまったようです。
思えば、普段の開発でもテーブル名の長さがPostgreSQLの上限に引っかかったり、様々な事情を考慮して開発をする必要あったりと、ネストしたモデル構造によって苦慮していたなと、チームが再認識しました。
おそらく世界で我々だけが、無駄な領域で苦しんでいます。つらい、つらすぎる!
この不具合を大きな教訓として、我々のチームはモデル構造の見直しに取り掛かることにしました。
(なお、このバグは社内勉強会で修正されました。 rails/rails#46287 )
CoC(設定より規約)改善のロードマップ
既存のネストしたモデルは、チームでの議論の末に app/models
直下に再配置することに決まりました。
およそ60ファイル程度のモデルを対象として、改善プロジェクトがスタートです。
改善プロジェクトは、次の手順で進行することになりました。
- 新しいモデル名を決める
- ネストしているモデル群を、一括で
app/models
直下に移動する - 関連操作を解析・置換する仕組みを作って、関連名を置換していく
続けて、具体的な進め方を紹介します。
1. 新しいモデル名を決める
Google Sheetsにモデル名を転記して、チームで議論しながら新しいモデル名を決めました。
モザイクがかかっているところに実際のモデル名が記載されているのですが、おおよそのRailsプロジェクトで目にすることがない文字長であることをお分かりいただけると思います。
2. モデルを一括で app/models
直下に移動する
先ほどのGoogle Sheetsを元に、Rubyスクリプトを書いて一括で置換します。 この作業は、簡単なファイル操作と文字列操作で完了しました。
git mv
でapp/models
直下に移動するself.table_name = "xxx"
を各モデルの先頭に追記する- 関連定義(
belongs_to/has_one/has_many
)にclass_name: "新しいクラス名"
を追記する git grep '[[:<:]]古いモデル名[[:>:]]'
で探索して、見つかった箇所を新しいモデル名で置換する。(記号は、単語の先頭・終端を表しています。)
この作業はスムーズに完了できました。
3. 関連操作を解析・置換する仕組みを作って、関連名を置換していく
続いて、古いモデル名のままになっている関連定義や関連名を、新しいモデル名のCoCに沿うように直していきます。
ActiveRecordでは関連名を様々な箇所で利用しており、これらの多彩な利用シーンを考慮する必要があります。
belongs_to/has_one/has_many
による関連定義preload/where/joins...etc
などのクエリ系メソッドに渡す関連名user.items/user.build_profile
のような関連名メソッドを使った呼び出し
これらを機械的に置換するために、メソッドの利用箇所を収集して、その情報を元にコードを置換する仕組みを作ることにしました。
3-1. CI上で関連を利用したメソッド呼び出しを収集する
関連名を利用したメソッド呼び出しの情報を集めるスクリプトを作成して、CI上で情報を収集します。
TracePointを使って、関連名を利用したメソッド呼び出しを片っ端から集めて解析していきます。
3-2. has_one/has_many/belongs_to
を使った関連定義
has_one/has_many/belongs_to
の定義は、モデルを読み込めば実行されるので解析が楽ちんです。
細かい点は省略していますが、おおむね以下のスクリプトで情報を集めることができました。
MethodDumper
はTracePointをwrapして、特定のメソッドだけ反応するようにしている独自クラスです。
# ./bin/rails r this_file.rb dumper = MethodDumper.new do Rails.application.reloader.reload! Rails.application.eager_load! end # メソッド呼び出しをdumpするメソッド群を定義する dumper.add_method_trace("ActiveRecord::Associations::ClassMethods.has_one") dumper.add_method_trace("ActiveRecord::Associations::ClassMethods.has_many") dumper.add_method_trace("ActiveRecord::Associations::ClassMethods.belongs_to") dumper.run do |row| trace_point = row[:trace_point] pp race_point.send(:caller) # do something end
3-3. user.items/user.build_profile
のような関連名を使ったメソッド
続いて、関連名を利用したメソッド呼び出しの情報を収集します。
MethodDumperの対象となるメソッドを関連名で指定して、 MethodDumper
にRSpecの実行を解析させることで関連名のメソッド呼び出しを収集することができました。
dumper = MethodDumper.new do run_rspec end Rails.application.eager_load! # 関連名を使っているメソッド群を解析対象とする klasses = ApplicationRecord.descendants.reject(&:abstract_class?) klasses.each do |klass| klass.reflections.each do |reflection_name, reflection| if reflection.belongs_to? || reflection.has_one? dumper.add_method_trace("#{klass.generated_association_methods}#reload_#{reflection_name}") dumper.add_method_trace("#{klass.generated_association_methods}#build_#{reflection_name}") dumper.add_method_trace("#{klass.generated_association_methods}#create_#{reflection_name}") dumper.add_method_trace("#{klass.generated_association_methods}#create_#{reflection_name}!") end dumper.add_method_trace("#{klass.generated_association_methods}##{reflection_name}") dumper.add_method_trace("#{klass.generated_association_methods}##{reflection_name}=") end end dumper.run do |row| trace_point = row[:trace_point] pp(trace_point.send(:caller)) # do something end
3-4. joins/where/includes...
のようなメソッドの呼び出し元と、引数
最後に、クエリ系メソッド(joins/where/includes/preload/find_by/create_by...他多数
)の収集処理を作っていきます。
これらは呼び出し方が多彩なので、なかなか厄介です。
以下は抜粋したコードですが、メソッドごとに解析処理を分岐させるマッチョ実装で情報を集めることにしました。
dumper = MethodDumper.new { ... } dumper.add_method_trace("ActiveRecord::QueryMethods#joins") dumper.add_method_trace("ActiveRecord::QueryMethods#joins!") dumper.run do |row| reflections = ReflectionExtractor.new(row[:trace_point], row[:method_name]).extract_reflections pp reflections end # ./reflection_extractor.rb class ReflectionExtractor attr_reader :trace_point attr_reader :method_name def initialize(trace_point, method_name) @trace_point = trace_point @method_name = method_name end def extract_reflections case @method_name when "ActiveRecord::QueryMethods#joins" args = trace_point.binding.local_variable_get(:args) collect_reflections_from_joins_clause(trace_point.self, *args) ... end end private def flat_arguments(args, collector = []) case args when Array args.each { flat_arguments(_1, collector) } when Hash args.each do |key, value| collector << key flat_arguments(value, collector) end when Symbol, String collector << args else raise NotImplementedError, "not implemented yet #{args.class}" end collector end def collect_reflections_from_joins_clause(receiver, *args) associations = args candidates = flat_arguments(args) join_dependencies = [] join_dependencies.unshift( receiver.construct_join_dependency( receiver.send(:select_association_list, associations, join_dependencies), nil ), ) join_dependencies.flat_map(&:reflections).uniq.filter_map do |reflection| [reflection.active_record.name, reflection.name] if candidates.include?(reflection.name) end end end
また、where/find系のメソッドは where(user_profile: { id: 1 })
のように関連を指定して呼び出すこともできます。
これらのメソッドの解析は、ActiveRecordの内部で使っている処理を上書きすることで情報を収集する仕組みにしました。
以下、その辺りの処理を抜粋したコードです。
ActiveRecord::TableMetadata.singleton_class.prepend(Module.new do def collect_reflections self.enabled_reflections_collector = true self.reflections = [] yield reflections ensure self.enabled_reflections_collector = false end end) ActiveRecord::TableMetadata.prepend(Module.new do def associated_table(table_name) if self.class.enabled_reflections_collector reflection = klass._reflect_on_association(table_name) || klass._reflect_on_association(table_name.singularize) self.class.reflections << [klass.name, table_name.to_sym] if reflection end super end end) class ReflectionExtractor ... def extract_reflections case @method_name.remove(/!$/) ... when "ActiveRecord::QueryMethods#find_or_create_by" attributes = trace_point.binding.local_variable_get(:attributes) collect_reflections_from_where_clause(trace_point.self, attributes) ... end def collect_reflections_from_where_clause(receiver, *args) if args.empty? || (args.length == 1 && args.first.blank?) [] else opts, *rest = *args ActiveRecord::TableMetadata.collect_reflections do receiver.send(:build_where_clause, opts, rest) end end end end
3-5. 置換用のパッチ生成
CI上で関連の呼び出し箇所を収集できたので、続いて関連の置換処理を作っていきます。
着手前は、コードの一部を書き換えた文字列から git diff
でパッチを大量に生成して、git apply
でまとめてパッチを適用すればいいと思っていました。
しかし、収集したのはメソッド呼び出し単位の情報なので、次の例のように複数のメソッドが同一行で呼ばれていると、joins
と where
それぞれでパッチを生成することになります。
User.joins(...).where(...)
残念ながら git diff
は行単位のパッチを生成するので、同一行に対して2つのパッチを適用するとコンフリクトします。
うーん、困りました。
何度も解析・パッチを当てれば解決しますが面倒です。代わりに、たくさんのパッチを1パッチに結合する方法を考えました。
収集したメソッドごとにコードを書き換える
まずは、コード上の関連名を書き換える文字列操作を作っていきます。 文字列の書き換え処理は RubyVM::AbstractSyntaxTree や parser をゴリゴリ使って行いました。
これは面白かったですが、説明が長くなるので省略します。
パッチを作る
コードの一部分を変更した文字列を、パッチファイルに変換する処理を書いていきます。
git diff
にお任せしちゃいます。
path = ... new_content = replace_reflection(File.read(path)) tempfile = build_tempfile(new_content) relative_path = Pathname.new(path).relative_path_from(Rails.root).to_s patch = `git diff --unified=0 --no-index --no-color #{path} #{tempfile.path}` patch.gsub!(path) { "/#{relative_path}" } patch.gsub!(tempfile.path) { "/#{relative_path}" } File.write('patches/xxx.patch', patch)
--unified=0
は、パッチファイルに含める前後行の行数を指定するオプションです。git apply
でパッチをあてる際に、gitは変更行を特定するために前後行の情報を見て探索します。前後行は他のパッチで書き換わる可能性があるので、0行を指定します。
--no-index
は、git管理外のファイルと差分を取ることを許可するオプションです。
これでパッチファイルを大量生成することができました。
パッチを結合する
同一行のパッチがコンフリクトしないように、パッチを可能な限り結合していきます。
まずはgit diff
で生成したパッチをパースします。手頃なgemが見つからなかったので自作しました。
GitDiff
次に、比較アルゴリズムを考えます。 結合も比較もアルゴリズムを全然知らないので、ググって見つかったdiff-match-patchを利用することにしました。こちらもrubygemsに公開されていたgemは壊れていたので、修正して利用します。alpaca-tc/diff_match_patch
最後に、パッチの結合処理を作っていきます。 あまり面白くないのですが、ナイーブに書いたら動いたのでこれで大量のパッチを結合しました。 GitDiffMerger
あとは、それぞれのパッチを git apply
して、関連名の置換は完了しました。
# パッチを可能な限り結合 diffs = Dir["./patches/*.patch"].map { GitDiff.parse(File.read(_1)) } group_files = diffs.flat_map(&:files).group_by(&:old_path) group_files.each do |path, files| files = GitDiffMerger.new(files).compact_files git_diff = GitDiff.new(files:) File.write("#{__dir__}/patches/combined_patch_#{SecureRandom.hex}.patch", git_diff.to_s) end # パッチを適用 Dir["./patches/combined_*.patch"].each { `git apply --unidiff-zero #{_1}` }
終わりに
ざっと書きましたが、これらの取り組みを通じてアプリケーションコードを機械的に置換することができました。 (実際には、それなりの手作業もありましたが。)
ようやく我々のチームも普通のモデル構造になり、生産性を低下させていた要因を解消できて嬉しいです。
また、この取り組みを通じてTracePointの知見が溜まったのは嬉しい副産物でした。
動的言語であっても影響範囲の調査や破壊的変更が容易になる可能性があり、今後も大規模な改修で活用していこうと思います。
最後に、変更にかかったPR数などを載せておきます。
PR数 54 commits数 1,100 追加行数 61,039 削除行数 23,058
みなさま、Railsは普通にCoCに則って使いましょうね!約束ですよ!