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

週刊Railsウォッチ(20210222)ActiveRecord::Relationの新メソッドload_asyncとexcluding、Active Jobのperform_laterの改善ほか

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

今回は以下のコミットリストのChangelogを中心に見繕いました。

🔗 render 'partial'でのインスタンス変数代入が非推奨化された

Railsのパーシャルレンダリングでは、以下のようなインスタンス変数の代入が許されている。

render 'partial', :@name => "Maceo"

上はActionView::Base@nameを"Maceo"に設定する。
ユーザーが定義したインスタンス変数は代入が許されているのみならず、@_assigns@output_buffer@_config@_default_form_builderといったprivateなRails変数の上書きも可能になってしまっている。

この問題は当初Hackeroneに投稿され、@tenderloveがこの挙動をpublicで非推奨化することを決定した。
同PRより大意


つっつきボイス:「え、今までrender 'partial', :@name => "Maceo"みたいな書き方が可能だったの?」「自分もさっきrender 'partial'でインスタンス変数に:が付いているのを見てびっくりしました」「こんな書き方がシンタックスエラーにならなかったとは知らなかった...」「初めて見ました」「こういうふうに書くこと自体思い付かなかった」

🔗 Active Jobのperform_laterでジョブのenqueue失敗を処理できるようになった

perform_laterに、アダプタがジョブのenqueue試行後に実行されるブロックをオプションとして渡せるようになった。このブロックは、enqueueが成功しなかった場合であってもジョブのインスタンスを受け取る。
さらに、ActiveJobアダプタがActiveJob::EnqueueErrorエラーをraiseできるようになった。これはジョブインスタンス内でキャッチされて保存されるので、raiseされたEnqueueErrorをジョブのenqueueを試行するコードがブロックを用いてinspectできるようになる。

MyJob.perform_later do |job|
  unless job.successfully_enqueued?
    if job.enqueue_error&.message == "Redis was unavailable"
      # invoke some code that will retry the job after a delay
    end
  end
end

Daniel Morton
Changelogより大意


つっつきボイス:「perform_laterにブロックを渡してジョブのenqueueに失敗した時の処理を書けるようになったのか」「perform_laterブロックを渡す方法、Active Jobで発生するようになったEnqueueErrorを使う方法、successfully_enqueued
enqueue_errorプロパティを使う方法の3つが実装されたみたい」「テストコードを見ると3とおりともテストしてますね↓」

# activejob/test/cases/queuing_test.rb#42
  test "job is yielded to block after enqueue with successfully_enqueued property set" do
    HelloJob.perform_later "John" do |job|
      assert_equal "John says hello", JobBuffer.last_value
      assert_equal [ "John" ], job.arguments
      assert_equal true, job.successfully_enqueued?
      assert_nil job.enqueue_error
    end
  end

  test "when enqueuing raises an EnqueueError job is yielded to block with error set on job" do
    EnqueueErrorJob.perform_later do |job|
      assert_equal false, job.successfully_enqueued?
      assert_equal ActiveJob::EnqueueError, job.enqueue_error.class
    end
  end
# activejob/test/jobs/enqueue_error_job.rb#3
class EnqueueErrorJob < ActiveJob::Base
  class EnqueueErrorAdapter
    class << self
      def enqueue(*)
        raise ActiveJob::EnqueueError, "There was an error enqueuing the job"
      end

      def enqueue_at(*)
        raise ActiveJob::EnqueueError, "There was an error enqueuing the job"
      end
    end
  end

  self.queue_adapter = EnqueueErrorAdapter

  def perform
    raise "This should never be called"
  end
end

「Changelogのコード例にある"Redis was unavailable"みたいに、enqueueに失敗する可能性は常にあるので、EnqueueErrorは必要ですね👍」「このやり方覚えとこうっと」

「この機能が追加されたということは、これまでperform_laterでジョブのenqueueが失敗したときの公式な処理手段がなかったということなんでしょう」「今まではどうやって処理してたんでしょうね?」「変更前のperform_laterではenqueueを返してはいたけど↓、successfully_enqueuedenqueue_errorなどに相当するものがなかったので、enqueueが成功したかどうかを知る方法がなかったということなのかな?」「この感じだと公式な処理方法はこれまでなかったみたいですね」「従来はsuccessfully_enqueuedenqueue_errorなどに相当するものを自分で書いて処理するしかなかったんだろうと想像できます」

# activejob/lib/active_job/enqueuing.rb#L
      def perform_later(*args)
-       job_or_instantiate(*args).enqueue
+       job = job_or_instantiate(*args)
+       enqueue_result = job.enqueue
+
+       yield job if block_given?
+
+       enqueue_result
      end

🔗 ActiveRecord::Relationの新メソッド2つ: load_asyncexcluding

