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

週刊Railsウォッチ(20201110前編)Rails 6.1 RC1がリリース、Railsアプリに最適なEC2インスタンスタイプ、n_plus_one_control gemほか

こんにちは、hachi8833です。

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

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

以下の公式更新情報より見繕いました。

つっつき直前に、6.1.0rc1タグができていることに気づきました↓。


つっつきボイス:「rc版が出るということはリリースはそう遠くなさそうかな」「6.1.0rc1タグはできていてzipもアップロードされているけど、releaseタブにはまだ6.1.0rc1が置かれてないんですよ」「rcはタグを付けるだけという運用なのかな?」「今まであまり気にしてなかった...」

「以前の6.0.3.rc1を見るとPre-releaseと表示されてReleaseに置かれている↑」「ということはrc版もリリースされたらReleaseタブに出るんでしょうね」「今回の6.1.0.rc1はまだPre-releaseと表示されてないから、まだ動く可能性がありそう」「rc1のアナウンスが出たら表示チェックしてみます」


その後、つっつき翌日の金曜日に6.1 RC1がリリースされました。

ちょっとわかりにくいのですが、Releasesを開いて「Show 2 newer tags」をクリックすると6.1 RC1が表示されます↓。



github.com/rails/rails/releasesより

ペンディング中のマイグレーションがある場合に画面/コンソール/ログに出力

# activerecord/lib/active_record/migration.rb#L146
    def initialize(message = nil)
-     if !message && defined?(Rails.env)
-       super("Migrations are pending. To resolve this issue, run:\n\n        bin/rails db:migrate RAILS_ENV=#{::Rails.env}")
-     elsif !message
-       super("Migrations are pending. To resolve this issue, run:\n\n        bin/rails db:migrate")
-     else
-       super
-     end
+     super(message || detailed_migration_message)
    end
+
+   private
+     def detailed_migration_message
+       message = "Migrations are pending. To resolve this issue, run:\n\n        bin/rails db:migrate"
+       message += " RAILS_ENV=#{::Rails.env}" if defined?(Rails.env)
+       message += "\n\n"
+
+       pending_migrations = ActiveRecord::Base.connection.migration_context.open.pending_migrations
+
+       message += "You have #{pending_migrations.size} pending #{pending_migrations.size > 1 ? 'migrations:' : 'migration:'}\n\n"
+
+       pending_migrations.each do |pending_migration|
+         message += "#{pending_migration.basename}\n"
+       end
+
+       message
+     end
  end

マイグレーションがペンディングされていたらUIやコンソールやログに表示し、どのマイグレーションがいくつペンディングされているかをすぐ把握できるようにする。
同PRより大意


つっつきボイス:「ペンディングされているマイグレーションを具体的に表示できるようになったんですね👍」「コミットやプルリクのmerge順次第では、未実行のマイグレーション番号が常に最新の番号とは限らないけど、マイグレーション番号単位で具体的に未実行のものが分かるようになるのか」

「タイトルのoutstandingって『秀逸な』という意味かと思ったら『未処理の』という意味もあるそうです」「つまりペンディング中ですね」

新機能: paramごとにエンコーディングを設定できるようになった

従来はエンコード(アクションのすべてのパラメータをASCII_8BITとしてエンコードする)をスキップできた。
今回の変更で、アクションの任意のパラメータでparam_encodingを指定できるようになった。
この変更はGitHubでパラメータのエンコーディングを扱うときに#40124の不正なエンコーディング検出をサポートする(この変更は大きい)。
これにより、POSTのbodyパラメータがリクエストのURLパラメータと異なる可能性がある点も配慮する必要がある。
同PRより大意

# actionpack/lib/action_controller/metal.rb#L138
-   def self.binary_params_for?(action) # :nodoc:
+   def self.custom_encoding_for(action, param) # :nodoc:
      false
    end

ウォッチ20200928で取り上げた#40124と関連しているそうです。


つっつきボイス:「デフォルトのエンコーディングはASCII_8BITなのはこれまでどおりだけど、UTF-8なども指定して取り出せるようになったみたい」「Rafaelさんがコメントでハッシュ探索が1段増えるとパフォーマンスに影響するのではと指摘してますね↓」

