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

週刊Railsウォッチ(20200316前編)Webpackerの対抗馬Simpackerはいかが、Hanami::APIは高速性をフィーチャー、N+1と戦うgemほか

こんにちは、hachi8833です。こちらはオンラインイベントや無料コンテンツのまとめサイトだそうです↓。


onlinefesjapan.comより


つっつきボイス:「音楽ライブ系の情報多いですね🎶」「フェスだからいろいろあるかも」「漫画や書籍が無料で読める系も📓」「毎週のようにライブ見に行ってる知り合いも、ここ最近イベント中止が相次いで週末やることなくなったって言ってますし😆」「毎週のようにってスゴい😳」「自分の回りのJリーグファンもみんな気落ちしてますし😭」

  • 各記事冒頭には⚓でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • 毎月第一木曜日に「公開つっつき会」を開催しています: お気軽にご応募ください

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

今回はコミットリストから見繕いました。

perform_enqueued_jobsがリトライしないよう修正

ActiveJob::TestCase#perform_enqueued_jobsは今後リトライしないようになる。
perform_enqueued_jobsをブロックなしで呼び出すと、アダプタは既にキューに入っているジョブを実行するようになった。キューの中にある、後に完了することが決まっているジョブは実行されなくなる。
この変更が影響するのは、perform_enqueued_jobsにブロックを渡さなかった場合に限られる。
Changelogより大意

# activejob/lib/active_job/test_helper.rb#L602
      def jobs_with(jobs, only: nil, except: nil, queue: nil, at: nil)
        validate_option(only: only, except: except)

-       jobs.count do |job|
+       jobs.dup.count do |job|
          job_class = job.fetch(:job)

          if only
            next false unless filter_as_proc(only).call(job)
          elsif except
            next false if filter_as_proc(except).call(job)
          end
          if queue
            next false unless queue.to_s == job.fetch(:queue, job_class.queue_name)
          end
          if at && job[:at]
            next false if job[:at] > at.to_f
          end
          yield job if block_given?
          true
        end
      end

つっつきボイス:「プルリクのreeenqueueってeが一個多い気がする😆」「ホントだ😆」「そういえばGitHubにはスペルチェッカーありませんね☺️」

問題
perform_enqueued_jobsをブロック無しで実行すると、リトライメカニズムで自分自身を再度キューに乗せるジョブが即座に動き出してしまう。
この動作はperform_enqueued_jobsにブロックを渡すのであれば理解できる。
しかし自分が期待するのは、perform_enqueued_jobsにブロックを渡さない場合は、後でキューに乗るジョブを実行するのではなく、既にキューに乗っているジョブを実行することである。
解決法
ジョブの配列をdupして今後改変されないようにする。
同PRより大意

「あーよくある操作かも: ジョブを実行した後にそのジョブリストを取り出すのか、実行する前にジョブリストを取り出すのかというタイミングをちゃんと使い分けできるようにしたのかな🤔」「ジョブをdupして解決したってありますね」「dupしないとジョブのタイミングによってうまくいかないことがあったのかも🤔」

これは#33626の意図にも合致する。
しかしこれは重要な変更ではあるが微妙な変更でもある。元のメソッドは「そのブロックを実行中に有効になったジョブが実行対象となる」というような意味だが、メソッド名にブロックを渡さない場合の文字どおりの意味としては「それまでにキューに乗っていた特定のジョブを実行する」ということになる。そしておそらく「既存のジョブだけではなく後続のジョブも実行してしまう」というバグが存在していた。
これらを別のメソッドに分割して振る舞いを区別できるようにする手もある。つまり「マッチするジョブを、ブロックの実行中に実行する」と「キューで待ち状態のジョブだけを実行する」を区別する。
ジョブのリトライをテストする場合、後続のジョブをジョブ階層的にキューに乗せ、スケジュールされたジョブは第2のブロック形式呼び出しでラップする必要があるだろうか?
同PRコメントより

# 同PRコメントより
# キューに乗ったジョブAを実行する。ジョブAはジョブBもキューに乗せる。
A.perform_later

# ここで実行すればアサーションで期待どおりの出力が得られるはず
perform_enqueued_jobs only: [ A, B ]

# あれ、ジョブAがキューに乗せたジョブBが動かなかったぞ
assert some_b_thing # => failed

# キューに乗ったジョブを実行し、その実行中にキューに乗ったジョブを実行する?
perform_enqueued_jobs do
  perform_enqueued_jobs
end

perform_enqueued_jobsが入れ子になってる😆」「変な書き方😆」「コメントを見た感じではブロック回りの話のようだ」