このメソッドは、スレッドプールから非同期実行されるようにクエリをスケジューリングする。
バックグラウンドスレッドがクエリを実行可能になる前にこの結果にアクセスすると、フォアグラウンドで実行されるようになる。
これは、結果が必要になるより前の実行時間が長いクエリや、独立したクエリを複数実行する必要のあるコントローラで有用。

def index
  @categories = Category.some_complex_scope.load_async
  @posts = Post.some_complex_scope.load_async
end

Jean Boussier
#41372 Changelogより大意


つっつきボイス:「1つ目はActiveRecord::Relation#load_asyncメソッドですか」「コードを見るとRubyのThreadで実装されていますね↓:将来もしかするとThreadの代わりにRubyのRactorを使うようになるのかもしれないとちょっと想像してみました」

# activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb#L460
      def schedule_query(future_result) # :nodoc:
        @async_executor.post { future_result.execute_or_skip }
+       Thread.pass
      end

参考: class Thread (Ruby 3.0.0 リファレンスマニュアル)
参考: ractor - Documentation for Ruby 3.0.0

「コントローラでlazy loadingする方法だとビューなどで実際に参照されるまで読み込みが開始されないんですが、このload_asyncならビューなどで参照される前に読み込みが開始されるので、期待どおりに動けばブラウザにレスポンスを早く返せるようにできるでしょうね」「お〜」

「ビューをレンダリングしている間にクエリを投げられるという感じでしょうか?」「ビューに限らず使えるでしょうね: たとえば以下のコードのCategory.some_complex_scopeが仮にやや遠くにあるサーバーへのクエリだとすると、ここでeager loadingもせずに普通にfindを使ってクエリを発行すると、結果が戻ってくるまでここでブロックされてしまうという問題がまず考えられます」「ふむふむ」

# Changelogより
def index
  @categories = Category.some_complex_scope.load_async
  @posts = Post.some_complex_scope.load_async
end

「同じ箇所でeager loadingする場合は、必要になった瞬間から読み込みが開始されますが、その場合もコードがブロックされるのは同じです」「たしかに」

「このload_asyncを使えば、そのクエリを実行するためだけのスレッドが新たに生成されるので、純粋な待ち合わせの時間は短縮されるでしょうね」「なるほど、コードがブロックされなくなるということですか」「重たくなりそうなクエリをバックグラウンドのスレッドで扱うというのはなかなかうまい方法だと思います👍」「言われてみると速くなりそうな気がしてきました」「load_asyncだとスレッド生成のコストも少しはかかると思いますが大きくはなさそう」「マルチプルデータベースにもいいかも」

「その代わり、別スレッドでエラーが発生した場合にどうやって拾うかは考えないといけないでしょうね」「あ、その場合どうすればいいんでしょうか?」「エラーそのものは別スレッド側で拾うしかないと思います」

load_asyncしたクエリはトランザクションが効かなくなりそうなので、使い方を間違えるとデッドロックするかも」「それもそうか」「たぶんデッドロックしそうな使い方はしないように、ということなんでしょうね」

参考: デッドロック - Wikipedia

「プルリクを見た感じでは、エラーの場合どうするかについては特に書かれてないのかな?」「お、トランザクションの中でload_asyncした場合のテストが書かれてますよ↓」「あ、ホントだ」

# activerecord/test/cases/relation/load_async_test.rb#55
    def test_load_async_from_transaction
      posts = nil
      Post.transaction do
        Post.where(author_id: 1).update_all(title: "In Transaction")
        posts = Post.where(author_id: 1).load_async
        assert_predicate posts, :scheduled?
        assert_predicate posts, :loaded?
        raise ActiveRecord::Rollback
      end

      assert_not_nil posts
      assert_equal ["In Transaction"], posts.map(&:title).uniq
    end

「上のテストコードを見ると、トランザクションの中で実行したPost.where(author_id: 1).load_asyncは、トランザクションが終わるまで待ってから動き出すようですね」「なるほど、このload_asyncではupdate_allが完了した後の結果が取れるのか」「完了まで待つのはRDBMS自体の機能です: 別スレッドがロックを掴んでいたら、タイムアウトするまでは待つ機能がRDBMSにあります」「なるほど」「いずれにしろトランザクションブロックの中ではload_asyncするかどうかで結果が変わる可能性があるので注意が必要でしょうね」

「たしかこのload_async的な機能は、これまで長らく待ち望まれていたけど実現していなかった機能だったと思います: 複数クエリを呼び出す処理をマルチスレッドで並列実行できるという意味で、その夢を実現する偉大なる一歩なんだろうなと思いました」「たしかに」


# 41439より
Post.excluding(post)
# SELECT "posts".* FROM "posts" WHERE "posts"."id" != 1

Post.excluding(post_one, post_two)
# SELECT "posts".* FROM "posts" WHERE "posts"."id" NOT IN (1, 2)

