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

週刊Railsウォッチ: Hanami 2.0リリース、Railsに関わる技術の体系化を目指した本ほか(20221129前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 readonlyの属性に代入するとActiveRecord::ReadonlyAttributeErrorを発生するようになった

# (修正後の挙動)
class Post < ActiveRecord::Base
  attr_readonly :content
end
Post.create!(content: "cannot be updated")
post.content # "cannot be updated"
post.content = "something else" # => ActiveRecord::ReadonlyAttributeError

従来は、こうした代入はエラーにならずに成功するがデータベースには書き込まれなかった。
この振る舞いを以下のコンフィグで制御可能になった。

config.active_record.raise_on_assign_to_attr_readonly = true

また、この振る舞いはload_defaults 7.1でデフォルトで有効になる。
Alex Ghiculescu, Hartley McGuire
同Changelogより


つっつきボイス:「readonlyの属性にうっかり代入しちゃうの、あるある」「ちょうど最近そういうバグを踏んだので、これは嬉しい👍」「Rails 7.1からはエラーを出すのがデフォルトになるんですね」「プロジェクトによっては7.1でbreaking changeになる可能性があるので、この改修は知っておきたい」「プロジェクトのコードがreadonlyに代入していなくても、gemが代入する可能性はありそうですね」

🔗 関連付けのpreloadeager_loadunscopeできるようになった

概要
関連付けのpreloadeager_loadunscopeする機能を追加する。
これは、unscope(:includes)(クエリ全体の複雑さに応じてpreloadまたはeager_loadを選ぶ)と同じ機能である。
その他情報
関連付け読み込みの種別が明確な場合(eager_loadおよびpreload)は、その明示的な関連付けをunscopeできるようになる。これは、has_many関連付けが明示的にpreloadまたはeager_loadを要求する場合に既存のクエリを取得して集計を実行するのに便利である。以下の例では、2つのhas_many関連付けをunscopeすると、余分なpreloadクエリとeager_loadのJOINが削除される。

query.eager_load!(:has_many_association1)
query.preload!(:has_many_association2)
query.unscope(:eager_load, :preload).group(:id).select(:id)

selectpreloadの関連付けキーを含まない場合は、集計とselect次第で明示的なpreloadが失敗するケースがある。この問題がきっかけでこの修正を作成した。
同PRより


つっつきボイス:「関連付けのpreloadeager_loadを今までunscopeできなかったのか」「そういうunscopeってできないかと思ってたけど、言われてみれば最終的なArelを組み替える形でできそうではありますね」「unscopeはあまりやりたいと思ったことなかったけど、特定のeager loadingを外してクエリを軽くしたいときなんかに使いたいこともあるので、あると嬉しいのはわかる👍」

unscopeそのものは昔からあるメソッドですね」「ORDER BYを外したいときとかに使いますね」

参考: Rails API unscope -- ActiveRecord::QueryMethods

unscopeというと、default_scopeunscopeしてハマった記事↓を思い出します」「複数のwhere節が設定されているときにそのうちの1つをunscopeするつもりでunscope(:where)したら、unscopeするつもりのなかったwhere条件もunscopeされてしまったりするので注意が必要ですね」

よくある?Rails失敗談 default_scope編

Railsのdefault_scopeは使うな、絶対(翻訳)

🔗 #inspect実行時に暗号化済み属性を自動的にフィルタで除外する機能が追加された

この機能はデフォルトで有効になるが、以下のコンフィグで無効にできる。

config.active_record.encryption.add_to_filter_parameters = false

Hartley McGuire
同Changelogより


つっつきボイス:「暗号化済み属性ってinspectでフィルタされていなかったのか」「add_to_filter_parametersというコンフィグが追加されて、デフォルトで有効になるんですね」「developmentモードでフィルタを外したくなることはありそう」

「これと直接関係はありませんが、前に取り上げたaudits1984やconsole1984のことをちょっと思い出しました(ウォッチ20211004)」「コンソールの保護と監査を行う37signals(旧Basecamp)のgemですね」

basecamp/audits1984 - GitHub

basecamp/console1984 - GitHub

Rails 7のActive Record暗号化機能(翻訳)

🔗 ActiveRecord::Relation#first_or_initialize#create_or_initializeの実行時に暗号化済み属性を初期化するよう修正

修正対象: #46151(問題についてはこちらを参照)
ここでの問題は、where(...).first_or_createのような書き方をするときに、first_or_createで以下のようにwhereの"等しさ"(equality)に関する述語(trueかfalseとして扱われるもの)に該当するものだけをActive RecordがSELECTするが、

# https://github.com/rails/rails/blob/90cba59ddd26a1fbbad85e9a9810583e2de85279/activerecord/lib/active_record/relation.rb#L754
hash = where_clause.to_h(klass.table_name, equality_only: true)

暗号化機能は、whereでそれに対応する述語を、その属性で取りうる値の配列に変換する(キーやアルゴリズムのローテーションをサポートするのが目的)。

# https://github.com/rails/rails/blob/90cba59ddd26a1fbbad85e9a9810583e2de85279/activerecord/lib/active_record/encryption/extended_deterministic_queries.rb#L66-L74
 when String, Array 
   list = Array(value) 
   list + list.flat_map do |each_value| 
     if check_for_additional_values && each_value.is_a?(AdditionalValue) 
       each_value 
     else 
       additional_values_for(each_value, type) 
     end 
   end

これは上述した"等しさ"の述語でない形になり、無視されてしまう。
そこで、受け取った暗号化済み属性を考慮するためにscope_for_createを手動で拡張する必要がある。
この説明で多少なりとも明確になるとよいが。
cc @jorgemanrubia(この機能の最初の実装者)
同PRより

参考: Ruby用語集 (Ruby 3.1 リファレンスマニュアル)

述語メソッド
predicate method
返り値を真偽値として用いるためのメソッド。メソッド名の末尾に ? を付ける習慣がある。
true/false を返すとは限らず、真である場合に、true 以外のオブジェクトを返すことで、単なる真偽を越えた情報を与えるものもある。
docs.ruby-lang.orgより


つっつきボイス:「どちらもRails 3.2ぐらいの頃からあるメソッド」「属性が暗号化されている場合に整合性が取れていなかったバグを修正した感じですね」「#first_or_initialize#create_or_initializeはAPIドキュメントがないのか↓」

# https://github.com/rails/rails/blob/656118bc17791291db7c10075b489dfc6f93fce3/activerecord/lib/active_record/relation.rb#L121
    def first_or_create(attributes = nil, &block) # :nodoc:
      first || create(attributes, &block)
    end

    def first_or_create!(attributes = nil, &block) # :nodoc:
      first || create!(attributes, &block)
    end

    def first_or_initialize(attributes = nil, &block) # :nodoc:
      first || new(attributes, &block)
    end

参考: Ruby on Rails 3.2 リリースノート - Railsガイド -- first_or_createfirst_or_create!first_or_initialize

🔗Rails

🔗 Railsのルーティングの高度なconstraintsRubyFlowより)