この変更によるパフォーマンスのインパクトはどうなる?パラメータ値ごとに毎回ハッシュ探索しているが、paramsのハッシュに値が200個以上あったらハッシュ探索も200回行われることになる。この変更で、このメソッドを使わないユーザーも複雑さがO(1)からO(N)に増加したりしないだろうか?
同PRのコメント(rafaelfranca)より

「続きの#40465ではcustom_encoding_forメソッドを使わない場合に呼び出しを回避している↓」「機能を使わない場合でもパフォーマンスが落ちないよう配慮したんですね」

# actionpack/lib/action_controller/metal/parameter_encoding.rb#L18
-     def custom_encoding_for(action, param) # :nodoc:
-       @_parameter_encodings[action.to_s][param.to_s]
+     def action_encoding_template(action) # :nodoc:
+       if @_parameter_encodings.has_key?(action.to_s)
+         @_parameter_encodings[action.to_s]
+       end
      end

custom_encoding_forメソッドを自分で使うところはあまり想像できませんが、プルリクの意図はそういう感じでしょうね」

crossorigin属性を使うとリソースが2回フェッチされる問題を修正

スクリプトやCSSを(それぞれjavascript_include_tagstylesheet_link_tagで)読み込むときにcrossorigin属性が適用されると、現在のRailsでは一部のブラウザでこれらのリソースを2回読み込みが発生してしまう。理由はlinkヘッダーのpreloadディレクティブやリソース自身のcrossoriginがブラウザでリソースの再利用にマッチする必要があるため。

たとえばビューで以下のタグを使うとする。

<%= javascript_include_tag("[...snip]react.production.min.js", crossorigin: "anonymous") %>

javascript_include_tagはこのリソースをLink HTTPヘッダーにプッシュするが、現在はcrossorigin属性を無視している。
これによって上述のダブルフェッチとなる。
double fetches in network tab chrome

Chromeではwarningも表示される。

chrome warning

A preload for 'https://cdnjs.cloudflare.com/ajax/libs/react/17.0.0/umd/react.production.min.js' is found, but is not used because the request credentials mode does not match. Consider taking a look at crossorigin attribute.

このプルリクではLinkヘッダーのディレクティブに、リソース自身に渡されたものと同じcrossorigin値を含めるように変更し、これによってプリロードされたリソースをブラウザで再利用できるようにする。

double fetches fixed chrome network tab

anonymousを生成するcrossorigin: trueは既存のヘルパーで行われるので、その振る舞いをここにも複製した。
同PRより大意


つっつきボイス:「2回フェッチするってブラウザ側で起きるのか!」「そういえばcrossorigin属性というものがありますね」「javascript_include_tagで作られるHTMLの問題らしい」

参考: HTML crossorigin 属性 - HTML: HyperText Markup Language | MDN

crossorigin 属性は、 <audio>, <img>, <link>, <script>, <video> の各要素で有効であり、 CORS への対応を提供し、したがって要素が読み取るデータのために CORS リクエストの構成を有効にします。要素によっては、属性は CORS 設定属性になります。
developer.mozilla.orgより

新機能: connecting_toメソッド

コンソールをreadonlyモードで起動されるようにするコードをGitHubで使おうと思ったが、そのためのpublic APIがなかったのでこのメソッドを追加した。
通常と異なるデフォルトコネクションが必要だが、そのコネクションをブロックで呼んでない場合がたまにある(コンソールを読み出しモードで起動するなど)。このプルリクは、アプリケーションコードで使うconnected_toの振る舞いを維持しつつ、起動時にスクリプトで特定のコネクションを設定する機能を追加する。
同PRより大意


つっつきボイス:「このconnecting_toはコントローラーやモデルロジック内で使うことは想定していなさそうです」「たとえばRailsコンソールのようなインタラクティブ端末内で以後の入力コードのデフォルト接続先を変えたいときなどに、このconnecting_toでできるということらしい」「へ〜」「Railsコンソールでconnecting_toを使って接続先を変えれば、readonlyな状態で操作したいなどのケースではコンソール操作でconnected_toブロックを書き忘れてもconnecting_toで設定したコネクションが使われるので、そのような用途に便利なんでしょうね」「なるほど」

