Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

約9年開発されている Rails アプリケーションを 6.1 から 7.0 へメジャーバージョンアップする

こんにちは クラウド経費開発チームクラウド債務支払開発チーム の 宮村(みやむー) @miyamura.koyo です。

先日、約9年開発されている Rails アプリケーションである、クラウド経費とクラウド債務支払の Rails バージョンを 6.1 から 7.0 へメジャーバージョンアップしました。

Ruby on Rails の EOL

※ 本記事では Ruby on Rails の EOL を 「Ruby on Rails が公開している各バージョンごとの Security Issues のサポート期限」と定義します。

Ruby on Rails の サポートは、シリーズの最初のリリースから2年間のポリシーで運用されています。

より具体的には Ruby on Rails の EOL は以下のように示されています。

rubyonrails.org

「遅くとも上記期限までには対応しなければ!」ということで某日からアップデートのプロジェクトを開始しました。

※ なおネタバレですがクラウド経費・債務支払は EOL 以前にアップデート完了しています!

バージョンアップの進め方

まずは単体テストを通過させました。

Gemfile をアップデートした際、 11,500 件以上のテストが落ちました(笑)。これを一つ一つ潰していくところから作業を開始しました。

その後、開発環境で自動化された E2E テストを通過させました。

一部 E2E テストでカバーしきれないところは手動テストを行い、最終的にリリースすることができました。

やったこと

ここでは Rails バージョンアップまでに対応したことをいくつか紹介します。

1. Zeitwerk に対応する

Rails 7.0 からはオートローダーに変更があります。

従来の classic ローダーが廃止され、新しい zeitwerk ローダーに移行する必要があります。

Rails 6 系から移行することができるので、ガイドに従って対応を行います。

railsguides.jp

以下のコマンドが通過すれば対応完了です!

$ bin/rails zeitwerk:check
Hold on, I am eager loading the application.
All is good!

2. bin/rails app:update を実行して必要なものをコミット

何はともあれ bin/rails app:update を実行して必要なものを精査してコミットします。

この時 config.load_defaults についてはバージョンアップのリリース後に対応することをオススメします。

詳しくは以下の記事を参考にしてください。

moneyforward-dev.jp

3. 依存ライブラリ の対応

いくつかの依存ライブラリが Rails 7.0 に対応してないケースがありました。

この問題については、ライブラリのバージョンを上げたり依存解消したりすることで対応しました。

※ あまりしない方がいいですが、モンキーパッチすることで一時的に対応するという方法もあります。

3-1. default_value_for

以前執筆しましたが、 default_value_for という gem の対応が必要でした。

一例として以下のようなエラーが出ます。

ArgumentError:
  wrong number of arguments (given 2, expected 0..1)

現時点では Rails 7.2 をサポートしたバージョンがリリースされているので、そちらにアップデートするか、もしくは Rails の標準機能に置き換えて削除するかの2択です。

削除する場合は手前味噌ですが、以下の記事をご参照ください。

moneyforward-dev.jp

3-2. validate_timeliness

validate_timeliness という gem があります。

github.com

こちらで以下のようなエラーが発生していました。

Failure/Error: it { is_expected.to allow_value('hoge').for(:some_column) }

ArgumentError:
  wrong number of arguments (given 3, expected 1..2)

gemspec を見ると validate_timeliness v6.0.1 は 「ActiveModel v6.0.0 以上 v7 より小さいバージョン」に依存するよう指定されています。

github.com

これを解消した 7.0.0.beta1 バージョンはあるのですが、 gem 'validates_timeliness' という指定では Dependabot が検知してくれませんでした。

validates_timeliness の README で説明されているとおり、ベータ版を明示的に取得するよう Gemfile に記載することで解消できます。

gem 'validates_timeliness', '~> 7.0.0.beta1'

3-3. AssociationLoader

graphql-ruby で N+1 問題を解消するために graphql-batch という gem を入れることがあります。

この gem を使って AssociationLoader のようなクラスを定義して使用するサンプルが以下のように提供されています。

github.com

この実装は ActiveRecord::Associations::Preloader に依存しているのですが、Rails 7.0 でこちらのモジュールに修正が入っています。

こちらのコミットを参考に修正することでエラーが解消できます。

4. ActionController::TestSession

テストでセッションの値をセットする時に Rails 6.1 では以下のように書くことができました。

allow_any_instance_of(ActionDispatch::Request).to receive(:session).and_return({key: :value})

Rails 7.0 では ActionDispatch::Request#session が返すオブジェクトは Hash ではなく ActionController::TestSession オブジェクトである必要があります。

未対応の場合、以下のようなエラーが発生します。