つっつきボイス:「ルーティングのconstraintsは普通に書くぶんにはいいけど、思い通りのルーティングにしようとすると割と大変」

参考: 3.8 セグメントを制限する -- Rails のルーティング - Railsガイド

「こういうふうにconstraintsにlambdaを渡してカスタマイズするのは割とよくある↓」「ここではリクエスト元IPアドレスを元に制限しているんですね」「この辺はググるといろいろ見つかりますよ」

# 同記事より
get '*path', to: 'restricted_list#index',
    constraints: lambda { |request| RestrictedList.retrieve_ips.include?(request.remote_ip) }

「そうそう、こういうふうにカスタムクラスでmatches?を定義することもよくあります↓」

# 同記事より
class ShortenerRouteConstraint
  def matches?(request)
    # if request.path is not in static routes it should be a short link
    static_page_routes.exclude?(request.path)
  end

  private

  def static_page_routes
    # dynamically pull all static pages + admin, 404, and errors
    static_page_paths = Dir.new("app/views/static").children.map do |f|
      "/#{File.basename(f, ".html.erb")}"
    end
    static_page_paths << "/admin"
    static_page_paths << "/404"
    static_page_paths << "/500"
  end
end

「動的に制約をかけたい場合は、定数やリテラルではなくlambda渡しや#matches?を実装したオブジェクトを渡すことで実現できます」「なるほど」