# activerecord/lib/active_record/connection_handling.rb#L175
    # 特定のコネクションを使う。
    #
    # このメソッドは特定のコネクションが使われるようにするときに有用。
    # (コンソールをreadonlyモードで起動するときなど)
    #
    # このメソッドは`connected_to`と違ってブロックでyieldしないので、requestで用いることは推奨されない。
    def connecting_to(role: default_role, shard: default_shard, prevent_writes: false)
      if legacy_connection_handling
        raise NotImplementedError, "`connecting_to` is not available with `legacy_connection_handling`."
      end

      prevent_writes = true if role == reading_role

      self.connected_to_stack << { role: role, shard: shard, prevent_writes: prevent_writes, klass: self }
    end

connected_toはブロックの中でしかコネクションが有効にならないんですけど、Railsコンソールのように自由に操作できる環境だとconnected_toのブロックを律儀にreadonlyを付けて書くとは限らないので、上のコード例のように書くことでコネクションに縛りをかけた状態で操作できるということだと思います」「readonlyモードを使いたくてこのメソッドを作ったのかもしれないと想像してみました」

データベースタスク作成でyamlを読めない場合に警告を出すようになった


つっつきボイス:「database.ymlの警告を何度も出すのではなく最初に1回だけ出すようにしたらしい」「for_each(databases)に変わったところからするとマルチプルデータベース関連の修正っぽいですね」「たしかにdatabasesが複数形になってる」

# activerecord/lib/active_record/railties/databases.rake#L28
-   ActiveRecord::Tasks::DatabaseTasks.for_each do |spec_name|
+   ActiveRecord::Tasks::DatabaseTasks.for_each(databases) do |spec_name|
      desc "Create #{spec_name} database for current environment"
      task spec_name => :load_config do
        db_config = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, spec_name: spec_name)
        ActiveRecord::Tasks::DatabaseTasks.create(db_config.config)
      end
    end
  end

「こちらのメッセージもマルチプルデータベース向きになってる↓」「database.ymlが書式レベルでは正しくても内容が正しくないような場合に、従来は最初のステップがパスして次のステップでうまくいかなかったのを、最初のステップで警告が出るようにチェックを加えたんでしょうね」

# activerecord/lib/active_record/tasks/database_tasks.rb#L144
-     def for_each
+     def setup_initial_database_yaml
        return {} unless defined?(Rails)

-       databases = Rails.application.config.load_database_yaml
+       begin
+         Rails.application.config.load_database_yaml
+       rescue
+         $stderr.puts "Rails couldn't infer whether you are using multiple databases from your database.yml and can't generate the tasks for the non-primary databases. If you'd like to use this feature, please simplify your ERB."
+
+         {}
+       end
+     end

「テストを見ると、ERBの中身が入っていないような場合にdb_create_with_warningしてる↓」「余分なエラーが減るのは嬉しいですね」

# railties/test/application/rake/dbs_test.rb
      test "db:create and db:drop show warning but doesn't raise errors when loading YAML with alias ERB" do
        app_file "config/database.yml", <<-YAML
          sqlite: &sqlite
            adapter: sqlite3
            database: db/development.sqlite3
          development:
            <<: *<%= ENV["DB"] || "sqlite" %>
        YAML

        app_file "config/environments/development.rb", <<-RUBY
          Rails.application.configure do
            config.database = "db/development.sqlite3"
          end
        RUBY

        db_create_with_warning("db/development.sqlite3")
      end


最初のdatabase.ymlを一旦読み込み、タスクを作成できない場合はwarningを出す。

マルチプルデータベースではRailsアプリケーションの起動前にdatabase.ymlを読み込んでタスク生成を試みる。これはERBをストリップする必要があるが、これはRailsコンフィグを読み出している可能性があるため。

