Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

週刊Railsウォッチ: DI的な書き方が必要なとき、脆弱性学習用アプリRailsGoat、brakemanは優秀ほか(20210705前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙇

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 先週の改修(Rails公式ニュースより)

今回も以下の公式更新情報の続きを追います。次の更新情報も出ましたね。

🔗 association extensionでのpurgepurge_later呼び出しを非推奨化

#42383コメントのフォローアップ。
association extensionからpurgeやpurge_later`を呼び出すとdeprecation warningを出すようになる。これらのメソッドは7.1リリース時に削除される。
同PRより大意


つっつきボイス:「purgepurge_laterの呼び出しが非推奨になるのはassociation extensionでの話のようですね」「完全に消されるのかと思ったら違った」「7.1で消されるそうです」

「今後はどうしたらいいんだろう?」「deprecation warningに書かれてた↓」「メッセージのアクション名がpurgepurge_laterに相当するんですね」

# activestorage/lib/active_storage/attached/model.rb#L157
          def purge
+           deprecate(:purge)
            each(&:purge)
            reset
          end

          def purge_later
+           deprecate(:purge_later)
            each(&:purge_later)
            reset
          end
+
+         private
+         def deprecate(action)
+           reflection_name = proxy_association.reflection.name
+           attached_name = reflection_name.to_s.partition("_").first
+           ActiveSupport::Deprecation.warn(<<-MSG.squish)
+             Calling `#{action}` from `#{reflection_name}` is deprecated and will be removed in Rails 7.1.
+             To migrate to Rails 7.1's behavior call `#{action}` from `#{attached_name}` instead: `#{attached_name}.#{action}`.
+           MSG
+         end

リフレクション名でのアクション名呼び出しは非推奨化され、Rails 7.1で削除される。この振舞いをRails 7.1に移行するには代わりにアタッチされた名.アクション名を呼び出すこと。
同メッセージより大意

「このテストコード↓で言うと、highlights_attachments.purgeではなくhighlights.purgeを呼ぶように変えるということですね: _attachmentsの部分が"association extension"」「あ〜なるほど」「association extensionを付けて呼び出すのが非推奨になるだけで、purgepurge_laterはなくならない」

# activestorage/test/models/attached/many_test.rb#459
 test "purging from the attachments relation" do
    [ create_blob(filename: "funky.jpg"), create_blob(filename: "town.jpg") ].tap do |blobs|
      @user.highlights.attach blobs
      assert @user.highlights.attached?

      message = <<-MSG.squish
        Calling `purge` from `highlights_attachments` is deprecated and will be removed in Rails 7.1.
        To migrate to Rails 7.1's behavior call `purge` from `highlights` instead: `highlights.purge`.
      MSG
      assert_deprecated(message) do
        assert_changes -> { @user.updated_at } do
          @user.highlights_attachments.purge
        end
      end
      assert_not @user.highlights.attached?
      assert_not ActiveStorage::Blob.exists?(blobs.first.id)
      assert_not ActiveStorage::Blob.exists?(blobs.second.id)
      assert_not ActiveStorage::Blob.service.exist?(blobs.first.key)
      assert_not ActiveStorage::Blob.service.exist?(blobs.second.key)
    end
  end

参考: Association extensions -- ActiveRecord::Associations::ClassMethods
参考: Rails6 のちょい足しな新機能を試す99(association extension編) - Qiita

🔗 CollectionAssocation#buildのパフォーマンスリグレッション修正

#40379によって、自分たちのアプリのひとつをRails 6.1にアップグレードしたときに大きなリグレッションが発生した。レコードを多数持つ関連付けでbuildを呼び出すと、targetの重複チェックのためにあらゆるオブジェクトをイテレートしなければならなくなって実行に長時間かかる。
has_many_inversingの仕組み上これは必要だが、もっと高速に実行する実装は可能。このプルリクでは、has_many_inversingでターゲットに追加されたレコードを別キャッシュに保持し、マッチするレコードをそこのみで検索することで解決を試みた。
このバグの再現スクリプトは、Rails 6.1とmainブランチでは失敗し、このブランチでは成功する。

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  gem "rails", "~> 6.1.0"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :authors, force: true do |t|
  end
  create_table :posts, force: true do |t|
    t.references :author
  end
end

class Author < ActiveRecord::Base
  has_many :posts
end

class Post < ActiveRecord::Base
  belongs_to :author
end

class BugTest < Minitest::Test
  def test_association_stuff
    author = Author.create!

    posts = 100_000.times.map { |n| { author_id: author.id } }
    Post.insert_all(posts)

    author = Author.find(author.id)
    author.posts.load_target

    5000.times do |n|
      time = Benchmark.ms { author.posts.build }
      assert time < 30, "iteration #{n}: #{time}" # takes about 200ms in Rails 6.1
    end
  end
end

同PRより大意


つっつきボイス:「プルリクメッセージにもあるようにRails 6.1でレコードを多数持つ関連付けでbuildを呼び出すと遅くなっていたのを修正したようですね」

「この@replaced_targetsに結果をキャッシュして、同じレコードセットを毎回検索しなくて済むようにしたっぽい↓」

# activerecord/lib/active_record/associations/collection_association.rb#L79
      def reset
        super
        @target = []
+       @replaced_targets = Set.new
        @association_ids = nil
      end
# activerecord/lib/active_record/associations/collection_association.rb#L271
      def add_to_target(record, skip_callbacks: false, replace: false, &block)
-       if replace || association_scope.distinct_value
-         index = @target.index(record)
-       end
-       replace_on_target(record, index, skip_callbacks, &block)
+       replace_on_target(record, skip_callbacks, replace: replace || association_scope.distinct_value, &block)
      end

      def target=(record)
        return super unless reflection.klass.has_many_inversing
        case record
        when Array
          super
        else
-         add_to_target(record, skip_callbacks: true, replace: true)
+         replace_on_target(record, true, replace: true, inversing: true)
        end
      end

「今Rails 6.0のプロジェクトがあるんですが、早く6.1にアップグレードしたい」「あ〜」「プロジェクト開始時点で6.1は出ていたんですが当時としては時期尚早だったんですよ」「それももっともですね」

🔗 TurboのフォームにUJSフォーム送信ハンドラをアタッチしないようになった


つっつきボイス:「DHH自らのHotwire関連プルリクです」「TurboとUJS(Unobtrusive JavaScript)を共存できるように、両方が読み込まれた場合にUJSを無効化してTurboだけが有効になるようにしたみたい: これは地味にありがたい👍」「なるほど」「'form:not([data-turbo=true])'data-turboがtrueのときはUJSを動かさないということか」「not trueってややこしいですね😅」

# actionview/app/assets/javascripts/rails-ujs.coffee#L20
  # Form elements bound by rails-ujs
- formSubmitSelector: 'form'
+ formSubmitSelector: 'form:not([data-turbo=true])',

  # Form input elements bound by rails-ujs
- formInputClickSelector: 'form input[type=submit], form input[type=image], form button[type=submit], form button:not([type]), input[type=submit][form], input[type=image][form], button[type=submit][form], button[form]:not([type])'
+ formInputClickSelector: 'form:not([data-turbo=true]) input[type=submit], form:not([data-turbo=true]) input[type=image], form:not([data-turbo=true]) button[type=submit], form:not([data-turbo=true]) button:not([type]), input[type=submit][form], input[type=image][form], button[type=submit][form], button[form]:not([type])',

Rails UJSで書かれたアプリをHotwireの新しいTurboフレームワークに移行するなら、移行中は(あるいは永遠に!)TurboとUJSを共存させたいこともあるだろう。そのためにはフォーム送信の処理方法を区別する方法が必要。更新されたセレクタを使うことで、Rails UJSはdata-turbo=true付きのフォームだけを無視し、Turboで処理できるようになる。
(rails/jquery-ujs#521のミラープルリク)
同PRより大意

🔗 zoneプロパティが設定されている場合にTime#changezoneプロパティを渡すよう修正


つっつきボイス:「テストコード↓を見てて気づいたんですけど、Time.newActiveSupport::TimeZone["Moscow"]という形でタイムゾーンを渡せるみたい」「へ〜、これいいですね」「日本だとTokyo以外のタイムゾーンはあまり使いませんけど、"+03:00"みたいな書き方よりわかりやすい: 」

# activesupport/test/core_ext/time_ext_test.rb#486
+   assert_equal Time.new(2021, 5, 29, 0, 0, 0, "+03:00"), Time.new(2021, 5, 29, 0, 0, 0, ActiveSupport::TimeZone["Moscow"])
+   assert_equal Time.new(2021, 5, 29, 0, 0, 0, "+03:00").advance(seconds: 60), Time.new(2021, 5, 29, 0, 0, 0, ActiveSupport::TimeZone["Moscow"]).advance(seconds: 60)
+   assert_equal Time.new(2021, 5, 29, 0, 0, 0, "+03:00").advance(days: 3), Time.new(2021, 5, 29, 0, 0, 0, ActiveSupport::TimeZone["Moscow"]).advance(days: 3)

+   assert_equal Time.new(2021, 5, 29, 0, 0, 0, "+03:00"), ActiveSupport::TimeZone["Moscow"].local(2021, 5, 29, 0, 0, 0)
+   assert_equal Time.new(2021, 5, 29, 0, 0, 0, "+03:00").advance(seconds: 60), ActiveSupport::TimeZone["Moscow"].local(2021, 5, 29, 0, 0, 0).advance(seconds: 60)
+   assert_equal Time.new(2021, 5, 29, 0, 0, 0, "+03:00").advance(days: 3), ActiveSupport::TimeZone["Moscow"].local(2021, 5, 29, 0, 0, 0).advance(days: 3)
  end

Time#changeおよびこれを呼び出すメソッド(Time#advanceなど)が、呼び出し元がタイムゾーン引数で初期化されていた場合は指定のタイムゾーン引数を持つTimeを返すようになった。
修正: #42467
Alex Ghiculescu
同Changelogより

追いかけボイス:「つっつき会の後でちょうど話題になった呪いの書↓的にはtzdb ID形式で書くべきなんだろうなあ: MoscowならEurope/Moscow が正しそう」

参考: タイムゾーン呪いの書 (知識編)
参考: タイムゾーン呪いの書 (実装編)
参考: List of tz database time zones - Wikipedia

🔗 number_to_currencyがゼロをマイナス表示することがあるのを修正


つっつきボイス:「なるほど、-0.00456789という数値をprecision: 2で丸めると-0.00になってたのか」「あら〜」「これは修正が必要なヤツ」

# 同PRより
assert_equal("$0.00", number_helper.number_to_currency(-0.00456789, precision: 2))

🔗Rails

🔗 DI的な書き方が必要なとき


つっつきボイス:「DHHによる元記事↓は2013年のだそうです」

「記事をざっと見た限りでは、自分がこのpublish!のコード↓をレビューするなら、Time.nowをハードコードしないでオプショナル引数などでDI的に渡せるようにすべきと指摘するでしょうね」「ふむふむ」「今はtimecop gemを使わなくてもRailsの時刻を変えられるので、わざわざDIっぽく書かなくてもテストできますけどね」

# 同記事より
def publish!
  self.update published_at: Time.now
end

「ちなみに上のコードをDIっぽく書き直すとたとえばこういう感じになります↓: あとRailsアプリなら理由がない限りRubyのTime.now(システムTZを参照する)よりもRailsのTime.current(RailsのTimeWithZoneになり、かつRailsの設定に従ったZoneになる)を使うべきでしょうね」「なるほど」「どうしても使いたければTime.zone.nowにすべき」

def publish!(time = Time.current)
  self.update published_at: time
end

「自分としては、publish!を呼んでいるのが1箇所だけならDI的に書くことでテストもしやすくなりますし別に構わないと思います: DI的に書くことの問題は、他のいろんな場所でも呼ばれていると、以下のようにそれらも全部Time.currentを渡す形に書き換えないといけなくなることでしょうね↓」「あ、そうか」

def use_publish(a, b, c)
  # 何かする
  publish!
end

# ↓

def use_publish(a, b, c, time = Time.current)
  # 何かする
  publish!(time)
end

「さらにその場所で別のTime.currentがハードコードされていれば、そこも以下のような感じでhogehoge_time = Time.current - 1.dayを引数に追加したりすることになるでしょうね」「うう、昔こんなコード書いたような覚えが😅」「DIを追求していくとこういうふうになっていくんですよ」

def use_publish(a, b, c, time = Time.current)
  # 何かする
  hogehoge(Time.current - 1.day)
  publish!(time)
end

# ↓

def use_publish(a, b, c, hogehoge_time = Time.current - 1.day, time = Time.current)
  # 何かする
  hogehoge(hogehoge_time)
  publish!(time)
end

「Javaのような言語ではpublish!use_publishが相互依存しないようにするためにhogehoge_time = Time.current - 1.dayなどを別の小さなクラスに分離したりするんですが、DIをやり始めるとこういうふうになるからだと思います」「なるほど」「RubyならTimecop gemを使えば済むような話ですね: 記事の要約↓にもあるように、RubyではテストのためだけにDIを使う意味はないと思います」

  • DHHはDI自体を否定しているのではなく、テストのためだけにDIを使うのはRubyにおいては無駄であると言っている
    • 上のコードで言うと、Timeを外から注入させたい理由がテストのためだけなら「ヤメロ」と
      同記事より

「ただ、自分がさっきのpublish!のようなコードではTime.currentをオプショナル引数でDI的に渡すべきと言ったのは、テストのためではなく、publish!のようなロジックは時刻を変更できる機能をビジネス上求められる可能性が非常に高いからなんですよ」「あ、それもそうか」

「たとえばさっきのpublish!Time.currentがハードコードされていると、それを呼ぶバッチが失敗したときに時刻を変更してやり直せなくなってしまいますよね: そういうふうに時刻を変えて呼び出したいというニーズが実際にありうるので、ここはDI的に書くべきという話」「なるほど、そういうDIならちゃんと意味がありますね」「経験した範囲では、Time.nowTime.currentがハードコードされている箇所は、想定外で失敗した日次バッチのやり直しや障害調査のために"この日時に実行したのと同じ挙動をさせたい"という使い方を要求される可能性がとても高いですよ」

「その意味では、DHHの元記事にあるpublish!のコードを別のコードに変えた方が、テストのためだけにDIを使う意味がないということを納得しやすいかも」「それもそうですね」

「ちなみに自分ならDI的なものはこういう感じで書くと思います↓」「あ、Rubyのキーワード引数ですね!」「そう、キーワード引数の方が明示的になります: キーワードもtime:よりnow:にする方が現在時刻という意図が伝わりやすいので好み」

def use_publish(a, b, c, now: Time.current)
  # 何かする
  publish!(now)
end

「DHHの記事はだいぶ昔のものなので、今もDIについて同じ考えかどうかはわかりませんが」「それもそうですね」

Rails: Timecopを使わなくても時間を止められた話

🔗 load_asyncRuby Weeklyより)


つっつきボイス:「Railsに追加されたload_asyncウォッチ20210222)の解説記事が出たんですね: ちなみに銀座Rails#33でもload_asyncの話をしました↓」「あ、そうでしたか」「load_asyncよさそう」「load_asyncはクエリが複数のデータベースコネクションにまたがるので更新系は要注意ですね: 参照系だけなら比較的使いやすそう」

