Money Forward Developers Blog

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

20230215130734

約 10 年開発されているアプリケーションの Rails バージョンを頑張って 7.0 から 7.1 に更新しました

クラウド経費 の開発を担当している野田 (@quanon_jp) と申します。趣味はヨーヨーで、プログラムを書いてる最中も頭の中はヨーヨーのことでいっぱいです。

同じチームで働くみやむー (@miyamura.koyo) が 2024/10 に以下の記事を執筆しました。

moneyforward-dev.jp

今回はその続編です。Ruby on Rails (以下 Rails) のバージョンを 7.0 から 7.1 に更新しました。前回の記事と重複する内容もありますが、改めてその記録を残しておきます。

Rails 7.0 から 7.1 に更新する動機

クラウド経費・クラウド債務支払の Web アプリケーションは Rails で構築されています。2025/01 時点で使用していた Rails のバージョンは 7.0 でした。このバージョンはサポート終了日である 2025/04/01 (火) が迫っており、早急な更新が求められています。

Ruby on Rails — Maintenance policy より

List of currently supported releases
8.0.x - Supported until November 7, 2026
7.2.x - Supported until August 9, 2026
7.1.x - Supported until October 1, 2025
7.0.x - Supported until April 1, 2025

前回の 6.1 から 7.0 へのメジャーバージョン更新とは異なり、今回は 7.0 から 7.1 へのマイナーバージョン更新です。そのため前回よりは楽だろうと高を括っていました。しかし Rails は巨大なフレームワークであり、マイナーバージョン更新でも数々の破壊的変更が行われます。今回も時間と労力を掛けて更新を行いました。

進め方

更新内容を調査する

Rails ガイドを読み、どのような更新が行われたのか事前に調べておきましょう。

railsguides.jp

また、ruby-jp(Ruby プログラマ同士の交流を目的とした Slack ワークスペース) の Scrapbox に Rails 7.1 の更新内容や更新事例がまとめられたページがあるので、それを参考にするのもよいでしょう。

scrapbox.io

Rails を更新する

まず Gemfile を書き換え、Rails のバージョンを 7.1 の最新バージョンに更新します。

# Gemfile
gem 'rails', '7.1.5.1'
$ bundle update --conservative rails

そして app:update というコマンドを実行し、新しい Rails のバージョンで新しく必要になるファイルや設定ファイルの生成と更新を行います。

$ bin/rails app:update

全件テストを実行する

単体テストを全件実行し、失敗するテストケースを洗い出します。クラウド経費・クラウド債務支払と合計して約 41,000 件のテストケースのうち 4,300 件が失敗しました。失敗の原因を地道に取り除いていきます。

非推奨警告 (DEPRECATION WARNING) を検知する

先ほどの単体テストでテストの失敗と同時に非推奨警告も洗い出して取り除きます。警告を全て取り除いたら、本番のアプリケーションで以下のコードを動かし、対応が漏れている警告を検知し、取り除きます。