#36540のようないくつかのケースではERBが複雑すぎて#35497で自分たちが使ったDummyCompilierでは上書きできない。複雑さが原因のときは、database.ymlからデータベースタスクを推測できないというwarningを単に出力している。

自分はこの作業をやりながら、同じwarningが何度も出力されるのを避けられるよう、database.ymlを最初に1度だけ読み込むようにコードを更新することに決めた。なお自分のテストではパフォーマンスへの影響はなく、単にエラーをどこかに保存せずに済むようにしたかった。それにこの方がクリーンに思える。

なおこの変更で既存の実行中タスクは壊れない(単にdb:create:other_dbのようなマルチDB向けタスクが生成されなくなる)。database.ymlが実際に読み取り不可能な場合は、通常のrakeタスク呼び出し中に落ちる。
修正対象: #36540
同PRより大意

TimeWithzoneDateTimeの比較を修正

問題点
#40413TimeWithZoneDateTimeと比較したときに丸めの問題が生じた(失敗するテストを書いたにもかかわらずパスした)。
修正方法
この問題はtime_instance.to_fを用いたときに発生した(特定のケースで精度が不足することがあった)。Timeインスタンスの作成をtime_instance.to_rでRationalにすることでこの問題の解決を試みた。これによってパフォーマンスがわずかに落ちるが引き換えに精度を得られる。
同PRより大意


つっつきボイス:「プルリクで参照している#40413↓の見出しにあるけど、Time.zone.atをTimeWithZoneで呼び出すときとTimeで呼び出すときで振る舞いが異なる場合があったとは」

「TimeWithZoneの場合はto_rすることで修正している↓」「Rationalに変換してるんですね」「DateTimeの場合はこれまでどおりto_fすべきなのか」「この問題よく見つけたな〜」

# #L
    def at_with_coercion(*args)
      return at_without_coercion(*args) if args.size != 1
      # Time.at can be called with a time or numerical value
      time_or_number = args.first

-     if time_or_number.is_a?(ActiveSupport::TimeWithZone) || time_or_number.is_a?(DateTime)
+     if time_or_number.is_a?(ActiveSupport::TimeWithZone)
+       at_without_coercion(time_or_number.to_r).getlocal
+     elsif time_or_number.is_a?(DateTime)
        at_without_coercion(time_or_number.to_f).getlocal
      else
        at_without_coercion(time_or_number)
      end
    end
    alias_method :at_without_coercion, :at
    alias_method :at, :at_with_coercion

Rails

🌟Railsアプリに最適なEC2インスタンスタイプとは(Ruby Weeklyより)🌟


つっつきボイス:「TechRachoの翻訳記事↓でもお世話になっている、ベンチマークに強いNoah Gibbsさんの記事です」「Railsに最適なEC2インスタンス!」「そこそこ長い記事ですね」「駆け足で読んでみますか」

Ruby 2.5.0はどれだけ高速化したか(翻訳)

「burstableなT4インスタンスは短期間のベンチマークだと速いけど長期間だと速度が落ちると書かれてる」「たしかにT系インスタンスはバーストパフォーマンスインスタンスのCPUクレジット問題があるので長期間のベンチマークのようなものに使うべきではないでしょうね: 長期実行だとCPUクレジットを使い切ってしまうという基本的な話」「なるほど」

参考: バーストパフォーマンスインスタンスの CPU クレジットとベースライン使用率 - Amazon Elastic Compute Cloud

「C5などのC系はCPUを重視するコンピューティング最適化インスタンスで、こちらはバーストクレジットがないので動かし続けられます」「RubyにRactorが正式に入ったらRailsでもC系インスタンスを使えるかもしれないと書かれてる」

参考: Amazon EC2 C5 インスタンス | AWS

「記事ではDiscourseのRailsアプリ↓が現実に近いということでベンチマークに使っている」「Railsのプロセスあたりのスレッド数のような具体的な数値が書かれてますね」「Discourseアプリの場合、EC2の2xlargeインスタンスだと1プロセスあたり6スレッドにしたときにプロセス10個でvCPUやメモリをだいたい使い切る感じらしい」「いつだったかRubyKaigiでこのあたりの話を聞いたような気がする🤔」

