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

週刊Railsウォッチ: Kaigi on Rails発表「Simplicity on Rails」を見るほか(20231107)

こんにちは、hachi8833です。Kaigi on Railsの動画が正式に公開されました🎉。

発表資料まとめ記事を再録します↓。現時点では1本を除いて全スライドが公開されています。まとめありがとうございます!

参考: 【Kaigi on Rails 2023】発表資料まとめ #Rails - Qiita

週刊Railsウォッチについて

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

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

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

参考: 7.1.2 Milestone(10件)
参考: 7.2.0 Milestone(8件)

なお、マイルストーンの#49371を見ていて、GitHubのプルリクでファイルリストのURLに?w=1を付けると、インデントしか違いのないdiffを除外してくれることを知りました。

参考: GitHub の Pull Request で出ている差分のスペースやタブを無視して見やすくする方法

🔗 ActiveSupport::Callbacksのメモリ使用量を削減

従来、サブクラスと共有されているコールバックは、Filters::BeforeFilters::Afterで生成されたプロシージャをサブクラス間で共有していなかった。しかもこれは遅延生成されていたので、アプリケーションの起動後にメモリ増加が発生する (Copy on Writeを介してワーカー間で共有できない)。

原因は、コールバックの呼び出しに使われるオブジェクトのリビルドをCallbackChain#compile経由で行っていたため。そのため、コールバックチェインに違いが生じるとすべてのコールバックprocがリビルドされることになる。

このコミットは、before系コールバックやafter系コールバック(ただしbeforeとafter両方で呼ばれるコールバックは含めない)が、それが定義された場所のあらゆるサブクラスで共有されるようにする。これは、Filters::BeforeFilters::Afterを(procを生成するのではなく)callに応答する素のクラスに変更することで実現する(厳密には必ずしもそうしなければならないわけではないが、この方が実装が楽であり、よりシンプルでメモリ消費の少ないオブジェクトも得られるようもなる)。これらのオブジェクトは参照を回避し、特定のコールバックシーケンスに結び付けられることで、メモ化可能かつ再利用可能になる。

これが最も大きく影響するのは、コントローラが多数あり、かつApplicationControllerなどで多数のコールバックが使われているアプリケーション。

また、この機会に、生成されるさまざまな形式のproc(haltinghalting_and_conditionalconditionalsimple)をif文で1つの形式に統一した。以前行われた特殊化は、さほどパフォーマンスが向上しない。

今回は、呼び出し可能なフィルタオブジェクトをeagerにビルドするようにはしなかったが、今後のフォローアップで行う価値がありそう。

たぶん@byrootに喜んでもらえそう😊

再現方法

これは自己完結型アプリになっていて、作成した100個のコントローラはすべて、50個のbefore系コールバックを持つApplicationControllerから継承している(この数はGitHubにある最大級アプリに匹敵する)。個別のコントローラにリクエストを投げると、スタック全体を通過する。このアプリでライブオブジェクトとRSSを観察してみて、次のことがわかった。

benchmark_controller_callback.rb

mainブランチの場合:

$ be ruby benchmark_controller_callback.rb
live objects before: 252506
RSS before: 70044

live objects after: 594812
RSS after: 126052

object delta: 342306
RSS delta: 56008

このブランチの場合:

$ be ruby benchmark_controller_callback.rb
live objects before: 252517
RSS before: 70036

live objects after: 339032
RSS after: 97756

object delta: 86515
RSS delta: 27720

アプリ起動からリクエスト配信後までのメモリ増加が半分程度にまで下がった(残りのほとんどはコールキャッシュだと思う)。
同PRより

参考: Rails API ActiveSupport::Callbacks
参考: コピーオンライト - Wikipedia


つっつきボイス:「これは最適化ですね」「親コントローラにコールバックがたくさんある場合の子クラスでのコールバックでメモリ使用量を削減したということかな: たしかに子のコントローラごとにコールバックのオブジェクトが生成されてたらメモリがもったいない」「改修後のオブジェクト数増加もかなり減っていますね」

「修正されたファイルはActive Supportのcallbacks.rbだけなのか!」「コールバック処理がここに凝縮されているからこのファイルだけ修正すればいいということでしょうね: diffを見るとほとんど全体を書き換えてますけど」「ほんとだ、ほぼ作り直し」「こういうコールバック処理そのものはそんなに複雑にならないはずですし、改修がこの範囲で収まったのもそのおかげでしょうね」

