- Ruby / Rails関連
週刊Railsウォッチ: DI的な書き方が必要なとき、脆弱性学習用アプリRailsGoat、brakemanは優秀ほか(20210705前編)
こんにちは、hachi8833です。
🔗Rails: 先週の改修(Rails公式ニュースより)
今回も以下の公式更新情報の続きを追います。次の更新情報も出ましたね。
- 更新情報: Rails 6.0.4, Lots of Active Storage goodies, and many Quality-of-Life improvements! | Riding Rails
- 更新情報: Support preloads on instance dependent associations and more! | Riding Rails
🔗 association extensionでのpurge
やpurge_later
呼び出しを非推奨化
#42383コメントのフォローアップ。
association extensionからpurge
やpurge_later`を呼び出すとdeprecation warningを出すようになる。これらのメソッドは7.1リリース時に削除される。
同PRより大意
つっつきボイス:「purge
やpurge_later
の呼び出しが非推奨になるのはassociation extensionでの話のようですね」「完全に消されるのかと思ったら違った」「7.1で消されるそうです」
「今後はどうしたらいいんだろう?」「deprecation warningに書かれてた↓」「メッセージのアクション名がpurge
やpurge_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を付けて呼び出すのが非推奨になるだけで、purge
やpurge_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フォーム送信ハンドラをアタッチしないようになった
- PR: Don't attach UJS form submission handlers to Turbo forms by dhh · Pull Request #42476 · rails/rails
つっつきボイス:「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#change
がzone
プロパティを渡すよう修正
つっつきボイス:「テストコード↓を見てて気づいたんですけど、Time.new
にActiveSupport::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
- Rails API:
ActiveSupport::TimeZone
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
- Rails API:
Time.current
- Rails Discussion: Time.now vs Time.current vs DateTime.now - A May Of WTFs - Ruby on Rails Discussions
「自分としては、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.now
やTime.current
がハードコードされている箇所は、想定外で失敗した日次バッチのやり直しや障害調査のために"この日時に実行したのと同じ挙動をさせたい"という使い方を要求される可能性がとても高いですよ」
「その意味では、DHHの元記事にあるpublish!
のコードを別のコードに変えた方が、テストのためだけにDIを使う意味がないということを納得しやすいかも」「それもそうですね」
「ちなみに自分ならDI的なものはこういう感じで書くと思います↓」「あ、Rubyのキーワード引数ですね!」「そう、キーワード引数の方が明示的になります: キーワードもtime:
よりnow:
にする方が現在時刻という意図が伝わりやすいので好み」
def use_publish(a, b, c, now: Time.current)
# 何かする
publish!(now)
end
「DHHの記事はだいぶ昔のものなので、今もDIについて同じ考えかどうかはわかりませんが」「それもそうですね」
🔗 load_async
(Ruby Weeklyより)
- 元記事: Speeding up Rails 7 Controller Actions using ActiveRecord #load_async
- PR: Implement Relation#load_async to schedule the query on the background thread pool by casperisfine · Pull Request #41372 · rails/rails
つっつきボイス:「Railsに追加されたload_async
(ウォッチ20210222)の解説記事が出たんですね: ちなみに銀座Rails#33でもload_async
の話をしました↓」「あ、そうでしたか」「load_async
よさそう」「load_async
はクエリが複数のデータベースコネクションにまたがるので更新系は要注意ですね: 参照系だけなら比較的使いやすそう」
🔗 RailsGoat: 脆弱性を仕込んだ教育用Railsアプリ
つっつきボイス:「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の情報も付いているのでいいアプローチだと思います」
「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より)
つっつきボイス:「お〜、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後編)
- 20210628前編 GitLab 14.0のbreaking changes、Railsのセキュリティ脅威解説シリーズ記事ほか
- 20210623後編 childprocess gemで子プロセスを制御、Ruby 2.6〜3.0で動くdelegationほか
- 20210621前編 Active Storageのvariantsをeager loadingするメソッドが追加、Hotwire専用Discuss、AnyCable Proほか
- 20210615後編 RubyのRBSを理解する、シンボルがGCされないとき、Terraform 1.0リリースほか
- 20210608後編 RubyでAppleのLZFSE圧縮データ解凍、AWS Lambda Extensionsが正式リリース、unixgame.ioほか
- 20210607前編 ActiveRecord::Relationのone?とmany?が高速化、RubyKaigi Takeout 2021登壇者募集開始ほか
- 20210601後編 Python使いから見たRuby、MySQLのインデックス解説、GitHubが採用したOpenTelemetryほか
- 20210531前編 RailsConf 2021の動画が公開、GraphQLのN+1を自動回避、Ruby 3のJITとRailsほか
- 20210525後編 Rubyのオブジェクトアロケーション改善、RubyKaigi Takeout 2021開催日発表、AWS App Runnerほか
- 20210524前編 Active Supportの知られてなさそうな機能5つ、RSpecの歴史、書籍『Practicing Rails』ほか
- 20210518後編 RubyのGCを深掘りする、Psych gemのbreaking change、11月のRubyConf 2021ほか
- 20210517前編 Bootstrap 5リリース、productionでSQLiteがwarning表示、rails-ujsの舞台裏ほか
- 20210511後編 AWS Lambda関数ハンドラをDSLで書けるyake gem、VPC Peeringが同一AZ転送量無料化ほか
- 20210510前編 属性メソッドをキャッシュして最適化、Railsのガバナンスに関する声明、bundle install高速化ほか
- 20210427後編 RactorでUDPサーバーを作る、JSONシリアライザalba gem、AppleのAirTagほか
- 20210420後編 ShopifyのJITコンパイラYJIT、PicoRuby、DynamoDBの3つの制約ほか
- 20210419前編 RailsのN+1クエリを定番以外の方法で修正する、GitLabのセキュリティ修正リリースほか
- 20210413後編 RubyMineのRBSサポートとCode With Me、GitHub ActionとDockerレイヤキャッシュほか
- 20210412前編 Active Record属性暗号化機能がRails 7にマージ、RailsNew.ioでrails newオプションを生成ほか
- 20210407後編 エイプリルフールのRuby構文プロポーザル、AWSのVPC Reachability Analyzerほか
- 20210406前編 GitHubが修正したRailsセッションハンドリングの競合、erb/haml/slimの速度比較ほか
今週の主なニュースソース
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)