discourse/discourse - GitHub

「記事ではAWSのARMインスタンスやdedicated instance(ハードウェア専有インスタンス)にも触れてますね」「やったことないけどARMでCRubyって動くのかな?」「CRubyのコンパイルぐらいまではできるかもしれないですね」

参考: Amazon EC2 A1 インスタンス | AWS
参考: Amazon EC2 ハードウェア専有インスタンス | AWS

「T系やC系やM6gインスタンスは、特殊なケースならともかく、一般にはRails向けに万人にはおすすめしない、特にスピードテストでは結果が正しくなくなると書かれてる」「なるほど!」

「記事の途中で『M4かM5のどちらか』と書かれてる」「たぶんRailsのメモリ使用量だとそのぐらいが適切で、かつCPUバーストクレジットもないから、特にこだわりがなければM4かM5に絞られるということなんでしょうね」「記事の終わりの方を見ると、コスパも見ると実はオンデマンドインスタンスならM4よりM5の方が安いみたいなことが書かれてる」「なおオンデマンドインスタンスは、スポットインスタンスと違ってコストが固定されます」

参考: Amazon EC2 スポットインスタンス | AWS
参考: オンデマンドインスタンスの料金 - Amazon EC2 (仮想サーバー) | AWS

「インスタンスタイプの一覧↓を見ると『コンピューティング最適化』とか『ストレージ最適化』とかいろんなカテゴリがあるんですね」「M4やM5というとメモリが多めの汎用インスタンスという印象でしたけど、最近だと『メモリ最適化』に含まれるR系がそれ用なのかな」

参考: インスタンスタイプ - Amazon EC2 | AWS

「急いで読んだ範囲では、思った以上に経験に裏打ちされた非常に具体的な記事で、読んでてとても納得がいきます👍」「AWS事情もみっちり解説されてて、親切丁寧さがスゴい」「もっと概念的な話かと思ったら具体的ですね」「Noah Gibbsさんらしい説得力のある記事」

「AWSのEC2でRailsを運用したことがない、または経験の少ないRailsエンジニアはこの記事を読んでおくといいんじゃないかと思います: おそらくそういう人にとって知らない用語がいっぱい出てくると思うので調べながら読むことになるでしょうけど」「この記事翻訳してもらっていいですか?」「はい、この後でオファー出します」「これはいい記事❤️」


超久しぶりに🌟を進呈します。おめでとうございます。

その後Noah GibbsさんからOKをいただきましたので、近々同記事の翻訳を公開します。ご期待ください。

追記(2020/12/10): 公開しました。

Railsアプリに最適なAWS EC2インスタンスタイプとは(翻訳)

Dockerコンテナ内のSSHコンフィグをWSL2で使い回す(Ruby Weeklyより)


つっつきボイス:「Dockerコンテナ内のSSHコンフィグをWSL2で使い回すということは、$HOME/.sshディレクトリをボリュームでマウントしようねという話かなと思ったら本当にそうだった↓」「予想通りでした😆」

# 同記事より
    volumes: 
      - type: bind
        source: ${HOME}${USERPROFILE}/.ssh
        target: /home/${DEV_USER:-abapa}/.ssh

rubocop-rails_config: Rails公式のRuboCop設定と同じスタイルを使える(Ruby Weeklyより)

toshimaru/rubocop-rails_config - GitHub


つっつきボイス:「officialという言葉があったのでRails公式のRuboCopコンフィグかなと思ったら、Rails公式のRuboCopコンフィグを持ってきてそれと同じスタイルにできるというgemだそうです」「inherit_gemで指定できるのね↓」

# 同リポジトリより
inherit_gem:
  rubocop-rails_config:
    - config/rails.yml

「自分で公式のコンフィグをコピペすれば同じことができますけど、gemでインストールする意義って何だろう?🤔」「gemでやれば公式のスタイルが更新されたときに追従できそうですね」「たしかに」「おそらくRails公式のRuboCopコンフィグは今後もRuboCopのバージョンアップにも追従するでしょうから、RuboCopをバージョンアップしやすくなるかもしれない」「Railsのスタイルに常に合わせておきたい人によさそう」