「ちなみに現場レベルだと1回実行するとうまくいかないのにもう1回実行するとなぜかうまくいくバグってあったりしますよね🤣」「あるある🤣」「実行するとなぜか1個余っちゃって、念のためもう1回実行すると消えるみたいな🤣」「理由わからないけど現場はそれで解決しようみたいな🤣」「# こうするとちゃんと動くとかコメントが付くヤツ🤣」「# たまにうまくいかないけど2回動かせば大丈夫とか🤣」「冪等になってればいいという考え方もありますけど☺️」

IPAddrをlocalhostと比較したときに毎回例外をrescueしていたのをやめた

# railties/lib/rails/application/configuration.rb#L36
       @hosts                                   = Array(([IPAddr.new("0.0.0.0/0"), IPAddr.new("::/0"), ".localhost"] if Rails.env.development?))
        @hosts                                   = Array(([".localhost", IPAddr.new("0.0.0.0/0"), IPAddr.new("::/0")] if Rails.env.development?))

つっつきボイス:「IPAddrをlocalhostと比較すると例外が発生していたんですね😳」「localhostはIPアドレスじゃないしな〜☺️」

「比較の順序を変えただけ?」「IPAddr構造体と比較するより先にlocalhostと比較するようにしたようだけど」

「これ見ると↓一番よく使うのがlocalhostなのにlocalhostの比較がトップに来ないのは遅いって😆(コメント)」「あ〜そういうことね😆」「リクエストのたんびに例外をrescueするのをやめてパフォーマンスを改善しようってことだと思いました☺️」「HostAuthorizationミドルウェアで毎回ここを通ってたのか、わかる☺️」

MRI以外のプラットフォームでは例外のraiseがかなり遅いので、例外を制御フローに使うのは可能な限り許すべきではないと個人的には思う。
同コメントより大意

localhostで使ったファビコンが残ってしまう問題

「Rackミドルウェアは必ず通るし」「まあ最近は開発環境でlocalhostを指定することあんまりありませんけど😆」「それな😆」「localhostを複数の案件で使っちゃうと、ブラウザ表示で他の案件のファビコンが表示されちゃったりしてやりづらいんですよ😇」「そうそう、スクショとか撮りづらくなる😆」「だからなんちゃら.lvh.meとかにして、他の案件のファビコンが出てこないようにしてます😆」

「それ気になってました😳: localhostを指定してブラウザ表示すると、普段よく表示しているオレオレアプリのファビコンが他のアプリにも表示されちゃうことが多いのはなぜなんだろうって」「もともとブラウザはファビコンについてはかなりアグレッシブにキャッシュしちゃうんですよ: 本来はメタタグにファビコンパスの定義を書けるんですけど、そういう定義がなくてもブラウザが『ここにファビコンがあるはず』って勝手に取りに行っちゃう変な文化があります」「えぇ〜、そうだったんですか😅」

参考: 正しいfaviconの設定方法を対応ブラウザ別にまとめる | ブログ | Glatch(グラッチ) - 夫婦で活動するフリーランスWeb制作ユニット
参考: Google Chromeでブックマークのfaviconがおかしいのを直す方法 | N★Typeブログ

「おすすめの対処法は、localhostの代わりに上で言ってるなんちゃら.lvh.meみたいに案件ごとにドメインを明示的に 分けることでしょうね🧐」「なるほど!」「IPアドレス直打ちとかすると、ごくまれにうまく行かなかったりすることもありますので、lvh.meみたいなループバックドメインのサブドメインにしておく方が余分な面倒を減らせます☺️」「自社アプリだけ開発しているなら気にしなくていいんでしょうけど😆」「うちみたいな受託ソルジャーでは必要ですね😆」

参考: 【Rails】ローカル環境の開発でサブドメインがある場合「localhost」ではなく「lvh.me」を使う - FujiYasuの日記

RubyのIPアドレス処理

「そういえばRubyのデフォルトのIPアドレス機能って、CIDR構成ネットワークのinclusionチェックはできるけど人間が読めるstring形式にさっと戻せないとか、結構融通効かないんですよね😢」「たしかにイマイチ」「サイダー?」「IPアドレスに続けて/26とか書くアレですね☺️」「任意の箇所でIPアドレスを区切るヤツ🧐」「ああ思い出しました😅」「Rubyのデフォルト機能には、サブネットマスクを取ってコネコネするみたいなのがないんですよね〜😢」