「ちなみにコールバックが使えるおかげでコントローラとかのコードをかなりきれいに書けるので便利: もちろんやりすぎ注意ですが」

🔗 enumでカラムのない属性のサポートが復活

#45734のフォローアップ。

このプルリクによって、enum属性名のタイポを引き続き防ぎながら、カラムのない属性をenumで利用可能にするサポートが再度追加される。カラムのない属性をenumで使う場合は、以下のように事前にその属性で型を明示的に宣言しなければならない。

class Post < ActiveRecord::Base
  attribute :topic, :string
  enum topic: %i[science tech engineering math]
end

修正: #49717

同PRより


つっつきボイス:「このプルリクの前に#45734が2022年にマージされていて、データベースカラムのないenumを定義するとタイポとみなしてraiseするようになっていたそうです」「ほ〜、データベースカラムのないenum属性が定義できなくなっていたけど、サポートが再度追加されたとあるので、それを復活させたということですね」「その代わりに、そういう属性を定義するときはattribute :topic, :stringみたいに型を明示的に指定する必要がある、なるほど」

🔗 number_to_human_sizeで負数を扱えるよう修正

動機/背景

このプルリクを作成した理由は、number_to_human_sizeで負の数値を扱えなかったため。

# 従来の振る舞い
helper.number_to_human_size(-1234567)
# => "-1234567 Bytes"

# 新しい振る舞い
helper.number_to_human_size(-1234567)
# => "-1.18 MB"

詳細

number_to_humanメソッドは既に負の数値をサポートしているので、このプルリクではそれを使う2つのメソッドが修正される。
同PRより


つっつきボイス:「プルリクメッセージには書かれていませんでしたが、振る舞いが修正されたのはAction Viewのnumber_to_human_sizeと、Active Supportの#to_fs(:human_size)でした」「#to_fsto_formatted_sのエイリアス」「たしかto_sに引数を渡すのは非推奨化されてて、to_fsでやるようになってましたね(ウォッチ20221025)」「absで絶対値を取るシンプルな修正↓」

# activesupport/lib/active_support/number_helper/number_to_human_size_converter.rb#L44
        def exponent
          max = STORAGE_UNITS.size - 1
-         exp = (Math.log(number) / Math.log(base)).to_i
+         exp = (Math.log(number.abs) / Math.log(base)).to_i
          exp = max if exp > max # avoid overflow for the highest unit
          exp
        end

        def smaller_than_base?
-         number.to_i < base
+         number.to_i.abs < base
        end

参考: § 10.4.2 to_fs -- Active Support コア拡張機能 - Railsガイド

🔗 マルチDBのrollback/up/downで指定していないDBスキーマをダンプしないよう修正

現在のrails db:migrate:primary(およびrails db:up:primaryrails db:down:primary)は、必要ない場合にも全データベースのスキーマをダンプしていた。

修正: #49351(問題についてはここの議論を参照)

同PRより


つっつきボイス:「issue #49351を以下に抜き出してみました↓」

再現手順

新規Railsアプリを作成する。

rails new --minimal rails-rollback-bug
cd rails-rollback-bug

database.ymldevelopmentセクションを以下で置き換える。

development:
  primary:
    <<: *default
    database: db/primary.sqlite3
  secondary:
    <<: *default
    database: db/secondary.sqlite3

ロールバックタスクを実行する。

rails db:rollback:primary

このタスクがprimaryデータベースだけに対して実行されるはずだったにもかかわらず、両方のデータベースでスキーマがダンプされていることを確認する。

ls db | grep schema
schema.rb
secondary_schema.rb # これがあってはいけない

期待される振る舞い

primaryデータベースのスキーマだけがダンプされるべき、つまりschema.rbファイルだけが作成されるべき。

実際の振る舞い
2つのデータベースのスキーマが両方ともダンプされる、つまりschema.rbとsecondary_schema.rbファイルが作成される。

同PRより

「rakeファイルの修正を見ると、3箇所で名前が渡されてなかったのね↓」「普通にバグでしたね」

# activerecord/lib/active_record/railties/databases.rake#L207
-         db_namespace["_dump"].invoke
+         db_namespace["_dump:#{name}"].invoke

🔗 PostgreSQLのmoney型へのキャストが特定の値でエラーになる問題を修正