NoMethodError:
       undefined method `enabled?'
           return unless session.enabled?

こちらは ActionController::TestSession オブジェクトを返すようにすれば解消します。

allow_any_instance_of(ActionDispatch::Request).to receive(:session).and_return(ActionController::TestSession.new(key: :value))

5. ActionController::InvalidAuthenticityToken

いくつかのテストが ActionController::InvalidAuthenticityToken に依存していました。

Rails 6.1 では引数なしですが Rails 7.0 では引数ありになっているので、対応しましょう。

6. Rendering actions with '.' in the name is deprecated

以下のようなエラーが出ていました。

ActionView::MissingTemplate: Missing partial xxx/_show.json

Rails 6.1 で render 時に拡張子をつけると非推奨警告(DEPRECATION WARNING)が出ます。

github.com

Rails 7.0 ではこれがエラーになります。対応としては formats オプションに拡張子を指定するだけです。

ここで注意なのですが、formats オプションを指定する場合、省略記法で書けなくなるので、適宜 locals オプションなどを明示する必要があります。

# NG 例
render 'shared/show.json', arg1: :hoge

# Good
render partial: 'shared/show', locals: { arg1: :hoge }, formats: :json

詳しい説明は GitLab の Issue が分かりやすいので参考にしてみてください。

7. datetime 型の precision の挙動が変わり、全テーブルの Ridgepole のスキーマ定義を見直す必要があった

以下の PR で datetime 型の precision の挙動が変わりました。

github.com

マイグレーションファイルに ActiveRecord::Schema[6.1].define と指定することで、Rails 6.1 の挙動に保つことができます。

railsguides.jp

ただし Ridgepole を使っている場合はこの方法は取れません。Ridgepole の README にも記載されているようにスキーマファイル自体を修正する必要があります。

具体的には、元々未指定の場合は precision: nil をつけます。

# Rails 6.1
t.datetime :hoge_at

# Rails 7.0
t.datetime :hoge_at, precision: nil

また precision: 6 がついている場合は削除します。

# Rails 6.1
t.datetime :hoge_at, precision: 6

# Rails 7.0
t.datetime :hoge_at

最終的に以下のコマンドで差分がないことを確認すれば安心です。

bundle exec ridgepole -c config/database.yml --apply --dry-run

8. 初期化中の自動読み込みの挙動が変わった

config/initializer の処理中に以下のエラーが出ることがあります。

NameError: uninitialized constant XXX

Rails アップグレードガイドには、少し解釈が難しい記述ではありますが、内容が記載されています。 具体的には config/initializer 配下の処理などの初期化処理が実行されるタイミングで、 app/ ディレクトリ配下に定義されたクラスやモジュールが自動読み込みされなくなります(ちなみに gem で定義されたクラスは読み込まれます)。

railsguides.jp

guides.rubyonrails.org

筆者の場合は config/initializer 以下のファイルを全て調査して、エラーが発生する箇所で Rails.application.config.after_initialize を用いて遅延評価するようにしました。

Rails.application.config.after_initialize do
  App::XXX.call
end

また app/ 配下のクラスを使用しなくても gem で定義されたクラスや標準モジュールで代替できる場合は置き換えました。

ちなみに config/initializer には APM や 監視ツールの設定を記述することが一般的ですが、この問題への対応策が Issue などに記載されていることもあるのでそちらを参照してみるとよいでしょう。

例えば、 Datadog では以下の Issue が記載されています。

github.com

9. ActiveRecord::Base#transaction ブロック内で早期リターンした時にロールバックするようになった

Rails リリースノートに記載されています。

railsguides.jp

guides.rubyonrails.org

具体的には以下のコードのような挙動になります。

def transaction_with_return
  ApplicationRecord.transaction do
    Comment.last.update!(message: "updated")

    # Rails 6.1 -> Commit
    # Rails 7.0 -> Rollback
    return
  end
end

Comment.last.update!(message: "original")
transaction_with_return
Comment.last.message # Rails 6.1 => "updated", Rails 7.0 => "original"

影響が大きい割に、発見が非常に難しいのですが、以下のような DEPRECATION WARNING が出るのでログを頼りに特定することができます。

DEPRECATION WARNING: Using `return`, `break` or `throw` to exit a transaction block is
  deprecated without replacement. If the `throw` came from
  `Timeout.timeout(duration)`, pass an exception class as a second
  argument so it doesn't use `throw` to abort its block. This results
  in the transaction being committed, but in the next release of Rails
  it will rollback.

RuboCop の以下の Cop でも発見できるのでご活用ください。

docs.rubocop.org

10. 同じカラム上で条件をマージした場合に両方の条件が維持されなくなり、常に後者の条件によって置き換わるようになった

こちらも Rails リリースノートに記載されています。サンプルコードも記載されています。

railsguides.jp

guides.rubyonrails.org

こちらも影響が大きい割に、発見が非常に難しいのですが、以下のような DEPRECATION WARNING が出るのでログを頼りに特定しましょう。

DEPRECATION WARNING: Merging xxxxx no longer maintain both conditions, and will be replaced by the latter in Rails 7.0. To migrate to Rails 7.0's behavior, use `relation.merge(other, rewhere: true)`.

11. .rubocop.yml などで指定している Rails のバージョンを上げる

.rubocop.yml には TargetRailsVersion で想定する Rails のバージョンを指定することができます。

Rails のバージョンアップとは別で RuboCop の対応を行いたい場合などで重宝します。

一方で、こちらの設定を上げ忘れることもあるので、確認しておきましょう。

ちなみにいくつかのルールでは、Deprecated になったコードに対するものがあります。

コードの静的解析で初めて気づける非互換的変更もあるので、日頃からこまめに対応しておくことをオススメします(筆者もいくつかこれに救われました)。

また、他のツールなどでバージョンを指定していることがあれば併せてチェックしてバージョンを上げておきましょう。

その他 Tips

可能な限り修正はバージョンアップ前にリリースする

バージョンアップと同時に大量の修正をリリースすると、不具合発生時に問題の切り分けが難しくなってしまいます。

また、Rails のバージョンアップに伴う修正の多くは、以前のバージョンでも修正が可能です。

実際にどのようなリリース戦略を採用するかはプロダクトの事情次第ですが、可能な限り小さくこまめにリリースすることをオススメします。

Rails アップグレードガイドや Rails のリリースノートを熟読する

バージョンアップ作業中は Rails アップグレードガイドは熟読しましょう。英語記事の方も見ておくと良いです。

railsguides.jp

guides.rubyonrails.org

また Rails 自体のリリースノートを見ておくこともオススメします。Rails アップグレードガイドに書かれてない重要な変更が記載されていることもあるので、要チェックです!

railsguides.jp

guides.rubyonrails.org

他のプロダクトの事例を調査する

他のプロダクト Rails アップグレードの記事を読むことで「あれ、これってうちのプロダクトも影響ある?」という箇所が見つかります。

こちらは弊社の id:Pocke さんが共有する場を用意してくれているので、ご利益にあやからせてもらいましょう。

moneyforward-dev.jp

またマネーフォワードでは他のプロダクトでも Rails を採用していることが多いので、他のプロダクトの事例も参考にさせてもらいました。

社内の有識者に相談する

社内に Rails や Ruby の有識者がいる場合は事前に相談することをオススメします。

これは Rails プロダクトが多いマネーフォワードの特権ではありますが、多くの有識者が社内にいます。

筆者は id:Pocke さんに相談の機会をいただき、だいぶ手探りで進めていたところに背中を押していただきました!この場を借りて感謝申し上げます。

本番で DEPRECATION WARNING ログを出す

config/environments/production.rb に以下の設定を書くことで標準出力にログを出力できます。

config.active_support.deprecation = :log

railsguides.jp

出力方法をカスタマイズしたい場合は config.active_support.deprecation = :notify に設定し、 config/initializer 配下にコードを書きます。

今回は特定の deprecation ログは無視したかったので以下のように実装しました。

# config/initializer/xxx.rb
if Rails.env.production?
  ActiveSupport::Notifications.subscribe('deprecation.rails') do |_name, _start, _finish, _id, payload|
    unless payload[:message]&.include?('some ignorable message')
      Rails.logger.warn(payload[:message])
    end
  end
end

Rails のバージョンアップでは、いくつか「単体テストでは見つかりづらいもの」があります。例えば「9. ActiveRecord::Base#transaction ブロック内で早期リターンした時にロールバックするようになった」のセクションで紹介した対応などが挙げられます。

その場合でも DEPRECATION WARNING ログを出すことでバージョンアップ時の非互換挙動を発見することができます。

やはり本番環境での振る舞いをベースに調査する方が確実です。手軽に調査できるうえに、リリース時の品質向上にもつながるため、バージョンアップの際にはぜひ試してみてください。

まとめ

本記事では、Rails のバージョンを 6.1 から 7.0 にアップグレードした話について書きました。

ここで紹介した作業は一部であり、他にも多くの対応がありましたが、チームメンバーの協力を得て乗り越えることができました。

また、クラウド経費・債務支払は多くのユーザーに利用されているため、 QA を徹底し、高品質を保つことでユーザーへの影響を最小限に抑えることができました。

以上のように慎重に取り組みつつも、迅速に動いたことで EOL 前にリリースすることができました。

今後は Rails 7.1 以降へのアップデート記事も書いていく予定ですので、どうぞお楽しみに!


マネーフォワード福岡開発拠点では、エンジニアを募集しています!

多くのユーザーに利用されている Rails アプリケーションに携わりたい方はぜひ!

hrmos.co

福岡開発拠点のサイトもあるのでぜひみてください!

fukuoka.moneyforward.com