# And on associations (also supports collections as above).
post.comments.excluding(comment)
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1 AND "comments"."id" != 2

「2つめのexcludingはプルリクに書かれているとおりPost.where.not(id: post.id)のショートハンドですね↓」「NOT INに相当するのか」「where.notを書かなくていいのがよさそう」「where.notをたくさん書くときにはいいでしょうね」

This is short-hand for Post.where.not(id: post.id) (for a single record) and Post.where.not(id: [post_one.id, post_two.id]) (for a collection).

「その後で@kamipoさんがエイリアスも追加していました↓」「なるほど、excludingwithoutでも書けるんですね」

🔗 データベースアダプタの平均値集計を修正

:averageで呼ばれるActiveRecord::Calculations.calculateActiveRecord::Calculations.averageのエイリアス)が、カラムベースのtype castingを使うようになった。これによって、浮動小数点値(float)のカラムがFloatとして集計され、固定小数点値(decimal)のカラムがBigDecimalとして集計されるようになった。
整数は特殊ケースとして、常にBigDecimalとして扱われる(これは既にそうなっている)。
この変更前は、Railsのデータベースアダプタで平均値集計のときにto_dを呼んでいた。これは今後行われなくなる。この種のマジックに依存していた場合は、独自のActiveRecord::Typeを登録する必要がある(ActiveRecord::Attributes::ClassMethodsのドキュメントを参照)。
Changelogより抜粋


つっつきボイス:「decimal型のカラムをaverageメソッドで集計した際、スキーマ側で定義された型を無視してfloatに変換されて返ってくる問題を修正したということみたい」「テストを見ると、integerは常にBigDecimalで返すべきとなっている↓」「floatはFloatで、decimalはBigDecimalで返すべきということか」

# activerecord/test/cases/calculations_test.rb#50
  def test_should_return_decimal_average_of_integer_field
    value = Account.average(:id)
+
    assert_equal 3.5, value
+   assert_instance_of BigDecimal, value
  end
# activerecord/test/cases/calculations_test.rb#L64
  def test_should_return_float_average_if_db_returns_such
    NumericData.create!(temperature: 37.5)
-
    value = NumericData.average(:temperature)
-   assert_instance_of Float, value
+
    assert_equal 37.5, value
+   assert_instance_of Float, value
+ end
+
+ def test_should_return_decimal_average_if_db_returns_such
+   NumericData.create!(bank_balance: 37.50)
+   value = NumericData.average(:bank_balance)
+
+   assert_equal 37.50, value
+   assert_instance_of BigDecimal, value
  end

「あれ、37.5と37.50って型が違うんですか?」「お?テスト用の定義はスキーマのどこかにあるかな(しばらく探す): あった↓」「temperatureがfloatでbank_balanceがdecimalなのか、なるほど理解しました」「このスキーマ定義の型に応じてtype castingできているかどうかのテストということですね」

# rails/activerecord/test/schema/schema.rb#L651
  create_table :numeric_data, force: true do |t|
    t.decimal :bank_balance, precision: 10, scale: 2
    t.decimal :big_bank_balance, precision: 15, scale: 2
    t.decimal :unscaled_bank_balance, precision: 10
    t.decimal :world_population, precision: 20, scale: 0
    t.decimal :my_house_population, precision: 2, scale: 0
    t.decimal :decimal_number
    t.decimal :decimal_number_with_default, precision: 3, scale: 2, default: 2.78
    t.numeric :numeric_number
    t.float   :temperature
    t.decimal :decimal_number_big_precision, precision: 20
    # Oracle/SQLServer supports precision up to 38
    if current_adapter?(:OracleAdapter, :SQLServerAdapter)
      t.decimal :atoms_in_universe, precision: 38, scale: 0
    else
      t.decimal :atoms_in_universe, precision: 55, scale: 0
    end
  end

「以下がその前にマージされたプルリクですね↓」

🔗Rails

🔗 Railsにマイクロサービスを追加する(Ruby Weeklyより)


つっつきボイス:「Railsにマイクロサービスですか?」「Railsをマイクロサービス化するんじゃなくて?」

# 同記事より
class QuizController < ApplicationController
  # Other controller methods omitted here
  # This is our new service method
  def participants
    response = {}
    response["participants"] = Attempt.all.map { |attempt| attempt.taker }
    render :json => JSON[response]
  end
end

「え、QuizController.action(:participants)でコントローラのアクションを直接呼び出してるのか↓、しかもpumaの引数で.ruファイルを指定してる」「すごい書き方ですね😳」

# 同記事より
# This file is used by Rack-based servers to start the application.
require_relative 'config/environment'
run QuizController.action(:participants)
# 同記事より
bundle exec puma -b tcp://0.0.0.0:8080 config_svc_participant.ru

