概要 元サイトの許諾を得て翻訳・公開いたします。 英語記事: Rails 7.1 allows subscribing to Active Record transaction events for instrumentation - BigBinary Blog 原文公開日: 2024/01/16 原著者: Vishnu M 参考: 週刊Railsウォッチ20230926: Active Recordのトランザクションをinstrumentationで計測できるようになった なお、transaction.active_recordは現時点でRails Guidesやedgeguidesには記載されていません。 […]
The post Rails 7.1: Active Recordのトランザクションイベントがinstrumentationで計測可能になった(翻訳) first appeared on TechRacho.
Active Supportのinstrumentation(計測)APIは、アプリケーション内で特定のイベントが発生したときに通知の受信を可能にするフックを提供します。Railsには、サブスクライブ可能な組み込みのイベントを多数提供しています。フレームワーク用フックのリストは以下にあります。
参考: §3 Railsフレームワーク用フック -- Active Support Instrumentation で計測 - Railsガイド
ここに最近追加されたのがtransaction.active_record
イベントであり、これはActive Recordの管理するトランザクションが発生したときにトリガーされます。これは、パフォーマンス監視や最適化のためにデータベーストランザクションをトラッキング・分析する監視システム(New Relicなど)を構築する場合に特に有用です。
このイベントのペイロードには、:connection
、:outcome
(結果)、詳細タイミングが含まれます。:connection
は、トランザクションが発生したデータベースがどれなのかを特定するときに便利で、特にマルチデータベース環境で有用です。:outcome
はトランザクションの結果を示すもので、以下のいずれかとなります。
:commit
:rollback
:restart
:incomplete
これを利用するには、config/initializers/events.rbイニシャライザで以下のようにイベントをサブスクライブします。
ActiveSupport::Notifications.subscribe(
"transaction.active_record"
) do |event|
MetricsLogger.record_transaction(event.payload)
end
上の例のMetricLogger
は、トランザクションの詳細を記録する責務を担っています。これで、遅いトランザクションを分析して報告し、それを元に適切な措置を取れるようになります。これはinstrumentationのニーズに合わせて変更できます。
詳しくは以下のプルリクをご覧ください。
The post Rails 7.1: Active Recordのトランザクションイベントがinstrumentationで計測可能になった(翻訳) first appeared on TechRacho.
概要 元サイトの許諾を得て翻訳・公開いたします。 英語記事: Completely custom Zeitwerk inflector | Arkency Blog 原文公開日: 2024/02/06 原著者: Piotr Jurewicz 日本語タイトルは内容に即したものにしました。 inflectorは英ママとしました。 Rails: Zeitwerkで使えるカスタムinflectorの作り方4種(翻訳) 前回の記事では、クラシックオートローダーとZeitwerkオートローダーがファイル名を照合するときの違いについて解説しました。両者の違いを取り急ぎ以下にまとめておきます。 クラシックオートローダー Repo […]
The post Rails: Zeitwerkで使えるカスタムinflectorの作り方4種(翻訳) first appeared on TechRacho.
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
inflectorは英ママとしました。
前回の記事では、クラシックオートローダーとZeitwerkオートローダーがファイル名を照合するときの違いについて解説しました。両者の違いを取り急ぎ以下にまとめておきます。
Report::PL::X123
という定数名が見つからない場合、Report::PL::X123.to_s.underscore
を呼び出すことでファイル名にマッピングする。lib/report/pl/x123/products.rb
を見つけ、Zeitwerkで定義されている語形変化(inflection)ルールを利用してReport::PL::X123::Products
という定数名にマッピングする。一般に、inflectorは事前定義済みのルールに沿って語を変換するソフトウェアコンポーネントです。Ruby on RailsのようなWebフレームワークのコンテキストでは、「複数形への変換」「単数形への変換」「頭字語の処理」「属性名をhumanize
で総合的に変換する」といった言語上の変換にinflectorが使われます。
Rails::Autoloader::Inflector
は、ZeitwerkとRailsの統合ではデフォルトで使われます。
module Rails
class Autoloaders
module Inflector # :nodoc:
@overrides = {}
def self.camelize(basename, _abspath)
@overrides[basename] || basename.camelize
end
def self.inflect(overrides)
@overrides.merge!(overrides)
end
end
end
end
上のcamelize
メソッドは、@overrides
に名前basename
があるかどうかをチェックして、あればそれを使い、なければString#camelize
メソッドを呼び出します。String#camelize
メソッドは、Active SupportのString
コア拡張です。
def camelize(first_letter = :upper)
case first_letter
when :upper
ActiveSupport::Inflector.camelize(self, true)
when :lower
ActiveSupport::Inflector.camelize(self, false)
else
raise ArgumentError, "Invalid option, use either :upper or :lower."
end
end
上のコードでわかるように、String#camelize
は内部でActiveSupport::Inflector
に委譲しています。
ActiveSupport::Inflector
は最初期のRailsから存在しており、語を単数形から複数形に変換したり、クラス名をテーブル名に変換したり、モジュール化されたクラス名をモジュール化されていないクラス名に変換したり、クラス名を外部キーに変換したりするのに使われます。
しかしZeitwerkのコンテキストにおけるinflectorの重要な機能は、頭字語(2個以上の大文字でできている語)の処理です。
頭字語の例として「REST」(Representational State Transfer)を考えてみましょう。API::REST::Client
などのように頭字語が定数に含まれることは珍しくありません。
クラシックオートローダーの場合、未定義の定数API::REST::Client
に遭遇すると、 API::REST::Client.to_s.underscore
を呼び出して、オートロード済みのディレクトリの中から api/rest/client.rb
ファイルを見つけます。
一方Zeitwerkは、api/rest/client.rb
ファイルを見つけて'api/rest/client'.camelize
を呼び出します。頭字語を処理するルールがない場合は、Api::Rest::Client
という定数名を得ます。
頭字語を小文字に変えないAPI::REST::Client
という結果を得るには、頭字語処理ルールにinflectorを提供する必要があります。本記事では、この結果を得られる方法を4通り紹介します。
ActiveSupport::Inflector
を直接設定するよく使われる直感的な方法は、ActiveSupport::Inflector
を直接設定する方法です。ただしこの方法では、Active Supportのグローバルな語形変化に影響するので、必ずしも望ましくない可能性があります。
# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym 'API'
inflect.acronym 'REST'
end
Rails::Autoloader::Inflector
をコンフィグでオーバーライドする場合によっては、特定のクラス名やモジュール名に関するルールをActive Supportの語形変化に加えたくないこともあります。実はそんなことをしなくても、Zeitwerkでのみ特定の語形変化をオーバーライドし、Railsのグローバルな語形変化を変更しないようにする方法があります。ただし、指定のキーが見つからない場合はString#camelize
とActiveSupport::Inflector
に引き続きフォールバックします。
# config/initializers/zeitwerk.rb
Rails.autoloaders.each do |autoloader|
autoloader.inflector.inflect(
"api" => "API",
"rest" => "REST",
)
end
Zeitwerk::Inflector
を利用するZeitwerk gemはRailsから独立して利用できるよう設計されており、Rails::Autoloader::Inflector
の代わりに利用可能なinflectorの別実装を提供しています。これを使うことで、モジュール名やクラス名の命名規則で使う頭字語を1箇所で完全に制御できるようになります。さらに、Active Supportの汎用inflectorがオートローダー固有のルールで汚染されることも避けられるようになります。
# config/initializers/zeitwerk.rb
Rails.autoloaders.each do |autoloader|
autoloader.inflector = Zeitwerk::Inflector.new
autoloader.inflector.inflect(
"api" => "API",
"rest" => "REST",
)
end
API::REST::Client
定数に加えてUser::Activities::Rest
定数もコードベースに存在する状況を考えてみましょう。
どちらの定数にも/rest/i
という部分文字列が含まれていますが、同じ語形変化規則を使ってファイル名からこれらの定数名を導出することはできません。
これは、カスタムinflector実装を提供する必要が生じる良い例となります。
理解を深めるために、Rails標準のRails::Autoloader::Inflector#camelize
メソッドの実装をもう一度見てみましょう。
def self.camelize(basename, _abspath)
@overrides[basename] || basename.camelize
end
見てのとおり、このメソッドはbasename
と_abspath
という2つの引数を受け取ります。basename
は拡張子を除いたファイル名、_abspath
はそのファイルへの絶対パスです。
この_abspath
は、Rails::Autoloader::Inflector
実装でもZeitwerk::Inflector
実装でも未使用になっている点にご注目ください。
カスタム実装では、この_abspath
引数の存在を利用できます。
# config/initializers/zeitwerk.rb
class UnconventionalInflector
def self.conditional_inflection_for(basename:, inflection:, path:)
Module.new do
define_method :camelize do |basename_, abspath|
if basename_ == basename && path.match?(abspath)
inflection
else
super(basename_, abspath)
end
end
end
end
prepend conditional_inflection_for(
basename: 'rest',
inflection: 'REST',
path: /\A#{Rails.root.join('lib', 'api')}/,
)
# ...
def initialize
@inflector = Rails::Autoloader::Inflector
end
def camelize(basename, abspath)
@inflector.camelize(basename, abspath)
end
def inflect(overrides)
@inflector.inflect(overrides)
end
end
Rails.autoloaders.each do |autoloader|
autoloader.inflector = UnconventionalInflector.new
autoloader.inflector.inflect(
'api' => 'API'
)
end
上の実装ではRails::Autoloader::Inflector
モジュールを利用していますが、そのcamelize
実装には、ファイルパスが不規則語形活用ルールにマッチするかどうかをチェックするコードがprepend
されています。マッチする場合は不規則語形変化を使い、マッチしない場合はデフォルトの実装にフォールバックします。
Rest
とREST
を共存させるコード例はわざとらしく見えるかもしれませんが、要点を説明するうえでは有用です。私たちが手掛けているプロジェクトでカスタムinflectorが非常に有用であることが判明したのと同様に、現実の業務でもカスタムinflectorを実装する理由付けに説得力が生じるでしょう。
The post Rails: Zeitwerkで使えるカスタムinflectorの作り方4種(翻訳) first appeared on TechRacho.
概要 元サイトの許諾を得て翻訳・公開いたします。 英語記事: The mysterious litany of require_dependecy calls | Arkency Blog 原文公開日: 2024/02/05 原著者: Piotr Jurewicz 日本語タイトルは内容に即したものにしました。 参考: Classic から Zeitwerk への移行 - Railsガイド Rails: Zeitwerk対応のためイニシャライザで大量のrequire_dependecyを削除(翻訳) ある大規模なレガシーアプリに取り組んでいるときに、時代遅れのクラシックオートローダーから最新のZeitwerkオート […]
The post Rails: Zeitwerk対応のためイニシャライザで大量のrequire_dependecyを削除(翻訳) first appeared on TechRacho.
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
ある大規模なレガシーアプリに取り組んでいるときに、時代遅れのクラシックオートローダーから最新のZeitwerkオートローダーに乗り換えるという課題に直面しました。
ZeitwerkはRails 6ではオプションとなっていましたが、Rails 7からは必須になりました。
これまで使っていたRails 6では、新しいデフォルトのほとんどについてはどうにか対応しましたが、いよいよZeitwerkに切り替える時期が到来したと判断しました。
...しかし話はここから始まるのでした...
このコードベースで多くの時間を費やすうちに、require_dependency
を300回以上も呼び出す巨大なイニシャライザを1つ見つけました。
最初の兆候は、イニシャライザでリストされているファイルがすべてオートロード済みディレクトリの下に置かれていたことです。
公式のRailsガイドには以下のようにはっきり書かれています。
Zeitwerkによって、
require_dependency
の既知のユースケースはすべて削除されました。プロジェクトをgrepしてrequire_dependency
をすべて削除してください。
しかしrequire_dependency
を削除する前に、こんなファイルがある理由と背景を明らかにしておきたいと思いました。そのファイルの冒頭には、不安をかきたてるコメントが書かれていたのです。
# すべてのReportingモジュールをプリロードすること
# さもないと「uninitialized constant errors」が起きるかもしれない
#(主にrakeタスクで)
かなり怖いですよね?私も同感でした。production環境にNameErrorsを導入したい人などいるのでしょうか?もちろん私は違います。
実際には、Sentryを用いてこれらのエラートレースの一部をどうにか見つけることに成功しましたが、そのエラーをローカルで再現できなかったのです。そこで環境にどんな違いがあるのかを深掘りし始めました。
production.rb
ではeager loadingが有効になっていました。productionのポッドは、DebianベースのあるLinuxディストリビューション上で動いており、ローカルのdevelopment環境はmacOSでした。
そのmacOSのファイルシステムは大文字と小文字を区別しないデフォルト設定になっていましたが、Linuxのファイルシステムは大文字小文字を区別する設定になっていました。
もうひとつ気付いた点は、謎のイニシャライザ内のファイルリストを見ると、lib/report/PL/X123/products
のように大文字小文字が通常なら使わないような形でパスに含まれていたことでした。
クラシックオートローダーでeager loadingを無効にすると、定数名をファイル名に変換するときにReport::PL::X123.to_s.underscore
が呼び出され、その結果report/pl/x123/products
が生成されました。
このマジックは、Module#const_missing
メソッド内で未定義定数を参照するたびに発生します(これはよく知られているmethod_missing
コールバックと似ています)。このconst_missing
メソッドはRuby標準の実装ではエラーになりますが、Railsはconst_missing
メソッドをオーバーライドして、オートロード済みのディレクトリの中からファイルを見つけようとします。
しかし、大文字と小文字を区別するファイルシステムではreport/pl/x123/products.rb
は存在しないのと同じになります。これが、起動時にすべてのコードベースでeager loadingを行わない限りproduction環境でNameErrorsが発生する理由の手がかりです(Railsでeager loadingを有効にすると、起動中にすべてのファイルをeager_load_paths
で読み込みます)。
❯ ls lib/report/PL/X123/products.rb
lib/report/PL/X123/products.rb
❯ ls lib/report/pl/x123/products.rb
lib/report/pl/x123/products.rb
$ ls lib/report/PL/X123/products.rb
lib/report/PL/X123/products.rb
$ ls lib/report/pl/x123/products.rb
ls: cannot access 'lib/report/pl/x123/products.rb': No such file or directory
Zeitwerkオートローダーの振る舞いは、これと逆です。
Zeitwerkは、オートロードされたディレクトリにあるファイルをリストし、個別のファイルで.delete_suffix!(".rb").camelize
を呼び出すことでファイル名を定数名に変換します。この変換では語形変化(inflection)ルールが考慮されてReport::PL::X123::Products
という結果を得ますが、ファイルシステムが大文字小文字を区別するかどうかには影響されません。
Zeitwerkは、Ruby組み込みの機能であるModule#autoload
を用いて、定数の読み込み元となるファイルを指定します。
# 起動時
autoload :Report, Rails.root.join('lib/report')
# Reportを最初に参照するとき
Report.autoload :PL, Rails.root.join('lib/report/pl')
# Report::PLを最初に参照するとき
Report::PL.autoload :X123, Rails.root.join('lib/report/PL/x123')
# Report::PL::X123を最初に参照するとき
Report::PL::X123.autoload :Products, Rails.root.join('lib/report/PL/X123/products.rb')
これをシンプルな言葉で説明すると以下のようになります。
Report::PL::X123::Products
に遭遇し、
それが定数テーブルにない場合は
lib/report/PL/X123/products.rb
を読み込む。
ここまで理解できたので、イニシャライザで延々と繰り返されるrequire_dependency
を自信を持って削除できました。作業はスムーズに終わり、NameErrorsは二度と発生しませんでした。
いずれにしろ、私は今後プロジェクトのファイルツリーで大文字を含むファイルを見かけるたびに、疑いのまなざしを向けるつもりです。
The post Rails: Zeitwerk対応のためイニシャライザで大量のrequire_dependecyを削除(翻訳) first appeared on TechRacho.
こんにちは、hachi8833です。 Railsの作者として有名なDHH氏がMacを捨ててWindows+WSL環境に変える宣言をしたのが話題になってるな。AppleのEUにおける一連のふぁっきんな対応にブチ切れたらしい😅RubiestならばMacという雰囲気があったが、これでどうなることやらw /Committing to Windows https://t.co/IMl32lPwQa — AOE Takashi (@aoetk) March 7, 2024 つっつきボイス:「Macを使わなくなるわけではなくてメインマシンをWindowsにするということみたい」「ところでDHHって最近までエディタに […]
The post 週刊Railsウォッチ: Rubyでシリアルポートにアクセス、Active Record vs Sequelほか(20240313後編) first appeared on TechRacho.
こんにちは、hachi8833です。
Railsの作者として有名なDHH氏がMacを捨ててWindows+WSL環境に変える宣言をしたのが話題になってるな。AppleのEUにおける一連のふぁっきんな対応にブチ切れたらしい
RubiestならばMacという雰囲気があったが、これでどうなることやらw /
Committing to Windows https://t.co/IMl32lPwQa— AOE Takashi (@aoetk) March 7, 2024
つっつきボイス:「Macを使わなくなるわけではなくてメインマシンをWindowsにするということみたい」「ところでDHHって最近までエディタにTextMate使っていたのね: 遠い昔にDHHが初めてRailsをお披露目したときの有名な動画↓でTextMateを使っていたこともあって、Macユーザーで使ってた人も割といた印象があるかな」「あの"15分でWebアプリを作る"動画ですね」
「自分が以前Sublime Textを使ってたときにTextMateを見かけたことはあったんですが、ついに使わないままVS Codeに乗り換えました」「TextMateは昔使ったことあったけど、日本語を入力するにはTextMate用のフォントを作ったりといろいろ手間かけないといけなかったんですよ」「TextMateってWindows版なかったのか...」
参考: TextMate: Text editor for macOS
参考: Macの定番テキストエディタ「TextMate」を入れてみたよ&日本語入力できるようにしたよ - bojovs blog
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)
お知らせ: 来週の週刊Railsウォッチはお休みをいただき、通常記事を公開いたします
つっつきボイス:「自分の管理下にない外部のJavaScriptコード(ここではgtag)を、書き方を変えずにTurboでやる方法がないかどうかを尋ねている書き込みを見つけました」「たぶんページ読み込みの時点で存在しているElementに対して最初に1回だけ一括でaddEventListener()
するような実装がいけないんじゃないかな」「Discussionの回答を見てもそのままでは無理っぽいですね」「extreme challengeとあるぐらいなのでめちゃくちゃ難しいということか〜」
// discuss.rubyonrails.orgより
console.log('current page: ', window.location.href)
document.addEventListener('click', function() {
console.log('document click')
})
document.querySelectorAll('a').forEach(function(a) {
a.addEventListener('click', function() {
console.log('a click')
})
})
「現代のWebフロントエンド環境ではDOM要素がちょくちょく再レンダリングされたりするので、addEventListener()
とかで現在あるDOM要素をイベントリスナーに登録するみたいな方法は現代だともしかしたら厳しいかも?」「う〜む」「こういうので現在のDOMにあるものを登録しても、DOMが更新されたらイベントリスナーごと消えてしまうので、たぶん他の現代的なフレームワークでもうまくいかなそうな気がする」
参考: EventTarget: addEventListener()
メソッド - Web API | MDN
HTML4
版にフォールバックした(その後修正)期待する振る舞い
sprockets-railsを使うかどうかにかかわらず、
ActionView::Helpers::SanitizeHelper.sanitizer_vendor
はRails::HTML5::Sanitizer
になるはず。new_framework_defaults_7_1.rb
でもエラーにならない。実際の振る舞い
- 1.Railsを7.0から7.1にアップグレードする場合:
sprockets-rails
を削除してpropshaft
に置き換えると、new_framework_defaults_7_1.rb
のコードがuninitialized constant Rails::HTML
例外を発生する(rails-html-sanitizer
がデフォルトではrequire
されていないため↓)。
- 2. Rails 7.1でsprockets-railsを使わない場合:
sprockets-rails
ではなくpropshaft
を選択すると、Railsガイドに書かれている振る舞いと異なり、sanitizer_vendor
にRails::HTML4::Sanitizer
が設定される。
同issueより抜粋
参考: §3.11.21 config.action_view.sanitizer_vendor
-- Rails アプリケーションの設定項目 - Railsガイド
つっつきボイス:「ruby-jp Slackでたまたま見かけたissueです」「お、Rails 7.1からAction TextのサニタイザではデフォルトでRails::HTML5::SafeListSanitizer
が使われるようになった(ウォッチ20230802)けど、既存アプリをPropshaftにアップグレードしたらRails::HTML4::SafeListSanitizer
にフォールバックしたのか」「Rails 8でPropshaftがデフォルトになることが決まったので(ウォッチ20240228)、開発もPropshaftがメインになった感じかな」「issueと同じ方がプルリクも出していますね↓」
つっつきボイス:「SequelはJeremy Evansさんがずっと手掛けている、Active Recordのオルタナ的なgemですね」「Active Recordだとbelongs_to
とhas_many
で書くようなところを、Sequelだとone_to_many
とmany_to_one
というDSLで書くのね↓: この違いは興味深い」
# 同記事より
# models/product.rb
class Product < Sequel::Model
one_to_many :orders
end
# models/order.rb
class Order < Sequel::Model
many_to_one :product
end
# クエリ
product_orders_under_20 = DB[:orders].join(:products, id: :product_id).where { price < 20 }
「Sequelだと楽観的ロックをこんなふうに書けるのか↓」「Sequelが好きな人なら、Rails以外のRubyアプリでSequelを使うのは普通にありだと思います」
# 同記事より
# invoice.rb
class Invoice < Sequel::Model
plugin :optimistic_locking
end
https://t.co/qeSAGMHWL2 meetup Vol.28 / 既存アプリをTurbo8に対応させよう https://t.co/GnNfxajyD8
— TOKUHISA (@TOKUJPNNET) March 7, 2024
つっつきボイス:「3/14(木に)Hotwire Loveイベントがあるそうです」「既存アプリを新しいTurbo 8に対応させる話題なんですね」「盛り上がってますね〜」
つっつきボイス:「tenderloveさんがシリアルポートで遊んでみた記事です」「懐かしい、UARTは自分も使ったことありますよ」「コードで使っているuart gemもtenderloveさん作なんですね↓」
参考: UART - Wikipedia
# 同記事より
require "uart"
MASK = (~(3 << 14)) & 0xFFFF
UART.open ARGV[0], 115200 do |serial|
# turn on heartbeat
serial.write "<HEARTBEAT1>>"
loop do
if serial.wait_readable(2)
count = ((serial.readbyte << 8) | serial.readbyte) & MASK
p count
else
$stderr.puts "oh no, something went wrong!"
exit(1)
end
end
ensure
# make sure to turn off heartbeat
serial.write "<HEARTBEAT0>>"
end
「あれ、シリアル接続といえばRS-232Cは何を指すんでしたっけ?」「RS-232C自体はむかーしのPCに付いてたようなコネクタの規格のことで、広義のシリアル通信としてはRS-232Cでないものもあります: たとえばArduinoとかで使われてるようなマイコンだと、特定のピンをUART出力に割り当てることでそこからシリアルコンソールを取れたりしますが、こういうシリアルコンソール用のピンは基板上のパターンにだけ出ていたりするので、RS-232Cコネクタを使わないシリアル通信の口だったりします」
「基板によってはシリアルポートにアクセスすることでfastbootのプロンプトを取ってファームウェアを流し込んだりとか楽しいことができたりしますね」「そうそう、M5Stackあたりを触り始めると結局そうやって遊んだりしてます」
つっつきボイス:「Koanって何かと思ったら禅の公案のことみたい」「サイトはWasmで動くRubyクイズサイトなんですね」「英語圏の禅文化好きって昔からあるけど今も根強いですね」
参考: 公案 - Wikipedia
つっつき後に以下のツイートを見つけました↓。Ruby Koansは実は昔からあって、それをブラウザ版に移植したんですね。
WIP: In memory of @jimweirich, inventor of Rake and RubyKoans who passed 10 years ago.
I ported the Ruby Koans to the browser. Learn Ruby by fixing tests.https://t.co/WZwEgWFPSu#buildinpublic #ruby@lucianghinda @marcoroth_ @skryukov_dev @ledsun @hasumikin @KuokkanenSampo pic.twitter.com/2NmcCRMzcV— Andi / アンディ (@largo) February 16, 2024
つっつきボイス:「記事は定番の話ですが、Rubyで乱数が必要なときはKernel#rand
とかではなくsecurerandom gemを使うべきですね(追記2024/03/18: ご指摘に基づいて文面を修正しました)」「ちなみに言語付属のライブラリで、数値としての乱数だけでなくhexやUUID、BASE64形式のフォーマットまでしてくれるのは結構ありがたいですよ↓」
# 同記事より
require "securerandom"
SecureRandom.alphanumeric # "tpEnoWgScSJRU3YB"
SecureRandom.base64 # "0jHnJ7Yx5oTW0OY+YKgUog=="
SecureRandom.hex # "b51372ee8b93eb3e1f0035d9300c3e97"
SecureRandom.rand # 0.6053942880507039
SecureRandom.urlsafe_base64 # "OTHNscnomrNjjT0g_dzpdw"
SecureRandom.uuid # "f6f54bd8-fc5a-483f-8909-05428dea2290"
参考: class Random
(Ruby 3.3 リファレンスマニュアル)
こういうことですか pic.twitter.com/bXSCELsCLF
— ᗝᗝᗝᗝᗝᱝ (@8Q6QP6GH_QJ) June 4, 2021
つっつきボイス:「南京錠のようなものを論理回路に見立てる遊びって昔からときどき目にしますね」
「直接関係ないけど、木で計算機を作る人を実際に見たことあったのを思い出しました↓」「お〜これはすごい!」「ちゃんと動くんですね」「レタリングや仕上げも丁寧なのが素晴らしいです」
参考: 「からくり計算器」「ゲームボーイシンセサイザー」「手のひらサイズお絵かきマシン」──“自作”の祭典「Maker Faire Tokyo 2019」で見た個性的な作品たち(1/2 ページ) - ITmedia NEWS
参考: 山宮隆 | Ogaki Mini Maker Faire 2022
参考: カラクリ計算器とは何か? - 言語ゲーム
後編は以上です。
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
The post 週刊Railsウォッチ: Rubyでシリアルポートにアクセス、Active Record vs Sequelほか(20240313後編) first appeared on TechRacho.
こんにちは、hachi8833です。 今年は現地参加のみで、録画は後日公開ですね。https://t.co/AUga53JSRL> RubyKaigi 2024 is an in-person only conferenceRubyKaigi 2024 doesn't offer remote attendance option and live streams. But as usual, we'll record all sessions and will upload to YouTube after the conference. — NAITOH Jun (@naito […]
The post 週刊Railsウォッチ: Rails 8に入るSolid Cacheほか(20240312前編) first appeared on TechRacho.
こんにちは、hachi8833です。
今年は現地参加のみで、録画は後日公開ですね。https://t.co/AUga53JSRL
> RubyKaigi 2024 is an in-person only conference
RubyKaigi 2024 doesn't offer remote attendance option and live streams. But as usual, we'll record all sessions and will upload to YouTube after the conference.— NAITOH Jun (@naitoh) March 6, 2024
つっつきボイス:「今年のRubyKaigi 2024はライブ中継ないのか〜」「Super Earlybirdはもう売り切れなんですね」
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)
お知らせ: 来週の週刊Railsウォッチはお休みをいただき、通常記事を公開いたします
fixture
アクセサを追加minitestと競合する可能性のあるfixture名に対応するため、汎用の
fixture
アクセサを公開する。assert_equal "Ruby on Rails", web_sites(:rubyonrails).name assert_equal "Ruby on Rails", fixture(:web_sites, :rubyonrails).name
Jean Boussier
同Changelogより
assert_equal "Ruby on Rails", web_sites(:rubyonrails).name assert_equal "Ruby on Rails", fixture(:web_sites, :rubyonrails).name
これは
Metadata
モデルを持っている某氏から自分に提供されたもの。fixtureアクセサはmetadata
だが、Minitest
に最近追加されたmetadata
メソッドと競合する。cc: @zk-1
同PRより
つっつきボイス:「たしかにfixture名のような自動生成される名前が既存のものとかぶるのはよくある話」「factoryなんかもそうですね」「fixture()
で囲うことで名前衝突を回避できるようになるのはいいですね」
# activerecord/lib/active_record/test_fixtures.rb#L256
def method_missing(method, ...)
- if fs_name = fixture_sets[method.name]
- access_fixture(fs_name, ...)
+ if fixture_sets.key?(method.name)
+ fixture(method, ...)
else
super
end
end
...
+ def fixture(fixture_set_name, *fixture_names)
+ if fs_name = fixture_sets[fixture_set_name.name]
+ access_fixture(fs_name, *fixture_names)
+ else
+ raise StandardError, "No fixture set named '#{fixture_set_name.inspect}'"
+ end
+ end
「minitestの以下の変更でMinitest
モジュールにmetadata
というメソッドが入ったのがたまたまぶつかっていたことでこの問題に気づいたのね↓」
参考: + Add metadata lazy accessor to Runnable / Result. (matteeyah) · minitest/minitest@de80282
# lib/minitest.rb#L460
def metadata
@metadata ||= {}
end
assert_initializer
を追加
Rails::Generators::Testing::Assertions#assert_initializer
を導入。既存の
initializer
ジェネレータアクションを補完する。assert_initializer "mail_interceptors.rb"
Steve Polito
同Changelogより
つっつきボイス:「assert_initializer
は新しいアサーションですね」「config/initializers/ディレクトリの下に指定のイニシャライザが存在しているかとか中身をチェックしたりするアサーションということなのか」「内容はassert_file
のショートハンドというシンプルなものですね↓」「アプリのイニシャライザがいつの間にか書き換えられていないかどうかのチェックに使う感じなのかな」「Railsフレームワークのジェネレータテストでも今後使うかも?」
# railties/lib/rails/generators/testing/assertions.rb#L141
+ def assert_initializer(name, *contents)
+ assert_file("config/initializers/#{name}", *contents)
+ end
参考: § 5 イニシャライザファイルを使う -- Rails アプリケーションの設定項目 - Railsガイド
参考: Rails API assert_file
-- Rails::Generators::Testing::Assertions
default_url_options
をデフォルトで設定するよう修正development環境とtest環境で
action_mailer.default_url_options
値を設定する。このコミットが入る前は、新規Railsアプリケーションのメーラーに
*_path
helperでビルドしたURLが含まれているとActionView::Template::Error
エラーが発生していた。Steve Polito
同Changelogより
つっつきボイス:「default_url_options
コンフィグが未設定だと、開発中にAction Mailerのテンプレートで*_url
ヘルパーとかを書いたときにエラーになっちゃうので、この修正が欲しい気持ちわかる: 設定を自分で入れれば解決できるけど、そのひと手間を解消してくれるのは地味にありがたい」「Rails開発の長い人なら一度は経験しているでしょうね」「修正ではlocalhost:3000
がdeveloper環境とtest環境のデフォルトになるんですね」
# railties/lib/rails/generators/rails/app/templates/config/environments/development.rb.tt#L46
+
+ config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
参考: §4.5 default_url_options
--Action Controller の概要 - Railsガイド
コントローラと異なり、メーラーのインスタンスは受信リクエストに関するコンテキストをまったく持たないので、
:host
パラメータは自分で設定する必要がある。
config.action_mailer.default_url_options = { host: "www.example.com" }
railties/lib/rails/generators/rails/app/templates/config/environments/test.rb.tt#L47より
Model.query_constraints
のエラーメッセージを修正主キーでない単一のカラムで
Model.query_constraints
をraiseされるのは期待通りだが、そのエラーメッセージが正しくなかった。適切なエラーメッセージを表示するよう修正した。Joshua Young
同Changelogより
つっつきボイス:「単一主キー(いわゆるサロゲートキー)がないカラムでquery_constraints
がエラーをraiseするときの条件が適切でなかったのね↓」「複合主キー(composite primary key)を使っているときに起きる可能性があるエラーなんですね」
# activerecord/lib/active_record/reflection.rb#L786
def derive_fk_query_constraints(foreign_key)
primary_query_constraints = active_record.query_constraints_list
owner_pk = active_record.primary_key
- if primary_query_constraints.size != 2
+ if primary_query_constraints.size > 2
raise ArgumentError, <<~MSG.squish
The query constraints list on the `#{active_record}` model has more than 2
attributes. Active Record is unable to derive the query constraints
for the association. You need to explicitly define the query constraints
for this association.
MSG
end
「本当はこのメッセージが表示されるべきだったということですね↓」
# activerecord/test/cases/associations_test.rb#L381
assert_equal "The query constraints on the `Sharded::BlogPost` model does not include the primary key so Active Record is unable to derive the foreign key constraints for the association. You need to explicitly define the query constraints for this association.", error.message
Sharded::BlogPost
モデルのquery constraintsには主キーが含まれていないので、Active Recordはこの関連付けで外部キー制約を導出できない。この関連付けのquery constraintsを明示的に定義する必要がある。
Base.connection
をwith_connection
やlease_connection
に置き換えた振る舞いをより適切に反映するため
#lease_connection
によって置き換えられる。
ActiveRecord::Base.connection
も同様に非推奨化されるが、削除猶予期間や非推奨化警告の表示は行わない。今後内部で使われることのないようにするため、Active Recordのテストスイート内部から
Base.connection
を削除する。一部の呼び出し側(callsites)では
with_connection
を利用する形に変換され、他の呼び出し側の一部についてもよりシンプルな形でlease_connection
に移行された。このプルリクは、#50793に基づいて変換される呼び出し側リストとして使える。
同PRより
つっつきボイス:「これと次はプルリクリストから見繕ったものです」「前回見た#51083の続きということですね(ウォッチ20240228)」「修正量は多いけど、基本的にconnection
をwith_connection
やlease_connection
に置き換えている↓: 特定のコネクションでコネクションプールを直接使うのをやめて、トランザクション内で同じコネクションプールに再接続できるwith_connection
やlease_connection
に変えたいというような話でしたね」
# actionmailbox/test/migrations_test.rb#L6
class ActionMailbox::MigrationsTest < ActiveSupport::TestCase
setup do
@original_verbose = ActiveRecord::Migration.verbose
ActiveRecord::Migration.verbose = false
- @connection = ActiveRecord::Base.connection
+ @connection = ActiveRecord::Base.lease_connection
@original_options = Rails.configuration.generators.options.deep_dup
end
「この修正で従来のBase.connection
は非推奨になったということだけど、プルリクメッセージにも書かれているように、Base.connection
を非推奨化サイクルに乗せて今後削除することはしないという点がポイントですね: Base.connection
はとても広範囲に使われている定番のメソッドなので、仮に非推奨化アラートを表示すると、ありとあらゆるgemからものすごい量のアラートが発せられることになるでしょうね」「そうなったらつらいヤツですね」
「Base.connection
は今後も外部のgemなどでは引き続き使えるけど、Railsフレームワーク内からは呼び出さないようにした: これは最も適切な処置だと思います」
「非推奨化されるけどアラートも出さず今後削除されることもないというのはかなり珍しいパターンかも: これに気づかないまま使い続ける人もたくさんいそうですし、実際そういう対応でもいける内容なんでしょうね」
前方互換性維持のために残されているレガシー機能といえば、以下の記事で取り上げられているcontent_tag
を思い出しました↓。こちらは非推奨ではありませんが。
uncached
メソッドにdirties
オプションを追加このプルリクは、
ActiveRecord::Base.uncached
とActiveRecord::ConnectionAdapters::ConnectionPool#uncached
にdirties
オプションを追加する。
true
(デフォルト)に設定すると、書き込みで現在のスレッドに属するすべてのクエリキャッシュがクリアされる。
false
に設定すると、影響を受けるコネクションプールへの書き込みでクエリキャッシュがクリアされなくなる。これは、Solid Cacheでキャッシュ書き込みによってクエリキャッシュがクリアされないようにするために必要。
Donal McBreen
同Changelogより
つっつきボイス:「これもActiveRecord::Base
とかに関連しているので上の#51240と関連しているのかなと思ったら、Solid Cache向けの別の改修のようですね」
参考: Rails API uncached
-- ActiveRecord::QueryCache::ClassMethods
「この間からSolid Queueが何度も話題になっていますけど、DBをエンジンにしたクエリキャッシュであるSolid CacheもRails 8でデフォルトになる流れになっていますね↓」「Solid QueueとSolid Cacheの字面が似ててSolid Cacheの方に気づいてなかった...」「 データベースをキャッシュとして使うこと自体はキャッシュ用テーブルを作れば可能なので、そうした実装としてSolid Cacheを作ったということでしょうね」
参考: Solid Cache should be the default caching backend for Rails 8 · Issue #50443 · rails/rails
参考: Rails API ActiveRecord::ConnectionAdapters::QueryCache
「最近の動きを見ると、Rails 8ではrails new
したときにジョブキューやクエリキャッシュのためのインフラをさしあたって決めなくても済むような方向に進めている感じはしますね」「自分もそんな感じがしています」「前回話したように(ウォッチ20240306)、アプリケーションの負荷が大きいときはRDBの仕事をあまり増やしたくないけど、アプリを新規作成するときにジョブキューやクエリキャッシュをどれにするかみたいな、決める必要のある項目が減るというのは新規アプリ開発プロジェクトで嬉しいことであるのはたしか」
「Railsチュートリアルのような教育用途でもジョブキューやクエリキャッシュを学びやすくなりそうですね」「インフラのセットアップが減るのは教育目的でももちろん有用だと思いますが、おそらくDHHの頭にあるのは主に業務プロジェクトで使う新規アプリのセットアップ軽減と期間短縮なんじゃないかなと想像しています」「なるほど」
参考: Ruby on Rails チュートリアル:プロダクト開発の0→1を学ぼう
「なお、クエリキャッシュについてはファイルシステム上(FileStore)やメモリ上(MemoryStore)のキャッシュなら現状でも追加インフラなしでできますけど、異なる環境同士でキャッシュを共有できないという問題があるんですよ」「あ、そうでしたか」「ファイルベースのものはコンテナと相性があまりよくないし、メモリベースのものもコンテナに割り当てられるメモリはそんなに多くないので、そのまま本番のコンテナにデプロイしたときなんかにメモリ上のクエリキャッシュが増えるとメモリを使い切ってしまうといった懸念はありますね」「たしかに」「MemoryStoreはプロセスが違うと共有できませんし、FileStoreはコンテナ(ホスト)が違うと共有できませんけど、Solid Cacheはコンテナ間でも共有が可能な点が大きなメリットだと思います」
前編は以上です。
週刊Railsウォッチ: method_missingの引数を'...'に置き換え、JRuby Prism、Sidekiqのしくみほか(20240306)
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
The post 週刊Railsウォッチ: Rails 8に入るSolid Cacheほか(20240312前編) first appeared on TechRacho.
概要 元サイトの許諾を得て翻訳・公開いたします。 英語記事: The most underused pattern in Ruby | Arkency Blog 原文公開日: 2023/08/31 原著者: Szymon Fiedler 日本語タイトルは内容に即したものにしました。 Rubyでもっと活用されるべきValue Objectパターン(翻訳) 最近私たちのRailsEventStoreユーザーが、issue #1650を投稿しました。PostGIS拡張を利用しているPostgreSQLデータベースでRailsEventStoreを使いたいが、イベントやストリームで使うテーブルをセットアップするマイグレー […]
The post Rubyでもっと活用されるべきValue Objectパターン(翻訳) first appeared on TechRacho.
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
最近私たちのRailsEventStoreユーザーが、issue #1650を投稿しました。PostGIS拡張を利用しているPostgreSQLデータベースでRailsEventStoreを使いたいが、イベントやストリームで使うテーブルをセットアップするマイグレーションがUnsupportedAdapter
で失敗するとのことでした。
これまでRailsEventStoreでは、PostgreSQLアダプタとMySQL2アダプタとSQLiteアダプタをサポートしていました。しかしPostgreSQLでこのPostGIS拡張を使いたい場合は、activerecord-postgis-adapter
を追加インストールしたうえでdatabase.ymlにadapter: postgis
を設定する必要があります。私たちのコードは、以下で返される値に依存しています。
ActiveRecord::Base.connection.adapter_name.downcase
=> "postgis"
このときの私は「ちょろい修正で済む」と思っていました。PostGISは単なる拡張なので、マイグレーションを生成するときに内部でPostgreSQLと同じように扱う必要があり、同じデータ型を許可する必要があります。
簡単な修正と思いきや、実際には8ファイルもの変更が必要でした(実装4ファイル、テスト4ファイル)。どうも変です。それでは個別の修正を見てみましょう。
VerifyAdapter
クラスのSUPPORTED_ADAPTERS
リストにpostgis
を追加する必要がありました。
module RubyEventStore
module ActiveRecord
UnsupportedAdapter = Class.new(StandardError)
class VerifyAdapter
- SUPPORTED_ADAPTERS = %w[mysql2 postgresql sqlite].freeze
+ SUPPORTED_ADAPTERS = %w[mysql2 postgresql postgis sqlite].freeze
def call(adapter)
raise UnsupportedAdapter, "Unsupported adapter" unless supported?(adapter)
end
private
private_constant :SUPPORTED_ADAPTERS
def supported?(adapter)
SUPPORTED_ADAPTERS.include?(adapter.downcase)
end
end
end
end
次に、ForeignKeyOnEventIdMigrationGenerator#each_migration
メソッドのcase
文も拡張が必要でした。
module RubyEventStore
module ActiveRecord
class ForeignKeyOnEventIdMigrationGenerator
def call(database_adapter, migration_path)
VerifyAdapter.new.call(database_adapter)
each_migration(database_adapter) do |migration_name|
path = build_path(migration_path, migration_name)
write_to_file(path, migration_code(database_adapter, migration_name))
end
end
private
def each_migration(database_adapter, &block)
case database_adapter
- when "postgresql"
+ when "postgresql", "postgis"
[
'add_foreign_key_on_event_id_to_event_store_events_in_streams',
'validate_add_foreign_key_on_event_id_to_event_store_events_in_streams'
]
else
['add_foreign_key_on_event_id_to_event_store_events_in_streams']
end.each(&block)
end
def absolute_path(path)
File.expand_path(path, __dir__)
end
def migration_code(database_adapter, migration_name)
migration_template(template_root(database_adapter), migration_name).result_with_hash(migration_version: migration_version)
end
def migration_template(template_root, name)
ERB.new(File.read(File.join(template_root, "#{name}_template.erb")))
end
def template_root(database_adapter)
absolute_path("./templates/#{template_directory(database_adapter)}")
end
def template_directory(database_adapter)
TemplateDirectory.for_adapter(database_adapter)
end
def migration_version
::ActiveRecord::Migration.current_version
end
def timestamp
Time.now.strftime("%Y%m%d%H%M%S")
end
def write_to_file(path, migration_code)
File.write(path, migration_code)
end
def build_path(migration_path, migration_name)
File.join("#{migration_path}", "#{timestamp}_#{migration_name}.rb")
end
end
end
end
Railsのマイグレーションジェネレータでも同じ変更が必要でした。
begin
require "rails/generators"
rescue LoadError
end
if defined?(Rails::Generators::Base)
module RubyEventStore
module ActiveRecord
class RailsForeignKeyOnEventIdMigrationGenerator < Rails::Generators::Base
class Error < Thor::Error
end
namespace "rails_event_store_active_record:migration_for_foreign_key_on_event_id"
source_root File.expand_path(File.join(File.dirname(__FILE__), "../generators/templates"))
def initialize(*args)
super
VerifyAdapter.new.call(adapter)
rescue UnsupportedAdapter => e
raise Error, e.message
end
def create_migration
case adapter
- when "postgresql"
+ when "postgresql", "postgis"
template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
template "#{template_directory}validate_add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_validate_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
else
template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
end
end
private
def adapter
::ActiveRecord::Base.connection.adapter_name.downcase
end
def migration_version
::ActiveRecord::Migration.current_version
end
def timestamp
Time.now.strftime("%Y%m%d%H%M%S")
end
def template_directory
TemplateDirectory.for_adapter(adapter)
end
end
end
end
end
ここで重要なのは、どちらのマイグレータもVerifyAdapter
クラスを使っていたことです(次の2つのマイグレータもそうでした)。
TemplateDirectory
クラスもプリミティブな強迫観念に悩まされており、これもあらゆるマイグレータで使われていました。
module RubyEventStore
module ActiveRecord
class TemplateDirectory
def self.for_adapter(database_adapter)
case database_adapter.downcase
- when "postgresql"
+ when "postgresql", "postgis"
"postgres/"
when "mysql2"
"mysql/"
end
end
end
end
end
さらにもうひとつ、VerifyDataTypeForAdapter
についてもそうでした。これはVerifyAdapter
のコンポジションで、指定のデータベースエンジンで利用可能なデータ型の検証を追加していました。
しかしその後、文字列値のチェックがより具体的なコンテキストで繰り返されていました。
# frozen_string_literal: true
module RubyEventStore
module ActiveRecord
InvalidDataTypeForAdapter = Class.new(StandardError)
class VerifyDataTypeForAdapter
SUPPORTED_POSTGRES_DATA_TYPES = %w[binary json jsonb].freeze
SUPPORTED_MYSQL_DATA_TYPES = %w[binary json].freeze
SUPPORTED_SQLITE_DATA_TYPES = %w[binary].freeze
def call(adapter, data_type)
VerifyAdapter.new.call(adapter)
raise InvalidDataTypeForAdapter, "MySQL2 doesn't support #{data_type}" if is_mysql2?(adapter) && !SUPPORTED_MYSQL_DATA_TYPES.include?(data_type)
raise InvalidDataTypeForAdapter, "sqlite doesn't support #{data_type}" if is_sqlite?(adapter) && supported_by_sqlite?(data_type)
raise InvalidDataTypeForAdapter, "PostgreSQL doesn't support #{data_type}" unless supported_by_postgres?(data_type)
end
private
private_constant :SUPPORTED_POSTGRES_DATA_TYPES, :SUPPORTED_MYSQL_DATA_TYPES, :SUPPORTED_SQLITE_DATA_TYPES
def is_sqlite?(adapter)
adapter.downcase.eql?("sqlite")
end
def is_mysql2?(adapter)
adapter.downcase.eql?("mysql2")
end
def supported_by_sqlite?(data_type)
!SUPPORTED_SQLITE_DATA_TYPES.include?(data_type)
end
def supported_by_postgres?(data_type)
SUPPORTED_POSTGRES_DATA_TYPES.include?(data_type)
end
end
end
end
これらの作業を専用のValue Objectにすべて任せることで、コード内での決定木を削減し、同じプリミティブを何度でもチェックできるようになります。
以下のような感じになります。
DatabaseAdapter.from_string("postgres")
=> DatabaseAdapter::PostgreSQL.new
DatabaseAdapter.from_string("bazinga")
=> UnsupportedAdapter: "bazinga" (RubyEventStore::ActiveRecord::UnsupportedAdapter)
DatabaseAdapter.from_string("sqlite", "jsonb")
=> SQLite doesn't support "jsonb". Supported types are: binary. (RubyEventStore::ActiveRecord::InvalidDataTypeForAdapter)
何度か試行錯誤した後、最終的に以下の実装が完成しました。
# frozen_string_literal: true
module RubyEventStore
module ActiveRecord
UnsupportedAdapter = Class.new(StandardError)
InvalidDataTypeForAdapter = Class.new(StandardError)
class DatabaseAdapter
NONE = Object.new.freeze
class PostgreSQL < self
SUPPORTED_DATA_TYPES = %w[binary json jsonb].freeze
def adapter_name
"postgresql"
end
def template_directory
"postgres/"
end
end
class MySQL < self
SUPPORTED_DATA_TYPES = %w[binary json].freeze
def adapter_name
"mysql2"
end
def template_directory
"mysql/"
end
end
class SQLite < self
SUPPORTED_DATA_TYPES = %w[binary].freeze
def adapter_name
"sqlite"
end
end
def initialize(adapter_name, data_type)
raise UnsupportedAdapter if instance_of?(DatabaseAdapter)
validate_data_type!(data_type)
@data_type = data_type
end
attr_reader :data_type
def supported_data_types
self.class::SUPPORTED_DATA_TYPES
end
def eql?(other)
other.is_a?(DatabaseAdapter) && adapter_name.eql?(other.adapter_name)
end
alias == eql?
def hash
DatabaseAdapter.hash ^ adapter_name.hash
end
def template_directory
end
def self.from_string(adapter_name, data_type = NONE)
raise NoMethodError unless eql?(DatabaseAdapter)
case adapter_name.to_s.downcase
when "postgresql", "postgis"
PostgreSQL.new(data_type)
when "mysql2"
MySQL.new(data_type)
when "sqlite"
SQLite.new(data_type)
else
raise UnsupportedAdapter, "Unsupported adapter: #{adapter_name.inspect}"
end
end
private
def validate_data_type!(data_type)
if !data_type.eql?(NONE) && !supported_data_types.include?(data_type)
raise InvalidDataTypeForAdapter,
"#{class_name} doesn't support #{data_type.inspect}. Supported types are: #{supported_data_types.join(", ")}."
end
end
def class_name
self.class.name.split("::").last
end
end
end
end
DatabaseAdadpter
クラスは、あらゆる個別のアダプタの親クラスのように振る舞います。個別のアダプタにはsupported_data_types
リストが含まれていてクライアントクラスからアクセス可能になっており、選択したデータが指定のデータベースエンジンでサポートされていない場合は適切なエラーメッセージを返します。また、指定したアダプタでtemplate_directory
に設定されている名前も取得できます。
つまり、DatabaseAdapter.from_string
を含む単一のエントリを持つことになります。DatabaseAdapter.from_string
にはadapter_name
と、オプションのdata_type
を渡せます。これらはどちらも個別のアダプタのインスタンス生成時にバリデーションされます。
以下のユーティリティクラスは削除できます。
VerifyAdapter
VerifyDataTypeForAdapter
TemplateDirectory
処理に必要な情報がValue Objectにすべて含まれているおかげで、4つのクラスと2つのrakeタスクがシンプルになりました。
ForeignKeyOnEventIdMigrationGenerator
module RubyEventStore
module ActiveRecord
class ForeignKeyOnEventIdMigrationGenerator
- def call(database_adapter, migration_path)
- VerifyAdapter.new.call(database_adapter)
+ def call(database_adapter_name, migration_path)
+ database_adapter = DatabaseAdapter.from_string(database_adapter_name)
each_migration(database_adapter) do |migration_name|
path = build_path(migration_path, migration_name)
write_to_file(path, migration_code(database_adapter, migration_name))
def each_migration(database_adapter, &block)
case database_adapter
- when "postgresql", "postgis"
+ when DatabaseAdapter::PostgreSQL
[
'add_foreign_key_on_event_id_to_event_store_events_in_streams',
'validate_add_foreign_key_on_event_id_to_event_store_events_in_streams'
RailsForeignKeyOnEventIdMigrationGenerator
def initialize(*args)
super
- VerifyAdapter.new.call(adapter)
+ @database_adapter = DatabaseAdapter.from_string(adapter_name)
rescue UnsupportedAdapter => e
raise Error, e.message
end
def create_migration
- case adapter
- when "postgresql", "postgis"
- template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
+ case @database_adapter
+ when DatabaseAdapter::PostgreSQL
+ template "#{@database_adapter.template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
- template "#{template_directory}validate_add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
+ template "#{@database_adapter.template_directory}validate_add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_validate_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
else
- template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
+ template "#{@database_adapter.template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
end
end
private
- def adapter
- ::ActiveRecord::Base.connection.adapter_name.downcase
+ def adapter_name
+ ::ActiveRecord::Base.connection.adapter_name
end
- def template_directory
- TemplateDirectory.for_adapter(adapter)
- end
MigrationGenerator
module RubyEventStore
module ActiveRecord
class MigrationGenerator
- DATA_TYPES = %w[binary json jsonb].freeze
- def call(data_type, database_adapter, migration_path)
- raise ArgumentError, "Invalid value for data type. Supported for options are: #{DATA_TYPES.join(", ")}." unless DATA_TYPES.include?(data_type)
- VerifyDataTypeForAdapter.new.call(database_adapter, data_type)
- migration_code = migration_code(data_type, database_adapter)
+ def call(database_adapter, migration_path)
+ migration_code = migration_code(database_adapter)
path = build_path(migration_path)
write_to_file(migration_code, path)
path
- def migration_code(data_type, database_adapter)
- migration_template(template_root(database_adapter), "create_event_store_events").result_with_hash(migration_version: migration_version, data_type: data_type)
+ def migration_code(database_adapter)
+ migration_template(template_root(database_adapter), "create_event_store_events").result_with_hash(migration_version: migration_version, data_type: database_adapter.data_type)
end
def template_root(database_adapter)
- absolute_path("./templates/#{template_directory(database_adapter)}")
- end
- def template_directory(database_adapter)
- TemplateDirectory.for_adapter(database_adapter)
+ absolute_path("./templates/#{database_adapter.template_directory}")
end
RailsMigrationGenerator
class Error < Thor::Error
end
- DATA_TYPES = %w[binary json jsonb].freeze
namespace "rails_event_store_active_record:migration"
source_root File.expand_path(File.join(File.dirname(__FILE__), "../generators/templates"))
type: :string,
default: "binary",
desc:
- "Configure the data type for `data` and `meta data` fields in Postgres migration (options: #{DATA_TYPES.join("/")})"
+ "Configure the data type for `data` and `meta data` fields in migration (options: #{DatabaseAdapter::PostgreSQL.new.supported_data_types.join(", ")})"
)
def initialize(*args)
super
- if DATA_TYPES.exclude?(data_type)
- raise Error, "Invalid value for --data-type option. Supported for options are: #{DATA_TYPES.join(", ")}."
- end
- VerifyDataTypeForAdapter.new.call(adapter, data_type)
- rescue InvalidDataTypeForAdapter, UnsupportedAdapter => e
+ @database_adapter = DatabaseAdapter.from_string(adapter_name, data_type)
+ rescue UnsupportedAdapter => e
+ raise Error, e.message
+ rescue InvalidDataTypeForAdapter
+ raise Error,
+ "Invalid value for --data-type option. Supported for options are: #{DatabaseAdapter.from_string(adapter_name).supported_data_types.join(", ")}."
end
def create_migration
- template "#{template_directory}create_event_store_events_template.erb", "db/migrate/#{timestamp}_create_event_store_events.rb"
+ template "#{@database_adapter.template_directory}create_event_store_events_template.erb",
"db/migrate/#{timestamp}_create_event_store_events.rb"
end
private
- def template_directory
- TemplateDirectory.for_adapter(adapter)
- end
def data_type
options.fetch("data_type")
end
- def adapter
- ::ActiveRecord::Base.connection.adapter_name.downcase
+ def adapter_name
+ ::ActiveRecord::Base.connection.adapter_name
end
db:migrations:copy
タスクおよびdb:migrations:add_foreign_key_on_event_id
タスク task "db:migrations:copy" do
data_type =
ENV["DATA_TYPE"] || raise("Specify data type (binary, json, jsonb): rake db:migrations:copy DATA_TYPE=json")
::ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"])
- database_adapter = ::ActiveRecord::Base.connection.adapter_name
+ database_adapter =
+ RubyEventStore::ActiveRecord::DatabaseAdapter.from_string(::ActiveRecord::Base.connection.adapter_name, data_type)
path =
- RubyEventStore::ActiveRecord::MigrationGenerator.new.call(
- data_type,
- database_adapter,
- ENV["MIGRATION_PATH"] || "db/migrate"
- )
+ RubyEventStore::ActiveRecord::MigrationGenerator.new.call(database_adapter, ENV["MIGRATION_PATH"] || "db/migrate")
puts "Migration file created #{path}"
end
@@ -30,7 +27,8 @@ desc "Generate migration for adding foreign key on event_store_events_in_streams
task "db:migrations:add_foreign_key_on_event_id" do
::ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"])
- path = RubyEventStore::ActiveRecord::ForeignKeyOnEventIdMigrationGenerator.new.call(ENV["MIGRATION_PATH"] || "db/migrate")
+ path =
+ RubyEventStore::ActiveRecord::ForeignKeyOnEventIdMigrationGenerator.new.call(ENV["MIGRATION_PATH"] || "db/migrate")
puts "Migration file created #{path}"
end
このように、条件分岐を削減して多数のprivateメソッドを削除できました。
上述のクラスのテストも大幅にシンプルなものになり、どのデータ型がどのアダプタと互換性があるかを個別にチェックする代わりに、クラスの中核となる責務のテストに専念できるようになりました。
間もなくRails 7.1でTrilogyと呼ばれる新しいMySQLアダプタが追加される予定です(訳注: その後7.1でTrilogyが追加されました)。この追加にも対応できたら素晴らしいですよね。
Trilogyへの対応は、コードを1行変更し、テストを1行追加するだけですべて完了しました。私たちが既に優れた抽象化を手にしていたからこそ可能だったのです。
module RubyEventStore
module ActiveRecord
class DatabaseAdapter
def self.from_string(adapter_name, data_type = NONE)
raise NoMethodError unless eql?(DatabaseAdapter)
case adapter_name.to_s.downcase
when "postgresql", "postgis"
PostgreSQL.new(data_type)
- when "mysql2"
+ when "mysql2", "trilogy"
MySQL.new(data_type)
when "sqlite"
SQLite.new(data_type)
else
raise UnsupportedAdapter, "Unsupported adapter: #{adapter_name.inspect}"
end
end
end
end
end
+ expect(DatabaseAdapter.from_string("Trilogy")).to eql(DatabaseAdapter::MySQL.new)
TrilogyはMySQL向けのアダプタです。私たちにとって両者には何の違いもありませんし、そのように扱いたいと考えています。
この作業全体に興味がおありの方は、RailsEventStoreのプルリク#1671でDatabaseAdapter
が導入された様子をご覧いただけます。mutant gemのおかげでミューテーションテストのカバレッジは100%になっています。
Value ObjectはRubyエコシステムでまだまだ活用されていないと私は思っています。そういうわけで別記事で別の例を皆さんに提供したいと考えました。この例は、皆さんがMoney
あたりで目にしているようなValue Objectとは一味違います。
不要な分岐や繰り返されがちな分岐を削減して複雑なコードをシンプルにするための、優れたツールなのです。
The post Rubyでもっと活用されるべきValue Objectパターン(翻訳) first appeared on TechRacho.
概要 元サイトの許諾を得て翻訳・公開いたします。 英語記事: Six ways to prevent a monkey-patch drift from the original code | Arkency Blog 原文公開日: 2023/09/13 原著者: Paweł Pacana 日本語タイトルは内容に即したものにしました。 Rails: モンキーパッチが元コードの更新で乖離するのを防ぐ6つの方法(翻訳) モンキーパッチ(monkey-patching)を手短に説明すると、直接管理されていない外部のソースコードを、プロジェクトの目的に沿って改変することです。長年動いている「レガシー」プロジェクトのフレー […]
The post Rails: モンキーパッチが元コードの更新で乖離するのを防ぐ6つの方法(翻訳) first appeared on TechRacho.
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
モンキーパッチ(monkey-patching)を手短に説明すると、直接管理されていない外部のソースコードを、プロジェクトの目的に沿って改変することです。長年動いている「レガシー」プロジェクトのフレームワークを一新する場合や、依存関係をまだアップグレードできない事情がある場合、あるいは多数のコードブロックを移動する必要が生じた場合にモンキーパッチが必要になることがよくあります。
モンキーパッチは手っ取り早く作業を進めるための短期的なソリューションです。モンキーパッチの効果はただちに現れます。外部コードの変更許可を取り付ける必要もなければ、依存関係のコードをforkさせて余分な作業を背負い込む必要もありません。
しかしモンキーパッチを使うと、コードが非常にもろくなるという代償がついて回ります。外部の依存関係が将来更新されたときに、現行のモンキーパッチが問題なく動作すると期待するのはまったく無理な話です。個人開発ではなくチームで開発しているのであれば、モンキーパッチという「短期借り入れ」を行ったことをチームに周知しておくことが非常に重要です。
依存関係にモンキーパッチを当てたことをチームに伝える手段のひとつは、テストを書く形でドキュメント化することです。
ドキュメントをテストの形にする理由は以下のとおりです。
単なるコードコメントと異なり、テストは実行可能なので、誰かさんのようにお知らせを見落とすリスクを大幅に軽減できる。
最近私が関わったとあるプロジェクトでは、既にUser
モデルの内部でAcitveRecord::Persistence#reload
にモンキーパッチが当たっていたのに、何も周知されていませんでした。910,000行を超えるコードのテストカバレッジがたった10%であったことを考えると、自分がこのモンキーパッチを掘り当てたのは実に強運だったと思います。
モンキーパッチを当てたことをコードのコメントに書いただけでは、私ならまず気づけないでしょう。私がそのプロジェクトに参加したのはごく最近の話ですが、プロジェクトに関わっていたコードの作者たちはとっくにいなくなっていました。
私がドキュメントに追加したテストは以下のような感じです。
# spec/models/user_spec.rb
require "rails_helper"
RSpec.describe "User" do
specify "#reloadメソッドがフレームワークの実装に基づいてオーバーライドされていること" do
expect(Rails.version).to eq("5.1.7"), failure_message
end
def failure_message
<<~WARN
Railsがアップグレードされた可能性あり。
User#reloadメソッドの本文が
rails/activerecord/lib/active_record/persistence.rb#reloadの
最新のRails実装と対応しているかどうかチェックすること。
準備ができたら、この条件でバージョンアップすること。
WARN
end
end
これで、Railsのバージョンが変更されるたびにチェックが失敗するようになります。失敗したときには、パッチを延命させるために何をすべきかについてわかりやすいメッセージが表示されます。
CI(継続的インテグレーション)に依存していて、テスト文化を目指している組織であれば、このようなパッチ適用の失敗をこれで十分防げるでしょう。
しかし開発者にとってもっと便利な形にできないものでしょうか?
バージョンを厳密にチェックする方法の欠点は、厳密過ぎることです。依存関係によっては、利用可能なバージョンを範囲指定するのがベストなこともあります。セキュリティ問題を軽減するバージョン変更のチェックが失敗しないようにするには、たとえば以下のようにします。
--- spec/app/models/user_spec.rb
+++ spec/app/models/user_spec.rb
@@ -4,7 +4,7 @@ require 'rails_helper'
- expect(Rails.version).to eq('7.0.7'), failure_message
+ expect(Rails.version).to eq('7.0.7.2'), failure_message
+ # Mitigate CVE-2023-38037
+ # https://discuss.rubyonrails.org/t/cve-2023-38037-possible-file-disclosure-of-locally-encrypted-files/83544
依存関係のバージョニングスキームが意味のあるものになっていることが確実であれば、バージョン制約のバリデーションを緩める形でチェックを変更できます。
以下はRubyGems APIを利用する例です。
--- spec/app/models/user_spec.rb
+++ spec/app/models/user_spec.rb
@@ -4,7 +4,7 @@ require 'rails_helper'
- expect(Rails.version).to eq('7.0.7.2'), failure_message
+ expect(Gem::Requirement.create('~> 7.0.0').satisfied_by?(Gem::Version.create(Rails.version))).to eq(true), failure_message
しかし、セキュリティパッチのリリースが、自分たちの当てたモンキーパッチを変更しないことを皆さんは確信できますか?
パッチが当たっているメソッドのソースコードを覗き込んで、元のメソッドから変更されていないかどうかをチェックできたら理想的ですよね。
arkencyの元フェローのツイート↓を読んで、この問題に新たな光が当たりました。
That is one of the coolest #ruby tricks, I've seen. You need to do some monkey patch of a library method. At any point in the future, the library might change the method implementation and your patch needs to be re-verified with newer library version. This detects such situation. pic.twitter.com/LVpZ8Fev6y
— Robert Pankowecki (@pankowecki) August 3, 2023
これは自分が見た中で最もクールなRubyトリックの1つ。ライブラリメソッドに何らかのモンキーパッチを当てなければならなくなった場合、今後ライブラリ側でメソッドの実装が変更される可能性があるので、新バージョンのライブラリでモンキーパッチが動くかどうかを再検証する必要がある。スクショのコードはそうした状況を検出するためのもの。
この方法を用いた完全なテストは以下のようになります。
# spec/models/user_spec.rb
require "rails_helper"
RSpec.describe "User" do
specify "#reloadメソッドがフレームワークの実装に基づいてオーバーライドされていること" do
expect(checksum_of_actual_reload_implementation).to eq(
checksum_of_expected_reload_implementation,
),
failure_message
private
def checksum_of_actual_reload_implementation
Digest::SHA256.hexdigest(
ActiveRecord::Persistence.instance_method(:reload).source,
)
end
def checksum_of_expected_reload_implementation
"3bf4f24fb7f24f75492b979f7643c78d6ddf8b9f5fbd182f82f3dd3d4c9f1600"
end
def failure_message
#...
end
end
end
少しがっかりした点は、以下でツイート↓したようにMethod#source
が標準のRubyにはないことでした。Method#source
は、実は私が参加したプロジェクトでたまたまpry
経由で間接的に使われていたmethod_source
という依存関係に由来していたのです。それでも既存のプロジェクトにおける依存関係についてはうまく動き、単なるバージョンチェックよりも優秀でした。
他に改良できそうな点はあるでしょうか?
ソースコードのハッシュを算出する方法はイケていると思いますが、「フォーマットのみの」変更には力不足です。ソースコードはテキストで表現されるものであり、スペースや改行といったいわゆるホワイトスペースを加えても実装としては変わらず、振る舞いは同じになります。しかしハッシュはホワイトスペースが変わると変わってしまい、偽陰性となります。
この点を改良できないものでしょうか?はい、可能です。抽象構文木(AST)に少しばかり助けてもらえばよいのです。理論上は、AST表現を用いることでモンキーパッチされたコードのフォーマットの違いに悩まされずに済むようになるはずです。
Rubyの場合、ソースコードのASTを得る方法はいくつも考えられます。人気が高いのはparser
gemやsyntax_tree
gemです。Ruby標準ライブラリにはRipper
もありますし、ネイティブのRubyVM::AbstractSyntaxTree
も利用できます1。
悲観的な方なら、それぞれの方法における限界に真っ先に気づくことでしょう。
RubyVM::AbstractSyntaxTree
とRipper
の出力には引き続きフォーマットが含まれてしまうので、目的を達成できません。parser
gemとsyntax_tree
gemは外部依存になるので、普遍的に適用できるとは限りません。おそらくですが、既にプロジェクト内でこれらのgemに間接的に依存していることでしょう。私も最初はこうした点の多くに気づきませんでした。私がオススメしたくない実装を以下に紹介します。
RubyコアにあるRubyVM::AbstractSyntaxTree
は、RubyコードをパースしてASTを得るメソッドがいくつかあります。残念ながら、この出力には行や列(=行の左から何文字目かを表す)の情報も含まれているため、ソースコードのフォーマットに影響されずにチェックサムを得るには適していません。つまりあらゆる点で、ソースコードをそのままダイジェスト化するのと大して変わりません。
# spec/models/user_spec.rb
require "rails_helper"
RSpec.describe "User" do
specify ""#reloadメソッドがフレームワークの実装に基づいてオーバーライドされていること"" do
expect(checksum_of_actual_reload_implementation).to eq(
checksum_of_expected_reload_implementation,
),
failure_message
end
private
def checksum_of_actual_reload_implementation
Digest::SHA256.hexdigest(
RubyVM::AbstractSyntaxTree.parse(
ActiveRecord::Persistence.instance_method(:reload).source,
).pretty_print_inspect,
)
end
def checksum_of_expected_reload_implementation
"ed2f4fdf62aece74173a44a65d8919ecf3e0fca7a5d38e2cefb9e51c408a4ab4"
end
end
Ruby標準ライブラリにはRipper
というRubyスクリプトパーサーもあります。Ripperは、コードをパースしてS式のツリーを得ます。残念ながら、こちらの出力にも行や列の情報が含まれますが、後処理をいくつか追加すればこの問題を取り除けそうです。個人的にはS式のチェックサムを比較する方法が好みです。テストフレームワークでは、比較した構文木の差分を表示する機会があります。嬉しいボーナスですね!
# spec/models/user_spec.rb
require "rails_helper"
RSpec.describe "User" do
specify "#reload method is overridden based on framework implementation" do
expect(actual_find_record_implementation).to eq(
expected_find_record_implementation,
),
failure_message
end
private
def actual_reload_implementation
Ripper.sexp(ActiveRecord::Persistence.instance_method(:reload).source)
end
def expected_reload_implementation
[
:program,
[
[
:def,
[:@ident, "reload", [1, 8]],
[
:paren,
[
:params,
nil,
[
[
[:@ident, "options", [1, 15]],
[:var_ref, [:@kw, "nil", [1, 25]]],
],
],
nil,
nil,
nil,
nil,
nil,
],
],
[
:bodystmt,
[
[
:call,
[
:call,
[
:call,
[:var_ref, [:@kw, "self", [2, 6]]],
[:@period, ".", [2, 10]],
[:@ident, "class", [2, 11]],
],
[:@period, ".", [2, 16]],
[:@ident, "connection", [2, 17]],
],
[:@period, ".", [2, 27]],
[:@ident, "clear_query_cache", [2, 28]],
],
[
:assign,
[:var_field, [:@ident, "fresh_object", [4, 6]]],
[
:if,
[
:method_add_arg,
[:fcall, [:@ident, "apply_scoping?", [4, 24]]],
[
:arg_paren,
[
:args_add_block,
[[:var_ref, [:@ident, "options", [4, 39]]]],
false,
],
],
],
[
[
:method_add_arg,
[:fcall, [:@ident, "_find_record", [5, 8]]],
[
:arg_paren,
[
:args_add_block,
[[:var_ref, [:@ident, "options", [5, 21]]]],
false,
],
],
],
],
[
:else,
[
[
:method_add_block,
[
:call,
[
:call,
[:var_ref, [:@kw, "self", [7, 8]]],
[:@period, ".", [7, 12]],
[:@ident, "class", [7, 13]],
],
[:@period, ".", [7, 18]],
[:@ident, "unscoped", [7, 19]],
],
[
:brace_block,
nil,
[
[
:method_add_arg,
[:fcall, [:@ident, "_find_record", [7, 30]]],
[
:arg_paren,
[
:args_add_block,
[[:var_ref, [:@ident, "options", [7, 43]]]],
false,
],
],
],
],
],
],
],
],
],
],
[
:assign,
[:var_field, [:@ivar, "@association_cache", [10, 6]]],
[
:method_add_arg,
[
:call,
[:var_ref, [:@ident, "fresh_object", [10, 27]]],
[:@period, ".", [10, 39]],
[:@ident, "instance_variable_get", [10, 40]],
],
[
:arg_paren,
[
:args_add_block,
[
[
:symbol_literal,
[:symbol, [:@ivar, "@association_cache", [10, 63]]],
],
],
false,
],
],
],
],
[
:assign,
[:var_field, [:@ivar, "@attributes", [11, 6]]],
[
:method_add_arg,
[
:call,
[:var_ref, [:@ident, "fresh_object", [11, 20]]],
[:@period, ".", [11, 32]],
[:@ident, "instance_variable_get", [11, 33]],
],
[
:arg_paren,
[
:args_add_block,
[
[
:symbol_literal,
[:symbol, [:@ivar, "@attributes", [11, 56]]],
],
],
false,
],
],
],
],
[
:assign,
[:var_field, [:@ivar, "@new_record", [12, 6]]],
[:var_ref, [:@kw, "false", [12, 20]]],
],
[
:assign,
[:var_field, [:@ivar, "@previously_new_record", [13, 6]]],
[:var_ref, [:@kw, "false", [13, 31]]],
],
[:var_ref, [:@kw, "self", [14, 6]]],
],
nil,
nil,
nil,
],
],
],
]
end
def failure_message
# ...
end
end
最後に、私は「実用的な」実装にこだわっています。そのためにparser
gemとmethod_source
gemに依存することになります。幸いこれらのgemはpry
やmutant
やrubocop
と一緒にプロジェクトに入っていたので、依存をこれ以上増やさずに平和にやれました。
require "parser/current"
RSpec.describe "User" do
include AST::Sexp
specify "#reload method is overridden based on framework implementation" do
expect(actual_find_record_implementation).to eq(
expected_find_record_implementation,
),
failure_message
end
private
def actual_reload_implementation
Parser::CurrentRuby.parse(
ActiveRecord::Persistence.instance_method(:reload).source,
)
end
def expected_reload_implementation
s(
:def,
:reload,
s(:args, s(:optarg, :options, s(:nil))),
s(
:begin,
s(
:send,
s(:send, s(:send, s(:self), :class), :connection),
:clear_query_cache,
),
s(
:lvasgn,
:fresh_object,
s(
:if,
s(:send, nil, :apply_scoping?, s(:lvar, :options)),
s(:send, nil, :_find_record, s(:lvar, :options)),
s(
:block,
s(:send, s(:send, s(:self), :class), :unscoped),
s(:args),
s(:send, nil, :_find_record, s(:lvar, :options)),
),
),
),
s(
:ivasgn,
:@association_cache,
s(
:send,
s(:lvar, :fresh_object),
:instance_variable_get,
s(:sym, :@association_cache),
),
),
s(
:ivasgn,
:@attributes,
s(
:send,
s(:lvar, :fresh_object),
:instance_variable_get,
s(:sym, :@attributes),
),
),
s(:ivasgn, :@new_record, s(:false)),
s(:ivasgn, :@previously_new_record, s(:false)),
s(:self),
),
)
end
def failure_message
# ...
end
end
ご覧の通り、出力に行や列への参照は含まれていません。
移植性を高めるために、これらのgemに依存しない形にできればと願っています。今後のRubyでこういうもろもろの作業を手軽に行える日が来ますように。
Not yet but certainly possible! I can take a quick look next week. I think this wouldn’t be a big lift for us.
— Kevin Newton (@kddnewton) August 26, 2023
Markus Schirp
Rubyに統合されたYARP(注: 現在はPrismに改名)にMethod#source_ast
を追加する案は出されているだろうか?これがあればツールを大幅に改善できるだろう。もちろん場合によってはnil
になってしまうことは承知しているが、メソッドとソースコードを関連付ける現在の方法は「最悪」なので。Kevin Newton
まだだけど、これは確かに可能!来週ちょっと見てみる時間を取れそう。この追加は重たい作業にはならないと思う。
どうかいい方向に進みますように
Prism
(旧YARP)も利用できます。 ↩
The post Rails: モンキーパッチが元コードの更新で乖離するのを防ぐ6つの方法(翻訳) first appeared on TechRacho.
概要 元サイトの許諾を得て翻訳・公開いたします。 英語記事: ViewComponent in the Wild III: TailwindCSS classes & HTML attributes—Martian Chronicles, Evil Martians’ team blog 原文公開日: 2024/01/23 原著者: Vladimir Dementyev(首席バックエンドエンジニア)、Travis Turner(技術記事編集者) サイト: Evil Martians -- ニューヨークやロシアを中心に拠点を構えるRuby on Rails開発会社です。良質のブログ記事を多数公開し、多くのg […]
The post 実践ViewComponent(3)TailwindCSSのクラスとHTML属性(翻訳) first appeared on TechRacho.
概要
元サイトの許諾を得て翻訳・公開いたします。
はじめに
Ruby on Railsのフルスタック開発がHTMLファーストという方法によって軌道に戻りました!ここからさらに前進するには、ビュー層の管理方法を改善する優れたツールが必要です。GitHubが開発したViewComponentライブラリは、HTMLを健全にするための選択肢として現在もRails開発者の間で筆頭の座についています。私たちEvila Martiansはしばらく前からViewComponentライブラリを使い続けており、本シリーズ開始以来つちかってきたさまざまなヒントやテクニックを新たに紹介できるようになりました。本記事では、TailwindCSSスタイルをViewComponentに統合する方法と、HTML属性をViewComponentの複数のコンポーネントに伝搬させる方法について解説します。それでは皆さま、シートベルトをお締めください!
私たちは数年前から、HTMLテンプレートとパーシャルを整理するためにViewComponentの採用を始めました。当初は主にコードを管理下に置くためのソフトウェアデザインパターンとしてViewComponentを使ってきましたが、エコシステムと私たちの経験が進化を遂げるに連れて、ViewComponentはアプリケーションのデザインシステムを構築するうえで重要な位置を占めるようになりました。
デザインシステムとは、再利用可能な要素やUIを作成するためのガイドラインを集約したものです。コード内では、デザインシステムはUIキット(ビュー層内でデザインシステムのUI要素の実装を担当する部分)を介して表現されます。「再利用可能な要素」は、そのまま自然にコンポーネント化に結びつきます。
しかしUIキットをメンテナンスするには、そのためのストーリーブックが不可欠です。シリーズの前回記事でも説明したように、ViewComponentでLookbookというストーリーブックを併用することで、デザインシステム主導のUI開発に必要なものをすべて得られます。
RubyやRails向けのUIコンポーネントライブラリには、他にもPhlexUI、RailsUI、ZestUIもあります(PhlexUIで用いられているPhlexもRuby向けのビューコンポーネント用ライブラリです)。
生産性を重視するRailsでコードを書くときに最も生産性を高める方法は、コードをまったく書かずに済むようにすることです。RubyアプリやRailsアプリを対象とする既存のUIキットはまだ希少で、ほとんどがアルファ段階なので、アプリケーションではUIライブラリを自作する必要が生じる可能性もあります。しかしどうかご心配なく!私たちは既にこの方面での経験をいくつか積み重ねており、ViewComponentを用いたUIキット開発の手間を軽減させる貴重なノウハウを公開できる段階に到達しています。本日のメニューは以下になります。
TailwindCSSはUI開発世界において覇を唱えました。すべてのスタイルを定義済みのHTMLクラスで定義できるのであれば、わざわざCSSルールやネストや命名(BEMやSMACSSなど)で頑張る必要があるでしょうか?TailwindCSSでは、HTMLを信頼できる唯一の情報源として定めており、最新のフルスタックRailsの"No Build"主義と特に相性が良くなります。
TailwindCSSを利用することで、CSSファイルに一切触らずに一貫したUIを開発できるようになります。デザインシステムの根幹部分(フォント、グリッドなど)やデザイントークンをtailwind.config.js
ファイル内に定義しておけば、アトミック(かつ動的)なCSSクラスの魔法を楽しめます。ただしそれと引き換えに、HTML内で数十個のCSSクラスが必要になります。Buttonコンポーネントの例を考えてみましょう。
class UIKit::Button::Component < ApplicationComponent
option :type, default: proc { "button" }
erb_template <<~ERB
<button type="<%= type %>"
class="items-center justify-center rounded-md border border-slate-300
bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm ring-blue-700
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2
focus:ring-offset-blue-50 dark:border-slate-950 dark:bg-blue-700
dark:text-blue-50 dark:ring-blue-950 dark:hover:bg-blue-800 dark:focus:ring-offset-blue-700">
<%= content %>
</button>
ERB
end
上のコード例では、ViewComponent V3で追加されたインラインテンプレート機能と、私たちのview_component-contribライブラリ(詳しくはシリーズ第2回を参照)で生成したApplicationComponent
機能を利用しています。
このコンポーネントは以下のようにレンダリングできます。
<%= render UIKit::Button::Component.new do %>
Click Me
<% end %>
これにより以下のUIを得られます。
Buttonコンポーネント
しかしこれは始まりに過ぎません。ボタンコンポーネントの形式を1種類提供すれば済むことはないので、UIキットには必ずボタンのバリアントも複数用意するものです。
そこで、このボタンのアウトライン(白抜き)バージョンを追加することを検討してみましょう。そのためには、指定したバリアントに応じて、いくつかのTailwindCSSクラスを条件付きで含める必要があります。
class UIKit::Button::Component < ApplicationComponent
option :type, default: proc { "button" }
option :variant, default: proc { :default }
STYLES = {
default: "text-white bg-blue-600 ring-blue-700\
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2\
focus:ring-offset-blue-50 dark:border-slate-950 dark:bg-blue-700\
dark:text-blue-50 dark:ring-blue-950 dark:hover:bg-blue-800 dark:focus:ring-offset-blue-700",
outline: "bg-slate-50 hover:bg-slate-100 focus:outline-none\
focus:ring-2 focus:ring-slate-600 focus:ring-offset-2\
focus:ring-offset-blue-50 dark:border-slate-950 dark:bg-slate-700\
dark:ring-slate-950 dark:hover:bg-slate-800 dark:focus:ring-offset-slate-700"
}.freeze
erb_template <<~ERB
<button type="<%= type %>"
class="items-center justify-center rounded-md border border-slate-300
px-4 py-2 text-sm font-medium shadow-sm <%= STYLES.fetch(variant) %>">
<%= content %>
</button>
ERB
end
これで、ボタンをレンダリングするときにvariant: :outline
でバリアントを指定できるようになります。
<div class="flex flex-row space-x-4">
<%= render UIKit::Button::Component.new do %>
Click Me
<% end %>
<%= render UIKit::Button::Component.new(variant: :outline) do %>
Click Me
<% end %>
</div>
ボタンのバリアント
ここでのコツは、クラスの動的な部分を定数に切り出したことです。しかしこれはさまざまなバリアントの1つの側面に過ぎず、実際にはさまざまなバリアントを組み合わせて使うのが普通です。
たとえば、サイズをsmallやfullなどのさまざまなバリエーションで使う可能性があります。バリアント同士が独立していない場合は、追加するクラスやその組み合わせをさらに増やすはめになります。たとえば、ボタンコンポーネントをdisabled
で無効にするには、以下のようなコードを書く必要があるでしょう。
class UIKit::Button::Component < ApplicationComponent
option :type, default: proc { "button" }
option :variant, default: proc { :default }
option :disabled, default: proc { false }
STYLES = {
# ...
}.freeze
erb_template <<~ERB
<button type="<%= type %>"
class="items-center justify-center rounded-md border border-slate-300
px-4 py-2 text-sm font-medium shadow-sm <%= STYLES.fetch(variant) %>
<%= disabled_classes if disabled %>"
<%= "disabled" if disabled %>>
<%= content %>
</button>
ERB
def disabled_classes
if variant == :outline
"opacity-75 bg-slate-300 pointer-events-none"
else
"opacity-50 pointer-events-none"
end
end
end
これではコンポーネントのコードがますます複雑になるばかりで、しかもこれは期待通りに動きません。問題は、アウトライン版のボタンを無効にしたときに、bg-slate-50
(STYLES[:outline]
で導入される)とbg-slate-300
(#disabled_classes
で導入される)がかち合ってしまい、その結果前者が勝ってしまうことです。
コンポーネントのスタイルをすっきりさせて、メンテナンス性と正確さを取り戻すにはどうすればよいでしょうか?そこで、Style Variantsを紹介いたします。
Style Variantsは私たちのview_component-contribパッケージにプラグインとして収録されており、Tailwind VariantsとCVAにインスパイアされています。Style Variantsを使うと、以下のようにスタイルのルールを宣言的に定義できます。
class UIKit::Button::Component < ApplicationComponent
option :type, default: proc { "button" }
option :variant, default: proc { :default }
option :disabled, default: proc { false }
style do
base {
%w[
items-center justify-center px-4 py-2
text-sm font-medium
border border-slate-300 shadow-sm rounded-md
focus:outline-none focus:ring-offset-2
]
}
variants {
variant {
primary {
%w[
text-white bg-blue-600 ring-blue-700
hover:bg-blue-700
focus:ring-offset-blue-50
dark:border-slate-950 dark:bg-blue-700 dark:text-blue-50 dark:ring-blue-950
dark:hover:bg-blue-800
dark:focus:ring-offset-blue-700
]
}
outline {
%w[
bg-slate-50
hover:bg-slate-100
focus:ring-slate-600 focus:ring-offset-blue-50
dark:border-slate-950 dark:bg-slate-700 dark:ring-slate-950
dark:hover:bg-slate-800
dark:focus:ring-offset-slate-700
]
}
}
disabled {
yes { %w[opacity-50 pointer-events-none] }
}
}
defaults { {variant: :primary, disabled: false} }
# "compound"ディレクティブを用いると、
# 指定の組み合わせが使われるときに
# 追加するクラスを後追いで宣言可能になる
compound(variant: :outline, disabled: true) { %w[opacity-75 bg-slate-300] }
end
erb_template <<~ERB
<button type="<%= type %>" class="<%= style(variant:, disabled:) %>"<%= " disabled" if disabled %>>
<%= content %>
</button>
ERB
end
スタイル設定のロジックは、すべてstyle do ... end
ブロック内に記述します。HTMLテンプレート内では#style(**)
ヘルパーでバリアントを指定するだけで済みます。これで、コンポーネントコードのあちこちにCSSクラスが散らばらずに済みます。
スタイルバリアントの定義内でカスタム規則を強制することも可能です。たとえば、 CSSクラスが修飾子ごとにグループ化されていることに気づいたとしましょう(focus:*
クラスとdark:*
クラスを別の行に分けているなど)。RuboCop用のカスタムcopを書けば、この規則を強制する(さらにCSSクラスを自動配置する)ことも可能です。つまり、テキストの切れっ端をRubyコードに変えることで、新たなDXの可能性も広がります。
compound
ディレクティブは、従来の#disabled_classes
メソッドを置き換えるものです。クラス衝突1の問題はどのように解決されるのでしょうか?Style Variantsプラグインは、CSSクラスの本質について一切の仮定を措きません(つまりTailwindCSS固有ではないということです)。これをもう少し賢くしてCSS衝突を解決する方法を指示するために、tailwind_merge gemと統合する方法が使えます。
class ApplicationComponent < ViewComponentContrib::Base
include ViewComponentContrib::StyleVariants
style_config.postprocess_with do |classes|
TailwindMerge::Merger.new.merge(classes.join(" "))
end
end
Style Variantsプラグインは、TailwindCSSベースのUIコンポーネント操作のDXを大幅に強化します。HTMLが冗長なスタイル定義で汚染されることもなくなり、CSSクラスも整頓されて静的に分析可能になります。この手法によって、HTMLのclass
属性をシンプルに定義できるようになりました。
では、それ以外の属性についてはどうでしょうか?HTML属性の伝搬について見ていきましょう。
ボタンやフォームのinputのような基本的な(アトミックな)UI要素については、HTMLで考えられる機能的属性(required1
、 disabled
、autocomplete
など)をすべてサポートする必要があります。以下のような汎用のInputコンポーネントを考えてみましょう。
class UIKit::Input::Component < ApplicationComponent
option :name
option :id, default: proc { nil }
option :type, default: proc { "text" }
option :value, default: proc { nil }
option :autocomplete, default: proc { "off" }
option :placeholder, default: proc { nil }
option :required, default: proc { false }
option :disabled, default: proc { false }
erb_template <<~ERB
<span class="relative">
<input type="<%= type %>"
<% if id %> id="<%= id %>"<% end %>
<% if value %> value="<%= value %>"<% end %>
<% if name %> name="<%= name %>"<% end %>
autocomplete="<%= autocomplete %>"
<% if placeholder %> placeholder="<%= placeholder %>"<% end %>
<% if required %> required<% end %>
<% if disabled %> disabled<% end %>
>
</span>
ERB
end
このコンポーネントには、そうした機能的属性が(定義済みの場合にのみ)注入されるテンプレートが大量に含まれています。Railsならcontent_tag
が(さらにtext_field_tag
も) 使えるのに、わざわざ生の文字列を使う理由には「ヘルパーと純粋なHTMLを混ぜたくない」「パフォーマンスのため」などさまざまな理由がありえます。いずれにしろ、ここで重要なのは、そうしたことが現場では起きる可能性があるということです。私は火のない所に煙を立てたわけではありません。
また、公開したいすべてのHTML属性を(dry-initializerの.option
で)宣言することも一応可能ですが、これは最適とはほど遠いことが判明しました。理由の筆頭は、そうした属性の個数が多くなる可能性があり、それらのほとんどをHTMLにそのまま注入しなければならなくなることです。理由2は、そうした属性が外部由来であることが多く(Stimulusのdata-*
属性や、ブラウザテストで使うtest_id
など)、せっかく分離したコンポーネントにその責務を負わせたくないことです。
明確さを高める第一歩として、複数の属性をまとめて扱うことを思いつきました。
class UIKit::Input::Component < ApplicationComponent
option :name
option :html_attrs, default: proc { {} }
option :input_attrs, default: proc { {} }, type: -> { {autocomplete: "off", required: false}.merge(_1) }
erb_template <<~ERB
<span class="relative" <%= tag.attributes(**html_attrs) %>>
<input <%= tag.attributes(**input_attrs) %>>
</span>
ERB
def before_render
input_attrs.merge({name:})
end
end
可能なオプションをすべてリストアップする代わりに、html_attrs
(コンテナ要素用)とinput_attrs
の2つだけを追加しました。ハッシュをHTML属性の文字列に変換するために、Rails組み込みのtag.attributes
(Rails 7から利用可能)を使いました。コンポーネントを利用するための新しいインターフェイスは以下のようになります。
<%= render UIKit::Input::Component.new(
name: "name",
input_attrs: {placeholder: "Enter your name", autocomplete: "on", autofocus: true}) %>
inputフィールドが重要であること(かつ必須であること)をコードで強調する目的で、inputフィールドの名前を引き続き別のオプションで渡すようになっている点にご注目ください。
この宣言DSLは、そのままでは理想的とは言えません(特にtype: ...
の部分)。そこで、以下のようにAPIにシンタックスシュガーを加えることも可能です。
class UIKit::Input::Component < ApplicationComponent
option :name
html_option :html_attrs
html_option :input_attrs, default: {autocomplete: "off", required: false}
erb_template <<~ERB
<span class="relative" <%= dots(html_attrs) %>>
<input <%= dots(input_attrs) %>>
</span>
ERB
end
なお、#dots
は、JavaScriptでオブジェクトを参照するspread演算子...
のエイリアスです。
毎年恒例となっている「火星流ViewComponent」レポートの締めくくりとして、ささやかな拡張をもう1つ紹介したいと思います。これは、ViewComponentで開発するときのエクスペリエンスを改善するためのものです。
本シリーズのパート1では、コンポーネントに乗り換える主なメリットの1つとして「テストのしやすさ」を挙げました。ただし、単体テストだけではなく、Railsのシステムテストでコンポーネントを対話的にテストすることも可能です。
# spec/system/components/my_component_spec.rb
it "何か動的なことをする" do
visit("/rails/view_components/my_component/default")
click_on("JavaScript-infused button")
expect(page).to have_content("動的な何か")
end
このようなテストはプレビュー機能に依存しているため、せっかくのストーリーブックが台無しになり、テスト環境と開発環境が癒着してしまいます。これに対処するため、テスト用に以下のインラインテンプレートを構築しました。
it "does some dynamic stuff" do
visit_template <<~ERB
<form id="myForm" onsubmit="event.preventDefault(); this.innerHTML = '';">
<h2>Self-destructing form</h2>
<%= render Button::Component.new(type: :submit, kind: :info) do %>
Destroy me!
<% end %>
</form>
ERB
expect(page).to have_text "Self-destructing form"
click_on("Destroy me!")
expect(page).to have_no_text "Self-destructing form"
end
これで、テスト対象のHTMLをテスト自身の内部で直接定義可能になります。この機能はrails-intest-views gemで利用できますので、どうぞお試しあれ!
本シリーズの最初の記事を公開してから本記事を公開するまでの1年半の間に、Railsフルスタックアプリケーションを構築する方法は変わりました。TailwindCSSが戦いを制し(以前の私たちはPostCSS Modulesを実験していました)、Webpackerが廃止され、さまざまなUIコンポーネントライブラリがついにRailsの世界にも登場しました。
それでもViewComponentは引き続き私たちのUI技術の中核を担っており、そのおかげで絶え間なく変化を繰り返すソフトウェア開発トレンドにも楽に対応できています。私たちがViewComponentに投資する理由は、まさにそこにあるのです。
Evil Martiansは、成長段階のスタートアップ企業をユニコーン企業に飛躍させるためにサポートいたします。開発ツールの構築やオープンソース製品の開発も行っています。ワープの準備が整ったお客様、ぜひフォームまでご相談をお寄せください!
The post 実践ViewComponent(3)TailwindCSSのクラスとHTML属性(翻訳) first appeared on TechRacho.
こんにちは、hachi8833です。 超久しぶりに対面でのつっつき会を開催できました😂。 週刊Railsウォッチについて 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄 お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙏 TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いし […]
The post 週刊Railsウォッチ: method_missingの引数を'...'に置き換え、JRuby Prism、Sidekiqのしくみほか(20240306) first appeared on TechRacho.
こんにちは、hachi8833です。
超久しぶりに対面でのつっつき会を開催できました。
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)
assert_broadcasts
がブーリアンの代わりにブロードキャスト済みのメッセージを返すようになった
assert_broadcasts
が、ブロードキャスト済みのメッセージを返すようになった。
これにより、ブロードキャスト済みメッセージのさらなる分析がやりやすくなる。message = assert_broadcasts("test", 1) do ActionCable.server.broadcast "test", "message" end assert_equal "message", message messages = assert_broadcasts("test", 2) do ActionCable.server.broadcast "test", { message: "one" } ActionCable.server.broadcast "test", { message: "two" } end assert_equal 2, messages.length assert_equal({ "message" => "one" }, messages.first) assert_equal({ "message" => "two" }, messages.last)
Alex Ghiculescu
同Changelogより
このプルリクは#47025での
assert_email
の改修と同様、Action Cableのassert_broadcasts
がブロードキャスト済みのメッセージを返すようになった。これは以下のようにさらに分析可能になる。messages = assert_broadcasts("test", 2) do ActionCable.server.broadcast "test", { message: "one" } ActionCable.server.broadcast "test", { message: "two" } end assert_equal 2, messages.length assert_equal({ "message" => "one" }, messages.first) assert_equal({ "message" => "two" }, messages.last)
これは、ブロードキャストされるメッセージ数が多い場合や、データの一部の要素だけを照合したい場合に有用。
assert_broadcast_on
はどちらのシナリオでもうまくいかない。同PRより
つっつきボイス:「ブロードキャスト済みのメッセージを返す前のことは書かれていなかったんですが、以前はtrue/false
を返していたんですね」「アサーションでブロードキャストメッセージの内容も調べられるのはいい」
参考: Rails API assert_broadcasts
-- ActionCable::Channel::TestCase::Behavior
「ところで、今はRailsアプリのテストでminitestとRSpecとどちらがよく使われているんでしょうか?」「今やってるプロジェクトはRSpec」「個人アプリでminitest使ってます」「Rails作者のDHHがRSpecが好きでないというのは昔から有名で、他の人が使うのはいいけど自分は使いたくないというスタンスですね」
「一般的にですけど、ライブラリやフレームワークを対象とするテストはminitestみたいな比較的シンプルなアサーションの方が望ましいことが多いでしょうね: 一方、特にビジネスユースケースを伴うアプリだとRSpecのcontext
やdescribe
のようなものがビジネスを説明するうえでしっくりくると思いますし、特にcontext
と言えばビジネスコンテキストを連想しやすいしステートを持っている感じを出せますね」「なるほど」「もちろんライブラリやフレームワークにもステートがあることもありますけど、一般にライブラリやフレームワークはどちらかというとステートがあまり前面に出てこないような印象があるのでminitestを好む気持ちもわからなくもないかな: とはいえRailsぐらい大規模になるとそうもいかないでしょうけど」「rails new
のオプションを網羅するテストなんかはベタ書きだと大変そうですね」
「ところでminitestはパラレル実行できるけどRSpecはそのままだとパラレルにできないんでしたっけ?」「大昔はrrrspecで分散実行したりしていたこともありましたけど、もうだいぶ前からparallel_testsでパラレル実行できますよ」「あ、知りませんでした」
参考: Rails - Rspec で paralells test を利用して並列処理する ( create / migrate / prepar
参考: さようならrrrspec、いままで速度をありがとう - 弥生開発者ブログ
has_one
関連付けの自動保存のバグを修正外部キー属性が変更されていない場合に設定される
has_one
関連付けの自動保存を修正。この振る舞いは
belongs_to
の自動保存とも一貫しておらず、外部キー属性がread-onlyとマーキングされている場合にActiveRecord::ReadOnlyAttributeError
などの意図しない副作用が発生する可能性がある。Joshua Young
同Changelogより
動機/背景
修正: #50897
類似プルリク: #46759
詳細
ActiveRecord::AutosaveAssociation#save_has_one_association
が、子レコード上の外部キー属性が変更された場合にのみ外部キー属性を更新するよう修正。これにより、belongs_to
関連付けの振る舞いと一貫するようになり、属性が変更されなかった場合にActiveRecord::ReadOnlyAttributeError
が発生しないようになる。
同PRより
つっつきボイス:「has_one
ってhas_many
やbelongs_to
と比べてあまり使われてない分バグが見つかりやすいんですよね」「たいていhas_many
やbelongs_to
の方が圧倒的に注目されていますよね」「readonlyな属性を変更せずに保存したらエラーになるのはバグ」「修正はこれだけですね↓」
# activerecord/lib/active_record/autosave_association.rb#L442
def save_has_one_association(reflection)
association = association_instance_get(reflection.name)
record = association && association.load_target
if record && !record.destroyed?
autosave = reflection.options[:autosave]
if autosave && record.marked_for_destruction?
record.destroy
elsif autosave != false
primary_key = Array(compute_primary_key(reflection, self)).map(&:to_s)
primary_key_value = primary_key.map { |key| _read_attribute(key) }
if (autosave && record.changed_for_autosave?) || _record_changed?(reflection, record, primary_key_value)
unless reflection.through_reflection
foreign_key = Array(reflection.foreign_key)
primary_key_foreign_key_pairs = primary_key.zip(foreign_key)
primary_key_foreign_key_pairs.each do |primary_key, foreign_key|
- record[foreign_key] = _read_attribute(primary_key)
+ association_id = _read_attribute(primary_key)
+ record[foreign_key] = association_id unless record[foreign_key] == association_id
end
association.set_inverse_instance(record)
end
saved = record.save(validate: !autosave)
raise ActiveRecord::Rollback if !saved && autosave
saved
end
end
end
end
where.associated
のバグを修正この修正によって、スコープ内の既存のJOINに基づいて、適切なJOIN種別(INNER JOINまたはLEFT OUTER JOIN)を用いて関連付けがJOINされるようになる。
これにより、既存のJOIN種別が意図せずオーバーライドされることを防ぎ、一貫したSQLクエリが生成されるようになる。
例:
# `associated`では`JOIN`ではなく`LEFT JOIN`が使われる Post.left_joins(:author).where.associated(:author)
Saleh Alhaddad
同Changelogより
動機/背景
Railsの現在の
where.associated
メソッドは、関連付けられたレコードをinner join
でフィルタすることしかできず、クエリで既存のJOIN種別がオーバーライドされる可能性がある。この修正によって、スコープ内の既存のJOINに基づいて、適切なJOIN種別(INNER JOINまたはLEFT OUTER JOIN)を用いて関連付けがJOINされるようになる。これにより、既存のJOIN種別が意図せずオーバーライドされることを防ぎ、一貫したSQLクエリが生成されるようになる。例:
User.left_outer_joins(:orders).where.associated(:orders).count
修正前のクエリ:
SELECT COUNT(*) FROM "users" INNER JOIN "orders" ON "orders"."user_id" = "users"."id" WHERE "orders"."id" IS NOT NULL
問題修正後のクエリ:
SELECT COUNT(*) FROM "users" LEFT OUTER JOIN "orders" ON "orders"."user_id" = "users"."id" WHERE "orders"."id" IS NOT NULL
この機能強化によって、関連付けられたデータへのクエリを開発者がさらに柔軟に実行できるようになり、以下のようなシナリオが容易になる。
- 投稿の一部に著者が割り当てられていない場合でも、著者付きの投稿リストを取得する(
left_joins
を利用)- ユーザーが紐づけられていない孤立コメントを含めて、特定ユーザーと関連付けられているコメントリストを取得する(複数のJOIN種別を利用)
詳細
このプルリクにおける重要な改善点は、スコープ内の既存のJOINに基づいて、適切なJOIN種別(INNER JOINまたはLEFT OUTER JOIN)を用いて関連付け同士がJOINされるようになることである。これによって、既存のJOIN種別が意図せずオーバーライドされることを防ぎ、生成されるSQLクエリの一貫性が高まる。
追加情報
この改良は、さまざまなフォーラムやディスカッションでRails開発者によって明らかにされた共通要件に基づいている。これと類似した機能は特定の有名なORM拡張でも普及しており、その有用性が強調されている。この実装は、既存のコードベースの明確さと一貫性を維持するように作られている。
注: この説明はドラフトであり、各自の実装の詳細やプロジェクトの事情に合わせて調整が必要。
FYI: このプルリクがマージされたら、別のプルリクで
missing
メソッドをチェックしてこの問題を修正する予定。(メモ: パフォーマンスは修正前後でほとんど変わらないらしい)
同PRより
つっつきボイス:「このバグは、LEFT OUTER JOINだったクエリがassociated
でINNER JOINに変わったりするのを修正したということですね」「associated
って、missing
の逆のメソッドじゃなかったかな?」「そういえばありましたね↓」「Rails 7に入った、関連先に物理レコードが存在するものだけを取ってくる機能だった(ウォッチ20201208)」「missing
はもっと前からありますね」
参考: Rails API missing
-- ActiveRecord::QueryMethods::WhereChain
「associated
やmissing
って本編コードでは普通はあまり使わないはずのもので、ありそうな使い方としてはproductionデータで何らかの原因でバリデーションをすり抜けて、ないはずのレコードがあったり、あるはずのレコードがないといった異常データをメンテナンスするときの利用を主に想定しているんじゃないかな」「ふむふむ」「belongs_to
などでdependent: :nullify
とかoptional: true
とかを使ったり、あるいは生SQL使ってたりするとそういう孤立レコードができる可能性があるけど、そうでもなければ普通はできないはずなんですよね」「missing
を使うことがあるとすれば、トランザクション内で一時的にできる孤立レコードをバッチ更新したいときにmissing
で孤立レコードを取り直すとかですかね」「そういう場合はありうるし、やってはいけないわけではないけど、なるべくならActive Recordで1個ずつ正当な方法で処理していく方が望ましいでしょうね」
「ところで、サンプルコードでLEFT OUTER JOINした後にassociated
でまたLEFT OUTER JOINするのってどういうシチュエーションなのかよくわからないです↓」「これは単なる修正内容を説明するときのクエリなんじゃないかな」「left_outer_joins
がassociated
を通すとINNER JOINになってしまうのはよろしくないので元のLEFT OUTER JOINを変えないように修正したということでしょうね」
User.left_outer_joins(:orders).where.associated(:orders).count
「associated
やmissing
は主にそういうデータ不整合を解消するときに使う印象があるかな」
config.filter_parameters
が効くようになった元の#21045は8年前のプルリクだそうです。
リダイレクト先のパラメータをフィルタする機能を追加。
config.filter_parameters
を用いてフィルタが必要なものとマッチさせる。
結果は以下のようになる。Redirected to http://secret.foo.bar?username=roque&password=[FILTERED]
修正: #14055
Roque Pinel、Trevor Turk、tonytonyjan
同Changelogより
つっつきボイス:「リダイレクトしたときにリダイレクト先のクエリパラメータがそのままログに出力されていたのね」「リダイレクトのURLにもconfig.filter_parameters
のフィルタが効くように修正したんですね」「そもそもログインで?username=ユーザー名&password=パスワード
みたいにクエリパラメータに露出させる書き方って普通しないと思いますけどね」「ですよね」「ごくたま〜に見かけなくもないですけど」「秘密でも何でもないワンタイムのトークンぐらいだったらありますね」
# actionpack/lib/action_dispatch/http/filter_redirect.rb#L5
module ActionDispatch
module Http
module FilterRedirect
FILTERED = "[FILTERED]" # :nodoc:
def filtered_location # :nodoc:
if location_filter_match?
FILTERED
else
- location
+ parameter_filtered_location
end
end
...
+
+ def parameter_filtered_location
+ uri = URI.parse(location)
+ unless uri.query.nil? || uri.query.empty?
+ uri.query.gsub!(FilterParameters::PAIR_RE) do
+ request.parameter_filter.filter($1 => $2).first.join("=")
+ end
+ end
+ uri.to_s
+ end
参考: § 3.2.33 config.filter_parameters
-- Rails アプリケーションの設定項目 - Railsガイド
「デフォルトのフィルタは部分一致なので、以下の名前を部分的にでも含むものはフィルタで除外されるようになっていますよね↓」
# Railsガイドより
Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
]
「この種のフィルタは慎重な方に倒すのが基本ですね: たとえばログに個人を特定できるような情報やセンシティブな情報が含まれてしまっていると、情報取扱い上顧客がそのログを開発会社に出せなくなって作業に支障が生じる可能性があるので、そういう情報は極力ログなどに出ないようにしたい」「セキュリティ上重大な情報でないとしても、個人の特定につながるような情報が含まれてしまうと取り扱い注意になってしまいますよね」「顧客の正社員しか扱えなくなったりとかですね」
Logger.logger_outputs_to?
にファイル名の文字列やFile
オブジェクトを渡せるようになった。
Logger.logger_outputs_to?
の出力先でファイル名もサポートするようになった。Logger.logger_outputs_to?('/var/log/rails.log')
Christian Schmidt
同Changelogより
Logger.logger_outputs_to?
でサポートしている出力先はSTDOUT
とSTDERR
のみで、File
オブジェクトやファイル名の文字列がサポートされていなかった。
同PRより
つっつきボイス:「今までは標準出力と標準エラーしか指定できなかったのをファイルパスでも渡せるようにしたというシンプルな修正ですね」
# activesupport/lib/active_support/logger.rb#L20
def self.logger_outputs_to?(logger, *sources)
loggers = if logger.is_a?(BroadcastLogger)
logger.broadcasts
else
[logger]
end
logdevs = loggers.map { |logger| logger.instance_variable_get(:@logdev) }
- logger_sources = logdevs.filter_map { |logdev| logdev.dev if logdev.respond_to?(:dev) }
+ logger_sources = logdevs.filter_map { |logdev| logdev.try(:filename) || logdev.try(:dev) }
- sources.intersect?(logger_sources)
+ normalize_sources(sources).intersect?(normalize_sources(logger_sources))
end
...
+ private
+ def self.normalize_sources(sources)
+ sources.map do |source|
+ source = source.path if source.respond_to?(:path)
+ source = File.realpath(source) if source.is_a?(String) && File.exist?(source)
+ source
+ end
+ end
end
参考: Rails API logger_outputs_to?
-- ActiveSupport::Logger
method_missing
の引数を...
などに置き換えたキーワード引数が分離されて以来、
...
記法の方がシンプルかつより正しい。
同PRより
つっつきボイス:「これと次のプルリクは永和システムマネジメントさんが主催している"Rails / OSS パッチ会オンライン"↓のDiscordでたまたま見かけたもので、これはbyrootさんによるリファクタリングです」
参考: Rails / OSS パッチ会オンライン 2024年2月のお知らせ - ESM アジャイル事業部 開発者ブログ
「従来のmethod_missing
の引数がごっそり新しい...
記法などで置き換わってる」「文字通りRubyの書き方を現代化してますね」
# actionpack/lib/action_dispatch/http/mime_type.rb#L329
- def method_missing(method, *args)
+ def method_missing(method, ...)
if method.end_with?("?")
method[0..-2].downcase.to_sym == to_sym
else
super
end
end
# actionpack/lib/action_dispatch/system_test_case.rb#L181
- def method_missing(name, *args, &block)
+ def method_missing(name, ...)
if url_helpers.respond_to?(name)
- url_helpers.public_send(name, *args, &block)
+ url_helpers.public_send(name, ...)
else
super
end
end
「*args, **options, &block
みたいな長い引数も...
で置き換わってる↓」「新人向けに説明すると、*
が付いた仮引数はordered arguments(値のみの引数で、順序がある)をリストとして受け取る、**
が付いた仮引数はnamed arguments(名前つき引数、順序は入れ替え可能)をHashとして受け取る、&
が付いた仮引数は呼び出し時に渡されたブロックをProcとして受け取る、という昔からある書き方ですね」「書くときはその順序で書かなければいけないヤツ」
# actionview/lib/action_view/helpers/tag_helper.rb#L337
- def method_missing(called, *args, **options, &block)
- name = called.to_s.dasherize
+ def method_missing(called, ...)
+ name = called.name.dasherize
TagHelper.ensure_valid_html5_tag_name(name)
- tag_string(name, *args, **options, &block)
+ tag_string(name, ...)
end
end
「...
はRuby 2.7で入った新構文で、仮引数で受け取ったものをそのままforwardする記法でしたね」
「その後Ruby 3.0で(name, ...)
みたいに残りの引数をまとめてforwardすることも可能になってましたね↓」
「...
記法にすれば、受け取ったものを中継するだけのメソッドで引数の名前を考えずに済むのがとっても嬉しい」「しかも...
にすれば"全部受け取って丸投げするだけだよ"という意図も示せますよね」「...
は変数名に使えない文字だから中に何かがあっても参照しようがない」「たしかに」
「以前だとargs
とかarguments
とかoption
とかopts
のような引数名ってライブラリやプロジェクトごとにバラつきがちでしたよね」「そうなんですよ...」「プログラミングで最も時間がかかるのは名前を考えることだとよく言われるくらいなので、命名を減らせるのはとてもありがたい」
Time
やDateTime
に型変換したときのタイムゾーンオフセットのバグを修正文字列を
Time
やDateTime
に型変換するときにタイムゾーンオフセットの分(minute)の値がマイナスだと正しく計算されないバグを修正。Akira Matsuda
同Changelogより
このパッチは、Active Modelの
Time
型キャスターが、指定の文字列内にあるマイナスのオフセットを正しく扱っていないバグを修正する。背景/詳細
指定した文字列内のタイムゾーンにマイナスのオフセットがある場合は時(hour)と分(minute)の値を両方とも正負反転する必要があるが、現在のコードではhourだけが正負反転されている。
たとえば
Newfoundland Time Zone (UTC-03:30)
の場合、オフセット部分は"-03:30"
となり、これは「-3
時間と-30
分」として扱わなければならないが、これまでは「-3
時間と+30
分」として算出されていたため、実際の時刻より1時間進んだ値を返していた。追加情報
Time
自身を解析するのは専門家でないと難しすぎる。このバグは、自分が#46868で提案しているRuby組み込みのTimeパーサーへの差し替えの理由付けになるだろう。また、このバグはTime
のプロである@nobuによって#19293(コメント)で発見および報告された(感謝!)。
同PRより
つっつきボイス:「nobuさんが見つけて報告したタイムゾーンのオフセットのバグをa_matsudaさんが修正したプルリクです」「"-03:30"
は-3
時間と-30
分が正しいのに分だけ誤って+30
分になってたのか!」「あ〜なるほど」「しかもオフセットがマイナスかつ30分の国でのみ起きるレアなバグ」
「TimeとかDateTimeの扱いはただでさえ難しくて気を遣うのに(ウォッチ20210713)、オフセットが1時間単位じゃなくて30分の国があるのってつらい」「時間表記は60進法と24進法が混じっていて表記は10進数だし、しかもゼロ始まりだし」「プルリクにも書かれているように専門家じゃないと難しいですよね」「奇しくも今日は閏年で2/29なので(つっつきの日)、この日しか起きないバグが起きたりしているようですね」
「ちなみにプルリクにあったAMoって何だろうと思ったらActive Modelのことでした」「ARならActive RecordしかないけどAMだけだとAction MailerやAction Mailboxもあって一意に定まらないからでしょうね」
後で調べると、30分ずれの国に加えて45分ずれの国もありました↓。
参考: Half Hour and 45-Minute Time Zones
つっつきボイス:「Railsで非同期ジョブ処理によく使われるSidekiqの基本的なしくみを詳しく解説している記事です」「ちょっと長いけど、Sidekiqについて学びたい人は頑張って読んでみるといいと思います」
「最近はSolid Queue↑がRailsのデフォルトジョブエンジンに取り入れられることになったけど、Sidekiqとかが使われなくなるとは思えないですね」「SidekiqやRedisなど実績のあるものは今後も使い続けられると思います: BPS社内でbabaさんたちと話したときにも話題にしたんですが、Solid QueueはRDBにジョブエンジンの責務も負わせることになり、特にエンタープライズ系のアプリや多数のジョブをさばくようなアプリではRDBの仕事をあまり増やしたくないので、SidekiqやRedisなどは今後も普通に使っていくでしょうね」
つっつきボイス:「先ごろリリースされたTurbo 8の2本立て記事で、2本目の記事の下には、Turbo 8で使われているidiomorphを動かせるplaygroundが付いていました」
メジャーどころはだいたい提供されているのでtestcontainersは偉大。マイナーなアレとかアレが提供されていると嬉しいんだけど、主要RDBMSがあるだけでも開発が捗るhttps://t.co/wLy2z5SoPM
— カントク (@uokada) February 22, 2024
つっつきボイス:「このTestcontainersというサービスがRubyにも対応しているので取り上げてみました」「docker composeではcompose.yamlですべての環境をセットアップしなければならないけど、Testcontainersを使うとコンテナの起動後にもコードからプログラマブルに操作できるというものらしい: 何となくだけど、Railsのテストというよりは主に単一の実行ファイル形式のテストから使い捨てコンテナを利用したい人向けなのかも?」
「単に使い捨てのコンテナがコード内から起動して利用できるだけなら特に必要は感じないけど、起動後にプログラムからコンテナ内の設定をテストごとにある程度操作したりできるんだとしたらちょっといいかも」「PostgreSQLやMySQLやRedisやSeleniumなどのコンテナが用意されているんですね」「Seleniumはなくてもいいけど、RabbitMQやRedisのような複雑なサービスを本番同様にテスト環境でコードの中から動的に使い捨てることができるんなら使ってみたいかも」
# 同リポジトリより
container = Testcontainers::DockerContainer.new("redis:6.2-alpine").with_exposed_port(6379)
container.start
host = container.host
port = container.mapped_port(6379)
「Testcontainersサイトにあるこの記事が参考になりそうですね↓」「ありがとうございます」
参考: What is Testcontainers, and why should you use it?
Testcontainersで解決される問題:
- 事前にプロビジョニングされた統合テストインフラを用意する必要がない。
Testcontainers APIはテストの実行前に必要なサービスを提供し、インフラを定義するコードを実際のテストコードのすぐ近くに書ける。各パイプラインは分離されたサービスのセットで実行されるため、複数のビルドパイプラインがパラレル実行される場合でもデータ競合の問題は発生しない。
単体テストを実行するのと同じように、統合テストをIDEから直接実行可能になる。
変更のプッシュや、CI統合テストの実行完了を待つ必要がない。テストの実行後、Testcontainers はコンテナを自動的にクリーンアップする。
同記事より
つっつきボイス:「The Rails FoundationとDoximityがブラジルのサンパウロで活動しているRails Girls 2024のスポンサーになったという記事です」「う、ポルトガル語わからん」
「Rails Girlsって日本の団体しか知らなかったんですが、他の国にもあるんですね」「フィンランドのヘルシンキで最初に設立されたRails Girls以外にもいくつかの国でも活動していて、日本のRails Girlsもほぼ同じぐらい歴史がありますよ」「そうなんですね」
参考: Rails Girls -- ヘルシンキ
参考: Rails Girls - Japanese
参考: Prism pluggable integration work by enebo · Pull Request #8103 · jruby/jruby
つっつきボイス:「少し前にRubyの新パーサーであるPrismのJRuby版がリリースされたそうです」「お〜すごい!」「今でこそJRubyはRuby 3.1互換ですけど、以前はRubyのバージョンアップになかなか追いつけなくて苦労していましたよね(ウォッチ20221207)」「以前は移植のためにJRubyで独自パーサーをメンテしていたけど、Prismの目標のひとつがJRubyを支援することだったはずでしたね」
参考: Home — JRuby.org
今回は以上です。
週刊Railsウォッチ: Rails 8でSprocketsがPropshaftに置き換わる、devcontainerサポートほか(20240228)
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
The post 週刊Railsウォッチ: method_missingの引数を'...'に置き換え、JRuby Prism、Sidekiqのしくみほか(20240306) first appeared on TechRacho.
こんにちは。超教科書プロジェクトの担当をしている榊原です。 「デジタル教科書の活用促進に向けた「こども未来教育協議会」の設立に参加いたします」の記事でお知らせしたように、BPSはTOPPANホールディングス・Lentrance・東京書籍・帝国書院・啓林館とともに、こども未来教育評議会を設立し、デジタル教科書を起点として学びをつなぐ教科書ポータルのEduHub®の構築を目指しておりました。 このたびは、こども未来教育協議会が社団法人化し、教科書ポータルのEduHub®の運用を開始いたしますので、ご報告いたします。 こども未来教育協議会(https://kodomoedu.jp/) 学びをつなぐ教科書ポータル|Edu […]
The post こども未来教育協議会、一般社団法人化による本格活動を開始 first appeared on TechRacho.
こんにちは。超教科書プロジェクトの担当をしている榊原です。
「デジタル教科書の活用促進に向けた「こども未来教育協議会」の設立に参加いたします」の記事でお知らせしたように、BPSはTOPPANホールディングス・Lentrance・東京書籍・帝国書院・啓林館とともに、こども未来教育評議会を設立し、デジタル教科書を起点として学びをつなぐ教科書ポータルのEduHub®の構築を目指しておりました。
このたびは、こども未来教育協議会が社団法人化し、教科書ポータルのEduHub®の運用を開始いたしますので、ご報告いたします。
以下、プレスリリース資料です。
こども未来教育協議会のニュースリリース: こども未来教育協議会、一般社団法人化による本格活動を開始 -- デジタル教科書を起点として学びをつなぐ教科書ポータル「EduHub®」の提供を TOPPAN グループより 3 月 21 日から開始
今後ともBPSは、ITを通じた未来の子ども達を育む効果的な学習環境の実現に貢献して参ります。
よろしくお願いいたします。
The post こども未来教育協議会、一般社団法人化による本格活動を開始 first appeared on TechRacho.
概要
元サイトの許諾を得て翻訳・公開いたします。
参考: 週刊Railsウォッチ20230926: Active Recordのトランザクションをinstrumentationで計測できるようになった
なお、
transaction.active_record
は現時点でRails Guidesやedgeguidesには記載されていません。