説明はコミットメッセージの方に書かれていました。なおこの修正は7-1-stableブランチと7-0-stableブランチにもバックポートされました

ガード内の/^-?\D*+[\d.]+,\d{2}$/という正規表現は、後続のgsub!とマッチすることが保証されない(例: "3,50"をキャストする場合)。
つまりgsub!nilを返す可能性があり、その場合、チェインされているsub!呼び出しがNoMethodErrorエラーを発生する可能性がある。

このコミットは、この呼び出しチェインを分割する。また、このようなケースでは正規表現は不要なので、gsub!delete!に、sub!tr!にそれぞれ置き換える。

共著: Jonathan Hefner jonathan@hefner.pro
同PRのcommit bcd3589より

# activerecord/lib/active_record/connection_adapters/postgresql/oid/money.rb#L16
          def cast_value(value)
            return value unless ::String === value
            # Because money output is formatted according to the locale, there are two
            # cases to consider (note the decimal separators):
            #  (1) $12,345,678.12
            #  (2) $12.345.678,12
            # Negative values are represented as follows:
            #  (3) -$2.55
            #  (4) ($2.55)
            value = value.sub(/^\((.+)\)$/, '-\1') # (4)
            case value
            when /^-?\D*+[\d,]+\.\d{2}$/  # (1)
-             value.gsub!(/[^-\d.]/, "")
+             value.delete!("^-0-9.")
            when /^-?\D*+[\d.]+,\d{2}$/  # (2)
-             value.gsub!(/[^-\d,]/, "").sub!(/,/, ".")
+             value.delete!("^-0-9,")
+             value.tr!(",", ".")
            end

            super(value)
          end
# activerecord/test/cases/adapters/postgresql/money_test.rb#L55
  def test_money_type_cast
-   type = PostgresqlMoney.type_for_attribute("wealth")
-   assert_equal(12345678.12, type.cast(+"$12,345,678.12"))
-   assert_equal(12345678.12, type.cast(+"$12.345.678,12"))
-   assert_equal(12345678.12, type.cast(+"12,345,678.12"))
-   assert_equal(12345678.12, type.cast(+"12.345.678,12"))
-   assert_equal(-1.15, type.cast(+"-$1.15"))
-   assert_equal(-2.25, type.cast(+"($2.25)"))
-   assert_equal(-1.15, type.cast(+"-1.15"))
-   assert_equal(-2.25, type.cast(+"(2.25)"))
+
+   {
+     "12,345,678.12" => 12345678.12,
+     "12.345.678,12" => 12345678.12,
+     "0.12" => 0.12,
+     "0,12" => 0.12,
+   }.each do |string, number|
+     assert_equal number, type.cast(string)
+     assert_equal number, type.cast("$#{string}")
+
+     assert_equal(-number, type.cast("-#{string}"))
+     assert_equal(-number, type.cast("-$#{string}"))
+
+     assert_equal(-number, type.cast("(#{string})"))
+     assert_equal(-number, type.cast("($#{string})"))
+   end

参考: String#gsub! (Ruby 3.2 リファレンスマニュアル)
参考: String#delete! (Ruby 3.2 リファレンスマニュアル)
参考: String#tr! (Ruby 3.2 リファレンスマニュアル)

つっつきボイス:「通貨の値がたとえば"3,50"というフランス式表記(後述)になっているとエラーになっていた」「修正前だとgsub!sub!がチェインされていたけど、これだと"3,50"gsub!(/[^-\d,]/, "")で何も置き換えられないのでgsub!nilを返し、そのnilに対してsub!を行うとNoMethodErrorになっていたんですね」

「修正はチェインをやめて2行に分ける形で行ったのね」「実際、修正前のチェインを手元で以下のように2行に分けてみると正常に動作しました↓」

value = "3,50"
value.gsub!(/[^-\d,]/, "")
value.sub!(/,/, ".") #=> "3.50"

「それとは別にgsub!delete!に、sub!tr!にそれぞれ置き換えていますけど、これはバグとは無関係で、単に正規表現を使わなくても書ける高速なメソッドに置き換えたということですね(正規表現だと遅くなりがちなので): ちなみに修正前後の[^-\d.]^-0-9.[^-\d,]^-0-9,は、書き方が違うだけで内容は同じです」