🔗 RSpecがときどき失敗する問題(Ruby Weeklyより)


つっつきボイス:「ActiveSupport#descendantsが遅いからうんと速くしたらたまにテストが落ちるようになったらしい」「CachedDescendantsで子孫クラスをキャッシュから探索するようにしてたのか↓」

# 同記事より
module Mixins
  module CachedDescendants
    extend ActiveSupport::Concern

    cattr_accessor :descendants_map

    self.descendants_map = Concurrent::Hash.new

    class << self
      # Clears the descendants map cache - can be hooked to Rails reloader
      def reload!
        descendants_map.clear
      end
    end

    included do
      class << self
        # @return [Array<Class>] array with descendants classes
        def cached_descendants
          ::Mixins::CachedDescendants.descendants_map[self] ||= descendants
        end
      end
    end
  end
end

ActiveSupport#descendantsを毎回たどらなくていいようにすれば確かに爆速になるけど、見るからにマルチスレッドで壊れそうなコード」「一応concurrent-rubyのConcurrent::Hashを使っているとはいえ、こういう書き方ってドキドキしちゃいますね」

ruby-concurrency/concurrent-ruby - GitHub

「起きたり起きなかったりする問題はつらい...」「原因を突き止めて再現までしたのはすごい」

# 同記事より: 再現コード
GC.disable

puts "Total classes before: #{ObjectSpace.count_objects[:T_CLASS]}" 
puts "String subclasses count before: #{String.subclasses.count}" 

100.times { Class.new(String) }

puts "Total classes after defining: #{ObjectSpace.count_objects[:T_CLASS]}" 
puts "String subclasses count after defining: #{String.subclasses.count}" 

GC.enable
GC.start

puts "Total classes after GC: #{ObjectSpace.count_objects[:T_CLASS]}" 
puts "String subclasses count after GC: #{String.subclasses.count}" 

# Total classes after defining: 1324
# String subclasses count after defining: 102
# Running GC...
# Total classes after GC: 1124
# String subclasses count after GC: 2

「記事の結論はこれですね↓」

無名クラスや無名モジュールは、他のオブジェクトと同様にガベージコレクションの対象になる。それらを参照していないと、#descendantsなどで探索して使いたいと思っても、その前に消えてしまう可能性がある。絶えず何らかの形で参照しておかないと思わぬことになる。
同記事より

🔗 Railsに関わる技術の体系化を目指した本

編集部注: 上の記事はつっつき時点(2022/11/24 20:00頃)には読めましたが、その後Kindleストア配信化を目指して非公開になったことが以下の記事に追記されています。Kindleではタイトルが変わる可能性もありそうです。

参考: Rails初・中級者向けの技術の体系化を目指した本を書きました

無料で公開していましたが、現在Kindleストアでの配信を検討しており非公開にしました。
いいねしていただいた方ありがとうございます。
おそらく、近いうちにkindle unlimited(amazonの読み放題サブスクサービス)で読めるようになると思いますが、自分としては本を出すのは初めてでかなり実験的な施策なので今後どのようになるかは今ひとつ予測不能です。
低評価レビューで心が折れるような事態にならないといいなと願ってます。
Rails初・中級者向けの技術の体系化を目指した本を書きましたより


つっつきボイス:「TechRacho記事も引用されていたことで気づきました」「ざっと見た感じでは、@takahashimさんも書いているように、Railsにはどんな手法やパターンがあるかを一通り網羅することを目指している本みたい」「書かれていることを全部使うような本ではないということですね」「こういうのをまとめる過程が勉強になる」「初めて参加するプロジェクトで見慣れない手法やパターンが登場したときに調べるのに便利そう👍」


「ところでRailsを徹底的に学ぼうとすると、RailsチュートリアルやRailsガイドより先の部分を詳しく解説した本や教材も必要になりますよね」「Railsの深淵に触れるためにはどうしても必要になりますね」

参考: Ruby on Rails チュートリアル:プロダクト開発の0→1を学ぼう
参考: Ruby on Rails ガイド:体系的に Rails を学ぼう

「Railsのconcernsディレクトリひとつとっても、初めて見たときに"これは何だろう?"という気持ちになったりする」「concernsをいいと言う人もよくないと言う人もいて最初考え込んでしまいました」「concernsは使いようで良くも悪くもなる子ですね」