n_plus_one_control: N+1クエリをマッチャーで調査するgem(Ruby Weeklyより)

palkan/n_plus_one_control - GitHub

RSpecとminitestのどちらでも使えるそうです。


つっつきボイス:「Rails技術記事↓でお馴染みのEvil Martiansがスポンサーになっている、テストのマッチャーでN+1クエリを検出するツールの記事です」

成熟したRailsアプリのフロントエンドを最新にリニューアルする方法(翻訳)

N+1クエリ問題が本当に起きているかどうかを確認するには最終的にコードを動かさないといけないんですけど、このgemはこんなふうに↓『N=2のときは5回』『N=5のときは8回』というふうにテストで実際に回してヒントを出してくれるgemみたいですね」「あ〜なるほど」


同リポジトリより

「↓以下のような感じでスケールファクターを渡して実際にテストを回してみると、N+1クエリ問題が起きていればNの増加に応じて回数が指数関数的に増大するはず: 上のスクショではNと回数の差が常に3なのでN+1は起きていませんが」

# 同リポジトリより
# You can specify the RegExp to filter queries.
# By default, it only considers SELECT queries.
expect { get :index }.to perform_constant_number_of_queries.matching(/INSERT/)

# You can also provide custom scale factors
expect { get :index }.to perform_constant_number_of_queries.with_scale_factors(10, 100)

「N+1クエリ問題の検出によく使われるbullet gem↓はコードをチェックすることで自動検出しますけど、このn_plus_one_controlは実際にいくつかのスケールでクエリを投げていろんな実行回数を出して、それを人間が見てチェックするということでしょうね」「なるほど」

flyerhzm/bullet - GitHub

「n_plus_one_controlは入れただけで自動的にN+1クエリ問題を検出してくれるものではなく、N+1クエリ問題が起きている疑いがある場合に自分で当たりをつけて発生箇所を探すためのツールだと思います👍」


Rails: Bulletで検出されないN+1クエリを解消する

Meilisearch: Rust製の全文検索エンジン

meilisearch/MeiliSearch - GitHub


つっつきボイス:「Rust製のMeilisearchという全文検索エンジンをRailsで使ってみたという記事です」「外部サービスではなくてMeiliSearchサーバーを自分で動かしてやるものみたい」「手元の辞書を見ると、Meiliは人の名前っぽい雰囲気でした」

「タイポや同義語もよしなに解釈してくれるらしい↓」「漢字もサポートしてる!」「漢字はサポートされてても日本語の文法までサポートされているかどうかはわかりませんけど」「あ、たしかに」「軽くググった限りではMeiliSearchの日本語記事が見当たらないので、日本語はこれからなのかもしれませんね」「他のエンジンのtokenizerも使い回したりできたら嬉しいかも」

  • 機能
    • インクリメント検索(50 msec以内の応答)
    • 全文検索
    • タイポ許容(タイポやスペルミスを解釈する)
    • さまざまな検索・フィルタオプション
    • 漢字のサポート
    • 同義語のサポート
    • インストール・デプロイ・メンテが容易
    • 全文を返す
    • 高度なカスタマイズ機能
    • RESTful API

「全文検索機能はあまり使ったことがないけど、Meilisearchは割とホットなプロジェクトみたい」「★が9900個もあってリポジトリも活発みたいです」「こういうものがあることを知っておくとよいでしょうね」


後でMeiliSearchの公式サイトも見つけました↓。公式ツイートはつっつき後に見つけたものです。


追いかけボイス: 「MeiliSearchの同義語サポート↓、one-wayだけじゃなくmulti-wayに同義語検索するようにもできるのがなかなか面白い: 頑張ってDB作るとかなり柔軟&有用な検索が作れそう」

参考: Synonyms | MeiliSearch Documentation v0.16


前編は以上です。

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

週刊Railsウォッチ(20201028後編)RuboCop 1.0.0 stable版がリリース、Ruby DSLのGUIフレームワークGlimmer、Keycloakほか

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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