「以前ローカライズの仕事をやっていたときに知ったんですが、通貨の(に限らず)ピリオドとカンマの書き方って国によって違っているんですよ」

参考: 小数点 - Wikipedia

「たとえばテストデータの1番目の12,345,678.12は日本でも使われている記法で、小数点はピリオド.で桁区切りはカンマ,というイギリス式なんですけど、2番目の12.345.678,12という数値はフランス式の記法です」「あ〜、そういえばフランスでは書き方が違うみたいな話があった気がする」「はいそうなんです: 実はヨーロッパの一部の国(後で調べるとフランス、ドイツ、イタリア、スペイン、ロシア)ではピリオドとカンマが入れ替わっているんですよ」「これは面倒くさいヤツ...」

「さらにフランスでは、小数以下の桁も0.168 588 478みたいにnbsp(ノーブレークスペース)で3桁ずつ区切ることになっています」「数学記号や記法が国によって違う、あるあるですね」「数値のパースは以前やったことありますけど、なかなか言う事聞いてくれなかったな〜」

参考: ノーブレークスペース - Wikipedia


「ところで話は逸れますけど、自分たちが小学校のときは、桁区切りは一万円を1,0000円みたいに4桁区切りで習った覚えがあるんですよ」「あ、私もそうでした!」「えぇ、それ知らない...」「日本の貨幣の単位だと4桁区切りの方が万とか億とかと合うからわかりやすかったのに」「それがいつの間にか3桁区切りになってて戸惑った」「3桁区切りは英語で読む場合はthousandとかmillionと合うからわかりやすいんですけどね」「昭和のどこかの時点で教育現場が4桁区切りから3桁区切りに移行したんですかね」「当時は自分の知らないところでいつの間にか変更された気分でした」「もしかするとデータ交換との兼ね合いで3桁区切りになったのかも?」

後で調べてみましたが、学習指導要領が当時変わったのかどうかはわからずじまいでした。

🔗 BroadcastLogger#deep_dupを追加

動機/背景

このプルリクを作成した理由は、自分のアプリでBroadcastLoggerのインスタンスを手軽に複製したかったため。単にdupを呼んでもbroadcastsが複製されないのでよくない。なのでdeep_dupが使えてもよいのではと思っている。

詳細

このプルリクは、ActiveSupport::BroadcastLogger#deep_dupを追加する。deep_dupを呼ぶとブロードキャストロガーが複製され、個別のブロードキャストをイテレートして複製する。

同PRより


つっつきボイス:「BroadcastLoggerはRails 7.1リリース直前に駆け込みで追加された機能ですね(ウォッチ20231011)」「dupだといわゆるshallow copyになってしまうやつですね」「deep_dup欲しいから足した、わかりやすい」

# activesupport/lib/active_support/broadcast_logger.rb#L221
+   def initialize_copy(other)
+     @broadcasts = []
+     @progname = other.progname.dup
+     @formatter = other.formatter.dup
+
+     broadcast_to(*other.broadcasts.map(&:dup))
+   end

🔗 レコードの第2主キー以降が空文字列""の場合にurl_helpersが無効なURLを生成する問題を修正

修正: #49695(議論についてはこちらを参照)
同PRより


つっつきボイス:「これだけだとわからないので、リンク先のissue #49695にある再現手順の最後を取り出してみました」

期待される振る舞い

生成されるURLは、複合主キーの一部が空であっても正しく処理されるべき。空のキーをサポートしないのは設計上の選択かもしれないが、少なくともURLヘルパーでは無効なURLを警告すべき。

実際の振る舞い

マージされた複合主キーを含む生成URLは、第2主キーが空だと常に404エラーを返す。

#49695より抜粋

「URLヘルパーは、url_forとか、ルーティングのリソースから動的に生えてくる何とか_pathなどのことでしょうね」「ここで言う空(blank)というのは何を指すのかな?」「テストコードを見ると空文字列""ですね↓」

# actionpack/test/controller/parameters/accessors_test.rb#L425
  test "#extract_value splits param by delimiter" do
    params = ActionController::Parameters.new(
      id: "1_123",
-     tags: "ruby,rails,web"
+     tags: "ruby,rails,web",
+     blank_tags: ",ruby,,rails,"
    )

    assert_equal(["1", "123"], params.extract_value(:id))