# config/initializers/deprecation_log.rb
# Rails 7.0
if Rails.env.production?
  ActiveSupport::Notifications.subscribe('deprecation.rails') do |_name, _start, _finish, _id, payload|
    next if payload[:message].blank?

    body = <<~BODY.chomp
      #{payload[:message]}
      (Gem: #{payload[:gem_name] || '--'}, Removed in: #{payload[:deprecation_horizon] || '--'})
    BODY
    body << ("\n" + payload[:callstack].first(5).join("\n")) if payload[:callstack].is_a?(Array)

    Rails.logger.warn(body)
  end
end

実際にやったこと

コードの修正

to_s を to_fs に置き換えた

https://railsguides.jp/7_0_release_notes.html#active-support-%E9%9D%9E%E6%8E%A8%E5%A5%A8%E5%8C%96

フォーマットを #to_s に渡すことが非推奨化された。今後 Array, Range, Date, DateTime, Time, BigDecimal, Float, Integer では #to_fs を使うこと。

to_s にフォーマットを渡すことが非推奨になっていたため、すべて to_fs に置き換えました。単純な呼び出しだけでなく Object#try を使用して呼び出している場合もあるので注意です。

count.to_s(:delimited)
# ↓
count.to_fs(:delimited)

user.created_at.try(:to_s, :short_jp)
# ↓
user.created_at.try(:to_fs, :short_jp)

Model.table_name からデータベース名を取り除いた

モデルの table_name にデータベース名が含まれている箇所をテーブル名のみにしました。

class User < ApplicationRecord
  self.table_name = 'hoge_production.fuga_users'
end
# ↓
class User < ApplicationRecord
  self.table_name = 'fuga_users'
end

データベースには MySQL を使用しているのですが、table_name にデータベース名を含めていると、insert_all あるいは insert_all! を呼び出した際の INSERT 文がシンタックスエラーになるためです。

-- Rails 7.0
INSERT INTO `hoge_production`.`fuga_users` (...)
VALUES (...)
ON DUPLICATE KEY UPDATE `foo_id`=`foo_id`

-- ↓

-- Rails 7.1
INSERT INTO `hoge_production`.`fuga_users` (...)
VALUES (...) AS `hoge_production`.`fuga_users_values`
ON DUPLICATE KEY UPDATE `foo_id`=`hoge_production`.`fuga_users_values`.`foo_id`

ただし、モデルのテーブルのデータベース名がデフォルトのものでない場合は、依然としてデータベース名を含める必要があります。そのようなモデルでは苦肉の策として、以下のように insert_all あるいは insert_all! を override する対応を入れ、シンタックスエラーを回避しました。

# Rails 7.1
class Member < ApplicationRecord
  self.table_name = 'piyo_production.members'

  # @override https://github.com/rails/rails/blob/v7.1.5.1/activerecord/lib/active_record/persistence.rb#L242-L244
  def self.insert_all!(attributes, returning: nil, record_timestamps: nil)
    InsertAllInLogDB
      .new(self, attributes, on_duplicate: :raise, returning: returning, record_timestamps: record_timestamps)
      .execute
  end
end

if ActiveSupport.version >= Gem::Version.new('7.2')
  raise('This class implementation is for Rails 7.1.x. If you update the Rails version, please check that this override is correct.')
end

class InsertAllInLogDB < ActiveRecord::InsertAll
  private

  # @override https://github.com/rails/rails/blob/v7.1.5.1/activerecord/lib/active_record/insert_all.rb#L189-L191
  def to_sql
    # Replace "AS `piyo_production`.`members_values`" with "AS `piyo_production_members_values`"
    # to prevent MySQL syntax error.
    super.gsub(/(AS `\w+)(`\.`)(\w+`)/) { "#{Regexp.last_match(1)}_#{Regexp.last_match(3)}" }
  end
end

alias_attribute を alias_method に置き換えた

Deprecate aliasing non-attributes with alias_attribute.
(alias_attribute を使った属性以外のエイリアスを廃止します。)

という警告に従い、alias_attributealias_method に置き換えました。

アソシエーションの別名を alias_attribute で定義していましたが、それにより nested attributes を使用している箇所の挙動が意図せず変わっていることに気づきました。alias_method に置き換えることでその問題も解消しました。

# Rails 7.0
alias_attribute :user, :fuga_user
# ↓
# Rails 7.1
alias_method :user, :fuga_user
alias_method :user=, :fuga_user=

slim を更新した

slim のバージョンを 5.1.1 から 5.2.1 に更新しました。

ビューとして使用している slim ファイルで、以下のように class の定義が重複している箇所が軒並みエラーになりました。

/ Rails 7.0
- hoge = "foo"
button class="#{hoge}" class="fuga"
ActionView::Template::Error:
  undefined method `+' for #<ActionView::OutputBuffer:0x00007f93252e2a60 @raw_buffer="foo">

このエラーは slim のバージョンを更新することで解消しました。

ただしこのバージョンアップの影響で、以下のように slim ファイルで scss: という記述を使用している場合に .sass-cache というディレクトリを生成するようになりました。

https://github.com/slim-template/slim/pull/931/commits/3a31cef50b6c67e649f6cfc625c961728bd20dbf

この挙動によりファイルシステム上の問題が起きたので、scss: をすべて css: に置き換えました。

/ Rails 7.0
scss:
  .hoge {
    .fuga {
      z-index: calc(infinity)
    }
  }
/ ↓
/ Rails 7.1
css:
  .hoge .fuga {
    z-index: calc(infinity)
  }

数値以外の配列で sum を使用していた箇所を別のメソッドに置き換えた

Rails 7.0 has deprecated Enumerable.sum in favor of Ruby's native implementation available since 2.4.
Sum of non-numeric elements requires an initial argument.
(Rails 7.0 では Enumerable.sum が非推奨となり、Ruby 2.4 以降で利用可能な Ruby ネイティブの実装が推奨されています。
数値以外の要素を合計するには第 1 引数が必要です。)

という警告に従い、数値以外の配列で sum を使用していた箇所を別のメソッドに置き換えました。例えば、配列を要素とする配列の場合、sum の代わりに flat_map を使用するようにしました。さもないと Rails 7.1 では TypeError が発生します。

# Rails 7.0
[[1], [2]].sum { _1 }
# DEPRECATION WARNING: Rails 7.0 has deprecated Enumerable.sum in favor of Ruby's native implementation available since 2.4.
# Sum of non-numeric elements requires an initial argument.
#=> [1, 2]
# ↓
# Rails 7.1
[[1], [2]].sum { _1 }
# TypeError: Array can't be coerced into Integer
[[1], [2]].flat_map { _1 }
#=> [1, 2]

ActiveSupport::MessageEncryptor の内部実装に依存したコードを修正した

ActiveSupport::MessageEncryptor の内部実装が変わり、それを継承したクラスが動作しなくなっていたので修正しました。

permit されていない params で to_h を呼び出さないようにした

params.to_h が ActionController::UnfilteredParameters を発生させていました。そこで、適切に permit を呼び出すに修正しました。

# Rails 7.0
params.to_h.symbolize_keys.slice(:hoge, :fuga, :foo)
# ActionController::UnfilteredParameters: unable to convert unpermitted parameters to hash
# ↓
# Rails 7.1
params.permit(:hoge, :fuga, :foo)

after_commit を after_save に置き換えた

1 箇所 after_commit を after_save に置き換えました。after_commit でジョブをエンキューしている処理が Rails 7.0 では 1 回だけ呼ばれていたにもかかわらず、Rails 7.1 では 2 回呼ばれるようになってしまったためです。

どうやら以下の差分が関係しているようです。after_commit を after_save に置き換えるとコールバックが実行されるタイミングがトランザクションの外から中に変わってしまいます。動作上問題ないことを確認した上で置き換え、解決しました。

https://github.com/rails/rails/pull/45280/commits/936a862f3c7d472b8df0450205b6bc2672744e16

serialize メソッドで type を指定するようにした

DEPRECATION WARNING: Passing the class as positional argument is deprecated and will be removed in Rails 7.2. Please pass the class as a keyword argument: serialize :body, type: Hash (クラスを位置引数として渡すことは非推奨であり、Rails 7.2 で削除されます。クラスはキーワード引数として渡してください。)

という警告に従い、type: Hash を指定するようにしました。

# Rails 7.0
class Message < ApplicationRecord
  serialize :body, Hash
end
# ↓
# Rails 7.1
class Message < ApplicationRecord
  serialize :body, type: Hash
end

controller.request=, controller.response= を controller.set_request!, controller.set_response! に置き換えた

Rspec を使用したテストコードで controller.request=, controller.response= と書いていた箇所を controller.set_request!, controller.set_response! に置き換えました。ダミーのリクエストとレスポンスを設定する目的で controller.request=, controller.response= を利用していましたが、controller.response_body の挙動が変わってしまい問題が起きました。

ActionController::Metal#dispatch の実装を参考に set_request!, set_response! を使用すると正しく動作するようになりました。

# Rails 7.0
before do
  dummy_request = ActionController::TestRequest.create(controller)
  dummy_response = controller.class.make_response!(controller.request)
  controller.request = dummy_request
  controller.response = dummy_response
end
# ↓
# Rails 7.1
before do
  # 略
  controller.set_request!(dummy_request)
  controller.set_response!(dummy_response)
end

response.parsed_body の返り値の型が変わったので修正した

ActionDispatch::TestResponse#parsed_body が Rails 7.0 では String オブジェクトを返していましたが、Rails 7.1 では Nokogiri::HTML4::Document オブジェクトを返すようになりました。それに合わせてテストコードを修正しました。例えば Nokogiri::HTML4::Document#css メソッドが使えるようになったので、レスポンスボディの HTML が特定の要素を含んでいるかという、より詳細なテストが可能になりました。

# Rails 7.0
body = response.parsed_body
expect(body).to include(target_id)
# ↓
# Rails 7.1
body = response.parsed_body
expect(body.css("option[value=#{target_id}]")).to be_present

MemCacheStore と RedisCacheStore がコネクションプールを使用しないようにした

MemCacheStore と RedisCacheStore がデフォルトでコネクションプールを使用するようになりましたが、従来の挙動を変えないように pool: false を指定するようにしました。

# Rails 7.0
config.cache_store = :mem_cache_store, ENV.fetch('MEMCACHED_HOST')
# ↓
# Rails 7.1
config.cache_store = :mem_cache_store, ENV.fetch('MEMCACHED_HOST'), { pool: false }

その他

Rails 7.0 の時点でリリース可能な差分をリリースする

Rails 7.0 のアプリケーションに含めることが可能な差分は事前にマージし、リリースしておきます。そうすることで、Rails 7.1 に更新するリリースを行う際の差分が最小になり、変更内容を把握しやすくしたり、リリースの影響を最小限にしたりすることができます。

E2E テストを実施する

  • 全ての単体テストが成功する
  • 全ての非推奨警告を取り除く

以上が完了した後、アプリケーションを検証環境にデプロイし、E2E テストを実施してバグが発生していないかを確認しました。

まとめ

今回は Rails を 7.0 から 7.1 に更新する際に行ったことをご紹介しました。Rails は巨大なフレームワークであり、メジャーバージョンアップだけでなくマイナーバージョンアップでも多くの変更が行われるため、慎重に更新を行う必要があります。世の中には Rails 7.1 より前のバージョンで動作しているアプリケーションがまだまだ多く存在していると思います。この記事が更新の一助になれば幸いです。


マネーフォワード福岡開発拠点ではエンジニアを募集しています!多くのユーザーに利用されている Rails アプリケーションに携わりたい方はぜひ!

hrmos.co

福岡開発拠点のサイトもあります。もしご関心があれば、ぜひともご覧ください!

fukuoka.moneyforward.com