🔗 RailsGoat: 脆弱性を仕込んだ教育用Railsアプリ

OWASP/railsgoat - GitHub


つっつきボイス:「RailsGoat?」「先週取り上げたRailsセキュリティ脅威解説記事の第1回↓でこのRailsアプリが紹介されていました」「どこかで見た名前」

「RailsGoatはOWASPが提供していて、セキュリティ教育用にOWASPトップテン入りした脆弱性が仕込まれているそうです」「なるほど、脆弱性のサンプルアプリですか」「脆弱性をゼロから作り込むと大変なので記事ではこれを使っていました」

参考: OWASP Japan | OWASP Foundation

「ちなみにRailsGoatはまだ最新のRailsには対応していないそうです」「Railsのバージョンが変わると脆弱性も変わるので、すぐに作れないのは仕方ないでしょうね: Railsセキュリティの勉強用にはよさそう👍」

参考: Rails セキュリティガイド - Railsガイド

🔗 brakeman gemは優秀

「ところで、この間Rails 4アプリの脆弱性を調べるためにbrakemanのコードを大量に読みましたよ」「お〜それは大変そうですけど、brakemanならCVE IDの情報も付いているのでいいアプローチだと思います」

presidentbeef/brakeman - GitHub

「brakemanの素晴らしい点は、動いてないRailsコードでもチェックできること👍」「なるほど、静的にチェックするんですね」「静的解析なので、APIキーがないとか、Rubyのバージョンが古すぎるとか、Railsコンソールも動かないようなRailsアプリでもとりあえずチェックできるのがホントありがたい」「マジ優秀です」