+   assert_equal(["ruby", "rails", "web"], params.extract_value(:tags, delimiter: ","))
+   assert_equal(["", "ruby", "", "rails", ""], params.extract_value(:blank_tags, delimiter: ","))
    assert_nil(params.extract_value(:non_existent_key))
  end

🔗 ActiveRecord::Persistence.deleteに主キーとして無効な値を渡した場合にクエリを発行しないよう修正

ActiveRecord::Persistence.deleteメソッドに何らかの無効な主キーが渡されると、Active Recordは本来不要な場合であっても以下のようにDELETEを実行してしまう。

irb(main):001> User.delete([])
  User Delete All (0.6ms)  DELETE FROM "users" WHERE 1=0
=> 0

つまり、よく行われているようにUser.delete(ids) if ids.compact.any?User.delete(id) if idのように呼び出しの前後に条件を追加したり、あるいは条件を付け忘れたりすると、不要なクエリが実行されていた。しかしユーザーからこの重荷を取り除くことは可能。
以下は最近の実例:

solid_cache/app/models/solid_cache/entry.rb at 9be6755a2d8c8fd1c294912e1fbf6bbb69410c61 · rails/solid_cache

しかしNULLは果たして有効な値なのだろうか、実際には無視しても大丈夫なのだろうか?自分がチェックしたところ、主キーはNULLにできないことがわかった。ANSI標準でもそうなっているし、PostgreSQL、MySQL、Microsoft SQL Server、Oracle Serverもこれに準拠している。

例外はSQLiteで、ドキュメントによると「歴史的な理由により、いくつかの条件が満たされる場合を除いてNULL許容になる」(そうした条件の中には、明示的なNOT NULLや、テーブルをSTRICTモードで作成した場合などがある: 詳しくはリンク先のドキュメントを参照)。しかしRailsでは明示的にNOT NULLを設定している。

rails/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb at ad1e443e535a6709cc314b614e757eb843d8c208 · rails/rails

|  | primary_key: "integer PRIMARY KEY AUTOINCREMENT NOT NULL", |

そして#45346ではstrictモードが設定されている。つまり、nil#deleteに提供する値として安全だと思う。

同PRより


つっつきボイス:「最初見たときに、無効な主キーを渡すと実際にレコードが削除されていたのかと思ったけど、WHERE 1=0という絶対成立しない条件が設定されているから、DELETEは発行するけど何も行われてなかったということなんですね」「何もしないクエリだから害はありませんけど、発行するのは無駄なので、主キーが無効な場合はそもそもクエリを発行しないように早期脱出を追加したということですね」「deleteは削除した件数を返すから、キーが無効なら0を返して終了、なるほど」

# activerecord/lib/active_record/persistence.rb#L565
      def delete(id_or_array)
+       return 0 if id_or_array.nil? || (id_or_array.is_a?(Array) && id_or_array.empty?)
+
        delete_by(primary_key => id_or_array)
      end

「ところで、コード例のUser.delete([])を見てて思い出したんですけど、空配列だとループを回さない、つまり何もしないというメソッドってたまにありますよね: そういうメソッド呼び出しの後ろにif !ary.empty?みたいなのを書いてあって、それ空だったら何もしないヤツだからif文いらないでしょっていうコードを人生で3回ぐらい見かけたことがあります」「あ〜、ありそう」

🔗 @rails/ujsを7.1.0にしたときの読み込みエラーを修正