「concernsはRailsに限らず、RubyでModule#includeを使うベストプラクティスのひとつとして存在していますね: gemでも同じような方法を使っているのをよく見かけます」「concernsはRubyの方言というかRuby特有のパターンなのかも」

参考: Module#include (Ruby 3.1 リファレンスマニュアル)

「個人的には、1個のクラスでしかincludeされないコードならconcernsでモジュール化しなくてもいい気がすることもあるんですが、Rubyはそういうふうに書く文化なんだと考えるようになりました」「Rubyはオープンクラスなんだから、それで書けばいいのではと思ったりすることもありますけど、モジュールのincludeprependなどによってクラスの継承チェインに配置される方が便利だからそう書くんだろうなと思うようになりました」「そうそう、だから気軽にsuperを使える」「フックもはさめるようになりますよね」

オープンクラス
open class
組込みのクラスが再定義可能であること。 Ruby は String や Integer といった基本的なクラスも自由に改変できる。
しかし、既存のクラスやモジュールをむやみに改変することは思わぬバグを生みやすい。そのため、改変の効果を局所化する refinement という機構がある。
Ruby用語集 (Ruby 3.1 リファレンスマニュアル)より

追記(2022/12/05)

その後同書は『Ruby on Railsステップアップ』というタイトルで無事Kindle Unlimited化されました🎉

🔗 その他Rails


つっつきボイス:「RubyConf Mini 2022というイベントが11/15〜17に開催されていたそうで、そのまとめ記事です」「ロードアイランド州プロビデンス開催か」「Miniと銘打たれているにしては3日連続でスピーチも盛りだくさんですね」「あ、たしかに」

参考: RubyConf Mini
参考: プロビデンス (ロードアイランド州) - Wikipedia

「よく見ると、それとは別にRubyConf 2022が11/29〜12/1にテキサス州ヒューストンで開催される予定になっていました↓」「こちらが本番ということなのかな?」「並列のセッションが最大4つもありますね」「Miniとは会場も別なんですね」「同じ会場でやる方が会場や宿泊施設などを押さえやすそうだけど、何らかの意図か事情があるのかも🤔」

参考: Home | RubyConf 2022
参考: ヒューストン - Wikipedia

🔗 Hanami

🔗 Hanami 2.0がリリース

hanami/hanami - GitHub


つっつきボイス:「Hanamiもついに2.0がリリース🎉」「alpha2のリリースが昨年でしたね(ウォッチ20210517)」「"Better, Faster, Stronger"」「Hanamiの人たち頑張ってますね」「Hanamiはメンテしている人たちも実際に使い続けていますし、Railsよりスリムなものが欲しい人には人気があるんじゃないかな」「今のRailsもAPIモードでrails newするとスリムになりますけど、それでも色々入ってきますね」「Railsで使わない機能を後から外そうとすると割と大変」「使わないつもりの機能でも、入っていると使われちゃったりしますよね」

hanami.gemspecを覗いてみたらzeitwerkも入ってました↓」「お〜、こういうところもキャッチアップしているんですね」

...
  spec.add_dependency "bundler",          ">= 1.16", "< 3"
  spec.add_dependency "dry-configurable", "~> 1.0", "< 2"
  spec.add_dependency "dry-core",         "~> 1.0", "< 2"
  spec.add_dependency "dry-inflector",    "~> 1.0", "< 2"
  spec.add_dependency "dry-monitor",      "~> 1.0", ">= 1.0.1", "< 2"
  spec.add_dependency "dry-system",       "~> 1.0", "< 2"
  spec.add_dependency "dry-logger",       "~> 1.0", "< 2"
  spec.add_dependency "hanami-cli",       "~> 2.0"
  spec.add_dependency "hanami-utils",     "~> 2.0"
  spec.add_dependency "zeitwerk",         "~> 2.6"
...

前編は以上です。

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

週刊Railsウォッチ: The Rails Foundation発足、Ruby 3.2.0 Preview 3リリース、Ruby演算子クイズほか(20221122)

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

Rails公式ニュース

Ruby Weekly

RubyFlow

160928_1638_XvIP4h


CONTACT

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