「brakemanはDockerに入れる必要もないので、これだけgem installでインストールしてます」「なるほど」「Dockerコマンドを打たなくていいのが便利」

「brakemanはCVEのURLやサンプルの脆弱性コードなんかも表示してくれてすごく勉強になりますね: lib/brakeman/checksの下にあるファイルを読んでいてめちゃめちゃ楽しかった😋」

参考: 共通脆弱性識別子CVE概説:IPA 独立行政法人 情報処理推進機構

🔗 activerecord_json_validator:バリデーション条件をJSONスキーマで書けるgem(Ruby Weeklyより)

mirego/activerecord_json_validator - GitHub


つっつきボイス:「お〜、JSONスキーマを使ってActive Recordのバリデーションができるんですね↓」

{
  "type": "object",
  "$schema": "http://json-schema.org/draft-04/schema#",
  "properties": {
    "city": { "type": "string" },
    "country": { "type": "string" }
  },
  "required": ["country"]
}
# 同リポジトリより
create_table "users" do |t|
  t.string "name"
  t.json "profile" # First-class JSON with PostgreSQL, yo.
end

class User < ActiveRecord::Base
  # Constants
  PROFILE_JSON_SCHEMA = Pathname.new(Rails.root.join('config', 'schemas', 'profile.json'))

  # Validations
  validates :name, presence: true
  validates :profile, presence: true, json: { schema: PROFILE_JSON_SCHEMA }
end

user = User.new(name: 'Samuel Garneau', profile: { city: 'Quebec City' })
user.valid? # => false

user = User.new(name: 'Samuel Garneau', profile: { city: 'Quebec City', country: 'Canada' })
user.valid? # => true

user = User.new(name: 'Samuel Garneau', profile: '{invalid JSON":}')
user.valid? # => false
user.profile_invalid_json # => '{invalid JSON":}'

「JSONスキーマのバリデーションってこうやって書くのか〜」「JSONスキーマ自体が簡単な制約を含んでいるのでバリデーションに使えるでしょうね」「あ、なるほど」「JSONスキーマがどのぐらい使われているかはわかりませんが」

「制約条件の記載されたJSONスキーマが既にあるプロジェクトならこのgemを使うとよさそう👍」


前編は以上です。

バックナンバー(2021年度第2四半期)

週刊Railsウォッチ:書籍『Polished Ruby Programming』、DragonRuby、ES2021の新機能ほか(20210629後編)

今週の主なニュースソース

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース

Ruby Weekly


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。