これは7-1-stableにもバックポートされました(#49773)。

動機/背景

@rails/ujsを7.1.0以上にアップグレードすると以下のエラーが発生するという問題が最近持ち上がっている。

Uncaught Error: rails-ujs has already been loaded!

一般に、この問題はrails-ujsの振る舞いが「純粋なsprockets環境(JSを追加する)」と「バンドル環境(webpackやesbuildなど)」とで異なるために発生する。Sprockets環境ではstart()を自動的に呼び出すことが前提になっているが、バンドル環境ではユーザーがそれらをimportしてstart()を呼び出すことが要求される。

CoffeeScriptからJavaScriptへの移行の一環として、現在の環境が(JS)バンドラー環境であるかどうかを検出する条件が追加され、sprocketsの場合にのみstart()を呼び出すようになっている。しかし、この条件は期待通りに動かない。自分はimportmapとesbuildでrails-ujsを使った場合にこのエラーを再現することに成功した。興味深いことに、webpackを(JS)バンドラーにした場合はこの問題は発生しなかった。

詳細

修正方法としては、条件をうんと明確にすることがベストだと思う。sprocketsユーザーはesm(ESモジュール)バージョンのrails-ujsを使うべきではないので、rollupのreplaceプラグインを使ってesmバンドルでのstart()自動呼び出しを常にオプトアウトできる。これが機能する理由は、terserがreplaceプラグインより後に実行され、条件全体が(true == falseのような)デッドコードとして削除されるからである。

追加情報

修正: #49499
クローズ: #49606

自分は以下のアプリを使ってsprockets、importmap、esbuild、webpackをテストした。

skipkayhil/rails-ujs-app - GitHub

cc @tricknotes: この問題で作業してもらったので共著者に加える
cc @Earlopain: これをテストしてもらえると非常に助かる
同PRより


つっつきボイス:「CTOのbabaさんもRailsを7.1にアップグレードしたときにこのエラーを見たそうです」「rails-ujsの振る舞いがSprocketsの場合とバンドルの場合で違うの、ややこしそう」「esbuildとかrollupとかwebpackとか、フロントエンド関連は登場するものが多くて大変」「terserって何だろうと思ったらJavaScriptのminifyツールなんですね」

terser: terse(そっけない、簡素な)の比較級

参考: Terser

🔗Rails

🔗 特別企画: Kaigi on Railsの@moroさんの発表を見る


つっつきボイス:「今日はちょっと趣向を変えて、Kaigi on Rails 2023で大評判だった@moroさんこと諸橋さんの発表(参加レポート記事)を少し早回しで視聴してみたいと思います」「ほほ〜、これは楽しみ」「Railsを長くやっている人ほど絶賛していたので、強い人の感想を詳しく聞いてみたいと思います」(しばらく視聴)


「これはいい発表!👍: この内容が、"え、どうしてこんな当たり前の話をするの?"と思われるぐらいの世界になってくれたら理想ですね」「こういう設計が普通になる世界!」「もちろん簡単にはいかないでしょうけど」

「このE/R図↓ではconferencesテーブルとusersテーブルを中間テーブルを介して多対多のリレーションにしていて、それ自体はリレーショナルデータベースではよく行われることなんですが、何も考えずに中間テーブルを作ると、中間テーブルの名前がregistrationsじゃなくて単なるconferences_usersになっちゃうんですよ」「あ、たしかにRailsのhas_and_belongs_to_many(HABTM)でやるとテーブル名はそうなりますね」

参考: やさしい図解で学ぶ ER図 表記法一覧 #Rails - Qiita

「そうではなくて、中間テーブルにregistrationsという意味のある名前を明示的に付けて概念を与える、そしてこれは"コト"としてリソースにできる: この考え方はとてもよくできていると思います」

「その一方で、このように抽象化するのは、特に最初のうちは実はなかなか勇気が要るんですよ」「自分がやろうとすると、本当にこんなふうに設計していいんだろうかという気持ちになりそうです」「この"コト"が要件に潜んでいることに、最初はなかなか気づきにくいんですね」

「Webアプリは何もリレーショナルデータベースをそのままRESTの形にするのが仕事ではありませんし、conferences_usersregistrationsも、それだけならWebアプリの画面には直接出てこないものなので、なおさら気づきにくい: 要件からこういう"コト"をリソースとして見い出して使いこなせるようになったら、設計の語彙と表現力がとても豊かになると思います、素晴らしい」「見えにくいコトをうまく見い出せたらきっと快感でしょうね」

参考: Representational State Transfer - Wikipedia -- REST

「RESTを真面目に設計していれば、どこかでこういうふうにコトをリソースとして扱うようになるはずなんですよ: 逆に"動く機能がありさえすればいい"とばかりに、モデルも作らずにコントローラのアクションを生やして機能を書きまくっていると、肥大化してfatコントローラになってしまう昔ながらのアンチパターンになる」

「そうやってfatコントローラにコードを押し込めてしまうと再利用性もなくなるので、後でお客さんから"この機能を他の場所でも使いたい"というリクエストが来たときに詰む: 業務開発でそこまで極端なことはそうありませんけど、それに近いことは割りと起きがちですね」

「スライド↓にあるConferenceController#registerアクションを定義してしまうの、つい自分もやってしまいそう...」「そうそう、こんなふうに自分が書いているものがRESTじゃなくなったときに、"あれ、これでいいんだろうか?"と立ち止まれるかどうかが大事」「ルーティングのリソースにpost :registerみたいなのを書いたら負け、と」「もちろん何が何でもRESTにしなければならないという話ではないんですが、まずはRESTで自然に書く方法があるんじゃないかと十分考えてからにするのが大事」

「そういえば私はこういうのを"縛りプレイ"とよく呼んでいますね: Railsでいいとされていることしかやらない、野良アクションを生やさない、みたいに、RESTという縛りの中でどこまで自然に抽象化できるかを頑張っていると、コントローラがほぼscaffoldテンプレートと変わらないものになったりする」「野良アクションを生やさない、なるほど」「みんな縛りプレイしないのかな、もっとやればいいのにって思ったりします😆」

「RESTという概念にはこういう"コト"とか"イベント"の概念も含められるんですね: RESTわかってなかった...」「RESTはリレーショナルデータベースと1対1対応する必要はまったくありませんし、極端に言えばRESTはデータベースとつながっていなくてもいいんですよ: @moroさんが引き合いに出していた、ログインやログアウトを"セッションのcreatedeleteで表す"↓というのがまさにそれで、自分も大好きですし、いい例えだと思います」

「逆に言えば、RailsはRESTから外れても書けるので、コントローラにアクションを書いてルーティングを足せばそれだけで動くんですよ」「それもそうですね、だからこそ小さなアプリなら初心者がとりあえず作って動かせる」

参考: Rails をはじめよう - Railsガイド

「でもそれだけだと、ともすれば@moroさんの発表で説明しているような"コトの抽出"みたいなことを見落としたり忘れたりしがちなんですね: そして特に業務アプリを開発するうえでは、こういう"コトの抽出"のような部分こそがRailsの設計のキモなんだということを、ぜひ多くの人に知っていただきたいですね」


「そういう話がとてもシンプルにまとまっていて、本当にいいスライド👍」「話の持って行き方もうまいですよね」「キーノートスピーチにしてもいいくらい」「英語字幕付けて海外で普及させてもいいかも」

「こういう発表を若いうちに見られる人が本当にうらやましいですね: このあたりの概念を早いうちに把握して設計の練習を重ねれば、コードのコピペに頼らない、まっとうなRailsエンジニアとして成長できると思います」

「この設計を体現した実際のRailsアプリのコードを見てみたいです」「たぶん有名どころのオープンソースRailsアプリなら大体そうなっているんじゃないかな: 少なくともfatコントローラのような悪いことはしてないと思います」

has_many :throughとHABTMの使い所の違いもよくわかって嬉しいです」「has_many :throughなどのthrough系リレーションは自分も大好きで使いまくっています: よくできていますし、チェインした1個先や2個先のリソースがどんどん手に入るようになるのが楽しくてしょうがないですヨ」

参考: §2.8 has_many :throughhas_and_belongs_to_manyのどちらを選ぶか -- Active Record の関連付け - Railsガイド

「こういう設計を理解して会得するにはやっぱり練習が必要でしょうね」「そうそう、特に業務アプリだと最初は勇気も要りますね」「業務で最初にそういう良質な設計のコードに触れる機会があると、アプリの構造もわかりやすいし、設計の学びも大きいでしょうね」

「理解や練習ももちろん大事ですけど、こういう設計についてどしどし話をしたり質問ができるような場があることが一番重要なんじゃないかなって、聞いてて思いましたね」「あ〜、たしかに!」「こういうものを一人で理解するのは大変ですし、一人で理解するものでもない気がするんですよ: Registrationみたいな概念を最初にたった一人で見つけられる気がしないし、それこそ議論の賜物じゃないかと思うんですよ」「わかる」「私は自分が慣れ親しんでいた大学の研究室のゼミのような場を思い浮かべたんですけど、勉強会でもいいし、つっつき会のような雑談の場でもいいので、そういう場があるといいですよね」


スライドで@moroさんが学んだという書籍を挙げておきます↓。

参考: 楽々ERDレッスン(羽生 章洋)|翔泳社の本


今週は以上です。

バックナンバー(2023年度第4四半期)

週刊Railsウォッチ: ShopifyのWebAssemblyツールチェインRuvyほか(20231025後編)

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

Rails公式ニュース


CONTACT

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