参考: Classless Inter-Domain Routing - Wikipedia — CIDR
参考: class IPAddr (Ruby 2.7.0 リファレンスマニュアル)

「かといって今どきIPアドレス比較のビット演算処理を手作りしたくありませんし😇」「そんな生々しい処理😆」「現代の俺たちがやるべきことじゃない気がする😆」

「いつだったかkamipoさんも『今の時代にIPアドレス処理するはめになるとは』みたいなことをつぶやいてましたね」「そうそう、ipaddressとかいうもっと高機能なgemを入れてました」「引数で渡されたものがサブネットの中にあるかどうかみたいなコードを今どき自分で書きたくない🤣」「同意🤣」

以下のgemがそれかどうかわかりませんが参考までに。

stringのアロケーションを削減

# actionpack/lib/abstract_controller/helpers.rb#L60
      def helper_method(*methods)
        methods.flatten!
        self._helper_methods += methods
        location = caller_locations(1, 1).first
        file, line = location.path, location.lineno

        methods.each do |method|
          _helpers.class_eval <<-ruby_eval, file, line
-           def #{method}(*args, &blk)                     # def current_user(*args, &blk)
-             controller.send(%(#{method}), *args, &blk)   #   controller.send(:current_user, *args, &blk)
-           end                                            # end
-           ruby2_keywords(%(#{method})) if respond_to?(:ruby2_keywords, true)
+           def #{method}(*args, &block)                    # def current_user(*args, &block)
+             controller.send(:'#{method}', *args, &block)  #   controller.send(:'current_user', *args, &block)
+           end                                             # end
+           ruby2_keywords(:'#{method}') if respond_to?(:ruby2_keywords, true)
          ruby_eval
        end
      end

つっつきボイス:「なるほど、stringをシンボルに変えたと」「ブロック変数も&blkから&blockに」

Rails.envでStringInquirerのサブクラスを最適化して使うようにした

# activesupport/lib/active_support/core_ext/string/inquiry.rb#L3
require "active_support/string_inquirer"
+require "active_support/environment_inquirer"
# activesupport/lib/active_support/environment_inquirer.rb
module ActiveSupport
  # StringInquirerの特殊用途
  # 環境文字列に基づいてコンストラクション時にデフォルトの3つの環境を定義する
  class EnvironmentInquirer < StringInquirer
    DEFAULT_ENVIRONMENTS = ["development", "test", "production"]
    def initialize(env)
      super(env)

      DEFAULT_ENVIRONMENTS.each do |default_env|
        singleton_class.define_method(:"#{env}?", (env == default_env).method(:itself))
      end
    end
  end
end

つっつきボイス:「すとりんぐいんくわいあら〜?」「初めて見ました」「何をするヤツなんだろう?」「Active Supportなのか」

「ああなるほど: Rails.env.production?みたいなbooleanなカラムをチェックする述語メソッドを生やすヤツか」「なるほど」「StringInquirerからenvチェックを切り離してる」

「たぶんenvのdevelopmentとtestとproductionみたいによく使うものをいちいちmethod_missingで呼んでたら非効率だから、シングルトンメソッドをあらかじめ定義しちゃおうぜということかなと😋」「ははぁ、なるほど」「stagingみたいにデフォルトにないenvはmethod_missingで遅い呼び出しでもええやろと」「パフォーマンスも10倍ぐらい速くなってるぞと⚡」「method_missingの実装は重いからな〜🏋🏻‍♀️」

参考: BasicObject#method_missing (Ruby 2.7.0 リファレンスマニュアル)

Warming up --------------------------------------
      StringInquirer   145.978k i/100ms
 EnvironmentInquirer   367.087k i/100ms
Calculating -------------------------------------
      StringInquirer      2.332M (±10.8%) i/s -     11.532M in   5.019755s
 EnvironmentInquirer     27.333M (± 3.4%) i/s -    136.556M in   5.001554s

ドキュメント修正

つっつきボイス:「お、またindex_byウォッチ20200309)」「今度はAPIドキュメントが追加されてました」「ああなるほど☺️」「修正後はindex_byとindex_withの説明が相補的になってる😋」「index_byはともかくindex_withの説明は欲しい👍」「サンプルとともに」

index_by: enumerableをハッシュに変換する。ブロックの結果をハッシュのキーとし、そのときのelementをハッシュの値とする。
index_with: enumerableをハッシュに変換する。そのときのelementをハッシュのキーとし、ブロックの結果をハッシュの値とする。
同PRより大意


つっつきボイス:「こちらはGetting Startedガイドの修正で、RailsInstallerの記述が削除されました」「RailsInstaller、懐かしい〜: 随分前に更新されなくなってますよねこれ」「更新後のドキュメントにもありますけど、今はWindowsだとRubyInstaller for Windows使って、さらにSQLiteもインストールする」「まさに先週した話ですね(ウォッチ20200310)」「つらい作業😭」

Rails

Simpacker: Webpackerのオルタナティブ

Webpackerというプロダクトは、Webpackのことを知らなくても簡単に使えるし、他にも便利な機能を提供しているところがよい。Webpackerのつらみは、WebpackをWebpacker固有のDSLやwebpacker.ymlでコンフィグしないといけない点だ。既にWebpackのコンフィグ方法を知っている人は、それをWebpackerのコンフィグに置き換える必要がある。自分はwebpack.config.jsを直接設定したいの!
Simpackerは、webpackのmanifest.json出力を参照してjavascript_pack_tag経由でscriptタグを作成する最小限の機能のみを提供する。その分Webpackの機能を知る必要があるが、Simpackerについてはほとんど何も知らなくてよい。
ただし、Webpackerにあるyarn統合やリクエスト編集のようなお便利機能はSimpackerでは利用できない。
同リポジトリより大意


つっつきボイス:「ああクックパッドさんのSimpacker」「こんなのあったんだ〜😳」「考えてみたら、WebpackerってRailsエンジニア側の発想ですし😆」「😆」「Webpackでやりたいフロントエンジニア側のことはあんまり考えてない感」「WebpackerではWebpackに触らせないんでしたっけ?」「というより、あくまでWebpackerからWebpackを制御するという感覚ですね☺️: Webpackを直接触りたいフロントエンジニアはSimpackを使うと直接やれるようになるということらしい」「ふ〜む」

参考: Webpackerはもう要らない〜 Simpacker - Qiita

「ほほぅ、SimpackerではWebpackとwebpacker-cliは入るけど、webpacker-dev-serverは自分で入れないといけないのか」「webpacker-dev-serverは入ってないと結構つらそう」「このQiita記事を見た感じでは、普通にWebpackとReactを設定してる雰囲気👍」「使う機会があったらやってみてもいいかも😋」

N+1と戦うツール2点


つっつきボイス:「この間公開した翻訳記事↓でこの2つのgemが紹介されていたので」

Rails: Active Recordメソッドのパフォーマンス改善とN+1問題の克服(翻訳)

「activerecord-precounterはk0kubunさんのgemか」「これもk0kubunさんのactiverecord-precountより設計のきれいな、counter cacheのオルタナティブ」「わかる: Active Recordにパッチを当てるgemって結構コワイし👺」

# 同リポジトリより
tweets = Tweet.all
ActiveRecord::Precounter.new(tweets).precount(:favorites)
tweets.each do |tweet|
  p tweet.favorites_count
end
# SELECT `tweets`.* FROM `tweets`
# SELECT COUNT(`favorites`.`tweet_id`), `favorites`.`tweet_id` FROM `favorites` WHERE `favorites`.`tweet_id` IN (1, 2, 3, 4, 5) GROUP BY `favorites`.`tweet_id`

「もうひとつのeager_group gemは集計関数を扱うヤツみたいです」

-- 同リポジトリより
SELECT "posts".* FROM "posts";
SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 AND "comments"."status" = 'approved'
SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 2 AND "comments"."status" = 'approved'
SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 3 AND "comments"."status" = 'approved'

-- ↓ 
SELECT "posts".* FROM "posts";
SELECT COUNT(*) AS count_all, post_id AS post_id FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3) AND "comments"."status" = 'approved' GROUP BY post_id;

「なるほど、1つのSQLに集約してくれるのか」「きれいに動いてくれてればいいけど、scopedとかでおかしなことになったりしないかな😅」

# 同リポジトリより
class Post < ActiveRecord::Base
  has_many :comments

  define_eager_group :comments_average_rating, :comments, :average, :rating
  define_eager_group :approved_comments_count, :comments, :count, :*, -> { approved }
end

class Comment < ActiveRecord::Base
  belongs_to :post

  scope :approved, -> { where(status: 'approved') }
end

「こういうふうに↑define_eager_groupと書かなければ勝手に動き出さないということらしい↓: 自分はこういうときに生SQL書くことが多いけど、同じようなコードが何度も出てくるような状況ならこういうのを使ってもいいかな😋」「なるほど」「生SQLも似たようなものをいくつも書くことになるの多いですし😆」「たしかに😆」「使うならscopedでは避けたい気がしますね: joinしてscopedなものにこのgemをかけると変なことになったりして😆」

「このgemの作者としては、こうやってオブジェクトの属性の1個になるように書けるのがいいということなんでしょうね☺️」「たしかにシンプルですね😋」「ハッシュが登場してハッシュをループで回したりするのは気持ちよくないでしょうし」「それにQuery Objectにするにしても何個も似たようなQuery Objectができるのはあんまりうれしくないし」「こう書きたい気持ちはワカル」

Rails 6に入るstrict_loading

「N+1といえば、count系とは違いますけどstrict_loadingっていうN+1を殺す場所を探すのに便利な機能がRailsでデフォルトで入るってこないだのウォッチに載ってましたね(ウォッチ20200302)」「おぉそういえば」「includesみたいなeager loadingメソッドをを叩かない状態でhas_manyリレーションにアクセスすると落ちてくれるので、N+1を絶対殺したいときに使える標準機能😇」「エグい😆: strict_loadingモードで調べられるってことなのね」「lazy loadさせないための機能」

strict_loadingって名前がイマイチな気がしますけど😆: force_eager_loadingとかすればいいのに」「たしかに😆」「#37400にいいねが多いのもわかる気がしました」「みんなN+1に苦しめられてますし😆」「strict_loading使うのはちょいめんどくさいですけど😆」「N+1が出るときはあっても実用上問題ないレベルなら使えるのがRailsのよさという考えもあるので、悩ましい😆」「まあ業務アプリではこういう機能がある方が確実にいいですね👍」

Hanami::APIは速度をフィーチャー(Ruby Weeklyより)


つっつきボイス:「この間取り上げようと思って漏れてしまいましたが、HanamiがAPIはじめました〼ということだそうです」「うん、HanamiがAPIサーバーの方向に向かうのは正しい気がしますね👍」「言われてみればAPIの方が向いてるかも?」「Railsは昔から完全にフルスタックな方向に向かっていますし、Railsと棲み分けるのであれば、Hanamiはビューやりませんぐらいのスタンスにしてみるのもありだと思います😋」「自分もHanamiがAPIに向かうのは全然ありだと思います😋」

「ところでこのベンチマークのSinatraの遅さにビビったんですけど: Railsより遅いのかと😆」「😆」「ああ、下の方にルーティング10,000とか書いてますけど、Sinatraはルーティング増えるとてきめんに遅くなるんですよ😆」「そういえばそんな話ありましたね😆」


同アナウンスより

「メモリーフットプリントはそこまで差は開いてませんね↓」


同アナウンスより

「そもそもルーティングが10,000もあるようなアプリって作りませんよね😆」「😆」「とりあえずこの比較はSinatraに不利すぎてうのみにできないけど、HanamiがAPIの高速化に専念するのは方向性として有望だと思いますし、何ならうちらで使ってみてもいいかも😋」

chaskiq: オープンソースのキャンペーンプラットフォーム(Ruby Weeklyより)


chaskiq.ioより

最近conversational marketingという言い方が流行ってるんでしょうか🤔。


つっつきボイス:「IntercomやZendeskやDriftのオルタナティブか: Driftは聞いたことある気がするけど」「それっぽいキャンペーンサイトをRailsで立ち上げられる全部盛りキットみたいな感じでした」「ああ、そのまますぐ使えるようなヤツね: ノリとしてはRedmineみたいな感じかな☺️」「このchaskiq.ioみたいなLP(ランディングページ)的なサイトが作れるんでしょうね」「だと思います」

「それがRailsである必要がどこまであるかというのは思いますけど😆」「😆」「まあWordPressみたいに、つよつよのエンジニアなしでもとりあえず立ち上げられるところまでやれるなら価値あるかも?」「どうだろう、Railsという時点で割とハードル高かったりして😆」「一応Dockerとか使ってるみたいだし、うまいことパッケージングしてすっと使えるようにしてるかも🤔」「Rails 6.0.2だから最新ですね」「金もリソースもこれからみたいなベンチャーや個人が週末までにRailsでサイト立ち上げたいみたいな用途に使えそうですね」「そういうときはスピードが一番貴重だったりしますし、ダッシュボード的なものも付いててやってます感アピールできますし、スタートアップにはそういうのが本当に大事☺️」「やらずに済むことをやらずに済ませるというのが大事😋」


前編は以上です。

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

週刊Railsウォッチ(20200310後編)Flutter+Firebaseでモバイルアプリ開発、命名7つの鉄則、Rubyバージョンを切り替えるchruby gemほか

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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