「これってマイクロサービスなんでしょうか?」「自分たちが考えているマイクロサービスとは設計思想が違いますけど、単機能のサービスという意味での『マイクロなサービス』と考えることも一応できるでしょうね」「なるほど、そういう意味ですか」「unbundled seriesと銘打たれている記事だから、いろいろ自由に試しているのかなと想像してみました」


「ところでこの記事、よくみるとEngine Yardのブログなんですね」「Engine YardのWebサイトってもっと赤っぽい色彩だった覚えがあるんですけど、随分青みがかってる」「Engine YardはHerokuと同じぐらい歴史がありますけど、今はRails以外もいろいろサポートするようになっているので、シンボルカラーをRailsを連想する赤から変えたのかもしれませんね」「あ、そうかも」「そういえば、昔の赤色の時代のEngine Yardのステッカーがそこに転がっているパソコンに貼ってありますよ」

🔗 Railsのcycleメソッド(Ruby Weeklyより)


つっつきボイス:「i % 2 == 0を避ける、とは?」

<!-- 同記事より -->
<% @foods.each_with_index do |food, i| %>
  <tr class="<%= i % 2 == 0 ? 'bg-gray-200' : 'bg-gray-100' %>">
    <td><%= food %></td>
  </tr>
<% end %>

「なるほど、以下のようにeach_with_indexの中でcycleビューヘルパーを使うと、上みたいに1行ずつ交互に背景色を変えたりするときにi % 2 == 0 ?で偶数奇数を判定したりせずに書けるのね↓」「へ〜!」「Railsにこんなヘルパーもあるとは」

<!-- 同記事より -->
<% @foods.each_with_index do |food, i| %>
  <tr class="<%= cycle('bg-gray-200', 'bg-gray-100') %>">
    <td><%= food %></td>
  </tr>
<% end %>

odd?even?で書くよりもcycleの方が意味的にも自然かも」「背景色に限らず、ループの中で何かを順繰りに表示したいときに幅広く使えそうですね👍」

🔗Ruby

🔗 reductionとは何か、FiberがRubyコンカレンシーの解である理由(Ruby Weeklyより)


つっつきボイス:「お、まだこの記事の図しか見てませんが、こんなふうに↓スレッドを丁寧に図で説明しているのがすごくよさそう」「おぉ!」


同記事より

「Fiberだけじゃなくて最後の方でRactorまで説明してくれている」「こうやって時系列に沿った図になっているのがうれしいですね↓」「マルチスレッド系の解説って、こういう図がないと読んでてつらいんですよ」


同記事より

「歯ごたえありそうだけど、この記事は読む価値がありそうな予感がします👍」「週末に頑張って読んでみようかな」

🔗 M1 MacでRailsアプリを開発するときのコツ(Ruby Weeklyより)


つっつきボイス:「Rails discussionがRuby Weeklyで紹介されていました」「Appleシリコンだ」「Dockerもアップデートされて、ようやくいくつかの機能がM1チップでまともに動くようになったという記事も今日見かけましたね」

参考: Apple M1チップ対応のDocker Desktop、同梱のKubernetesも実行可能に - Publickey

「次に買うMacBookはM1チップにしようかな」「たぶん今後はM1チップのしか買えなくなると思いますよ」「え、そうなんですか?」「AppleがIntelへのCPUの新規発注を絞り込んでいるらしいという話もあるので、おそらく現行のIntel Macはいったんディスコンになる流れなんじゃないかなと予想しています」「あ〜なるほど」「最近Intelがこういう広告↓を打っているのが話題になってますけど、そのあたりも理由じゃないかと言われていますね」

参考: インテル、「MacにできないことがPCにできる」キャンペーンを展開 - Engadget 日本版


「ところで、最近一部で話題になったcpu-monkey.comのベンチマーク↓は、スペック情報は参考になる部分もあるんですけど、シングルコアでM1とM1Xの差がほとんどないとか、自分も含めてこのベンチマークの信憑性にはちょっと疑問があるんですよ」「あ、そうなんですか」

参考: Apple M1X vs. Apple M1 - Benchmark and Specs

「ここに載ってたスペックを見ると、M1Xにはメモリが32GB搭載できるらしいので次に買ってみてもいいかなと思うんですが、M1XのTDP(Thermal Design Power)が35Wもあるのでバッテリーの持ちという面では優位性がちょっと下がるかもと思っているところです」「それでも32GB積めたらいいですよね」「今の自分の使い方だと16GBではもうやっていけないレベルです」「今のMacBookは最大16GBですもんね」(以下延々)

参考: TDP - CPU の選び方


今回は以上です。

バックナンバー(2021年度第1四半期)

週刊Railsウォッチ(20210209後編)Rubyでミニ言語処理系を作る、Kernel#getsの意外な機能、CSSのcontent-visibilityほか

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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