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

週刊Railsウォッチ(20200210前編)Railsのベンチマークジェネレータ、長いバックグラウンドジョブと戦う、Timestamp切り詰めの謎、Open APIツールほか

こんにちは、hachi8833です。そういえば明日は祝日ですね。コロナウイルス流行の様子が早速ビジュアライズされたようです。

「まあこれで何かわかったとしても自分らに打てる手は限られてますし😷」「情報の動き激しくて...😅」(以下延々)

参考: CNN.co.jp : 新型ウイルス、潜伏期間中の感染例は「誤り」 独当局

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

今回は第19回公開つっつき会を元にお送りします。お集まりいただいた皆さまありがとうございます!

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

今週はコミットログより見繕いました。今週は細かめの修正が多い印象です。

default_scopedをpublic APIに

# activerecord/lib/active_record/scoping/named.rb#L25
        def all
          scope = current_scope
          if scope
            if scope._deprecated_scope_source
              ActiveSupport::Deprecation.warn(<<~MSG.squish)
                Class level methods will no longer inherit scoping from `#{scope._deprecated_scope_source}`
                in Rails 6.1. To continue using the scoped relation, pass it into the block directly.
-               To instead access the full set of models, as Rails 6.1 will, use `#{name}.unscoped`,
-               or `#{name}.default_scoped` if a model has default scopes.
+               To instead access the full set of models, as Rails 6.1 will, use `#{name}.default_scoped`.
              MSG
            end

            if self == scope.klass
              scope.clone
            else
              relation.merge!(scope)
            end
          else
            default_scoped
          end
        end
...
-       def default_scoped(scope = relation) # :nodoc:
+       # Returns a scope for the model with default scopes.
+       def default_scoped(scope = relation)
          build_default_scope(scope) || scope
        end

つっつきボイス:「上は実はついさっきkamipoさんがリツイートしたコミットです🐦」「お、unscopedしてしまった後でもデフォルトスコープを取れるのがdefault_scopedっていうことみたい: 自分はあんまり使わなそうだけどpublicメソッドが欲しい気持ちはワカル☺️」

API: unscope -- ActiveRecord::QueryMethods

default_scopedは、スコーピング上デフォルトスコープになっているスコープを強制的に返す唯一の方法であり、マイグレーションでのスコープのリークを回避するのに必要。
同PRより大意

default_scopeを使わないのが一番なんでしょうか?🤔」「そういうわけにもいかないことがありますし、小さいシステムならdefault_scopeがあっても嫌がられないと思いますが、育ってくるとちょっとね...😅」

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

delegateのeachを取り除いた

# actionpack/lib/action_dispatch/testing/integration.rb#L80
    class Session
      DEFAULT_HOST = "www.example.com"
      include Minitest::Assertions
      include TestProcess, RequestHelpers, Assertions

-     %w( status status_message headers body redirect? ).each do |method|
-       delegate method, to: :response, allow_nil: true
-     end
-
-     %w( path ).each do |method|
-       delegate method, to: :request, allow_nil: true
-     end
+     delegate :status, :status_message, :headers, :body, :redirect?, to: :response, allow_nil: true
+     delegate :path, to: :request, allow_nil: true

つっつきボイス:「見出しに"delegate allows multiple method names are passed"とあるのは...?」「delegateの内容は変わってないので、eachのループをなくしたということなんでしょうね☺️: タイトルは単にこの部分の挙動をメモった感じかな」「走り書きでしたか😅」「Railsの起動がちょっと速くなりそう☺️」「以前%w()で書かれてた理由はわかりませんけど😆」

キーワード引数対応: define_methodをstringのevalに変更

# actionpack/lib/action_dispatch/testing/integration.rb#L356
      %w(get post patch put head delete cookies assigns follow_redirect!).each do |method|
-       define_method(method) do |*args, **options|
-         # reset the html_document variable, except for cookies/assigns calls
-         unless method == "cookies" || method == "assigns"
-           @html_document = nil
-         end
+       # reset the html_document variable, except for cookies/assigns calls
+       unless method == "cookies" || method == "assigns"
+         reset_html_document = "@html_document = nil"
+       end

-         result = if options.any?
-           integration_session.__send__(method, *args, **options)
-         else
-           integration_session.__send__(method, *args)
+       definition = RUBY_VERSION >= "2.7" ? "..." : "*args"
+
+       module_eval <<~RUBY, __FILE__, __LINE__ + 1
+         def #{method}(#{definition})
+           #{reset_html_document}
+           result = integration_session.#{method}(#{definition})
+           copy_session_variables!
+           result
          end
-         copy_session_variables!
-         result
-       end
+       RUBY
      end

「これもさっきと同じintegration.rbファイルの変更ですね」「今までdefine_methodで定義していたのをmodule_evalに変えたのね😋」

「『キーワード引数をif options.any?で扱うのはイケてない』みたいなことが書いてあるのでキーワード引数関連かな🤔: definition = RUBY_VERSION >= "2.7" ? "..." : "*args"とかありますし、stringのevalにすればdefine_method経由ではなくなってstringで書けるようになるので、definitionで2.7以降の場合とそうでない場合に...*argsを使い分けられるようになったということか😋」「なるほど!」「速度が目的ではなさそうなので、たぶんstringじゃないと2.7対応がやりにくいんでしょうね☺️」

「コミットで引用されてるb7e591aa43de73を見るとamatsudaさんが**options引数周りで試行錯誤してますね」「あぁ、Ruby 2.7のキーワード引数変更問題に引っかからないようにするためのやり方を試行錯誤してる感じ: if options.any?の結果で分岐するか、上みたいにstringでRubyコードそのものにパッチを当てる形で修正するか」「Railsのフレームワークでは複数バージョンのRubyに対応しないといけないので大変😅」

スキーマキャッシュ読み込み時のDB configフォールバック

# activerecord/lib/active_record/railtie.rb#L127
    initializer "active_record.check_schema_cache_dump" do
      if config.active_record.delete(:use_schema_cache_dump)
        config.after_initialize do |app|
          ActiveSupport.on_load(:active_record) do
            db_config = ActiveRecord::Base.configurations.configs_for(
              env_name: Rails.env,
              spec_name: "primary",
            )
+           next if db_config.nil?
+
            filename = ActiveRecord::Tasks::DatabaseTasks.cache_dump_filename(
              db_config.spec_name,
              schema_cache_path: db_config.schema_cache_path,
            )
            if File.file?(filename)
              current_version = ActiveRecord::Migrator.current_version
              next if current_version.nil?
              cache = YAML.load(File.read(filename))
              if cache.version == current_version
                connection_pool.schema_cache = cache.dup
              else
                warn "Ignoring db/schema_cache.yml because it has expired. The current schema version is #{current_version}, but the one in the cache is #{cache.version}."
              end
            end
          end
        end
      end

つっつきボイス:「修正は1行追加だけ」「前回のウォッチでもマルチDBがreplicaじゃなくてprimaryから取ってきちゃう問題の修正があったのを少し思い出しますね🤔(ウォッチ20200203)」「マルチDBも大変そう...」

概要
スキーマキャッシュはデフォルトでprimaryデータベースconfigを読み出す。しかしspec nameがprimaryのデータベースconfigがアプリにない場合、ファイル名探索に失敗する。
ここでは念のためフォールバックを追加した。
その他
スキーマキャッシュのファイル名はハードコードされていたが、Railsではデフォルトパスをオーバーライドできるようになっている。#38348ではオーバーライドを考慮するようにしたが、spec nameがprimaryのデータベースconfigがないアプリの対応が漏れていた。
GitHubにはprimaryという名前のデータベースがないこともわかったので、このActive Recordイニシャライザに依存していないとしても失敗する。
同PRより大意

GitHub ActionsのRailsビルドを数秒短縮

# .github/workflows/rubocop.yml#L21
-       gem install bundler:2.1.2
+       gem install bundler:2.1.2 --no-document

つっつきボイス:「ドキュメント生成やめたのね😆」「言われてみればなるほど😆」「ドキュメント生成は結構重いし🏋🏻‍♀️」「速くなるのわかります😋」

細かな修正系


つっつきボイス:「微修正をまとめてみました」「Railsガイドのパスの間違いとか☺️」

# activerecord/test/cases/migration_test.rb#L211
      if current_adapter?(:Mysql2Adapter)
        if ActiveRecord::Base.connection.mariadb?
          assert_match(/Can't DROP COLUMN `last_name`; check that it exists/, error.message)
        else
          assert_match(/check that column\/key exists/, error.message)
        end
-     elsif
+     elsif current_adapter?(:PostgreSQLAdapter)
        assert_match(/column \"last_name\" of relation \"people\" does not exist/, error.message)
      end

elsifの条件が抜けてたとは↑😆」「その下のassert_matchが条件扱いされるからエラーなしで通っちゃってた😆」「RuboCopに引っかからなかったのか👮🏼‍♀️」

# railties/lib/rails/generators/rails/benchmark/templates/benchmark.rb.tt#L3
-require_relative "../config/environment"
+require_relative "../../config/environment"

「こんなエラー↑が今頃見つかるとは😆」「つかrails benchmarkってコマンドありましたっけ?」「知らない😆」「そんな機能が入ってたことの方がびっくり😳」

Railsのベンチマークジェネレーター

概要: パフォーマンス最適化を比較するベンチマークを生成する。
デフォルト: method = ips
:rails generate benchmark opt_compare path_a path_b
上で以下が生成される:
benchmarks/opt_compare.rb
--methodで他のベンチマーク手法を指定できる。有効な手法はips、bm、bmbm
Changelogより

「軽くググると昨年12月13日にベンチマークジェネレーターがmasterに入ってるし↑😆」「Rails 6.1あたりで使えるようになるのかな?」「そんなに複雑なことはやってないと思うけど、ベンチマークって自分でやろうとすると毎回ググるので、ベンチマークのテンプレートをRailsで生成できるならいいかな〜😋」 ​

番外: オプション引数をつぶして回る

# actionpack/lib/abstract_controller/translation.rb#L26
-   def localize(*args)
-     I18n.localize(*args)
+   def localize(object, **options)
+     I18n.localize(object, **options)
    end
# actionpack/lib/abstract_controller/translation.rb#L13
-   def translate(key, options = {})
+   def translate(key, **options)
      if key.to_s.first == "."
-       options = options.dup
        path = controller_path.tr("/", ".")
        defaults = [:"#{path}#{key}"]
        defaults << options[:default] if options[:default]
        options[:default] = defaults.flatten
        key = "#{path}.#{action_name}#{key}"
      end
      I18n.translate(key, **options)
    end
# activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb#L709
-     def foreign_key_exists?(*args)
-       @base.foreign_key_exists?(name, *args)
+     def foreign_key_exists?(*args, **options)
+       @base.foreign_key_exists?(name, *args, **options)
      end

つっつきボイス:「ああ*argsoptions = {}を置き換えるヤツ☺️」「options = {}みたいなレガシーな書き方がまだまだ残ってた😆」「だいぶ前にbabaさんがキライだって言ってた書き方ですね↓」

Railsフレームワークで多用される「options = {} 」引数は軽々しく真似しない方がいいという話

options = {}は、キーワード引数が登場する前のRuby 1.9とも互換性のある書き方だったと思います」「そういえばキーワード引数が登場したのはRuby 2.0でしたね」

「ところでお集まりの皆さんの中でRuby 1.8あたりを経験された方は?: さすがにいませんね😆」「Rails 4からなので😆」「自分の時代は今は亡きRuby Enterprise Edition↓使ってたので1.8経験しました: サイトは生き残ってるけどやはり1.8.7で止まってるか〜😆」「そういえばここってPassengerの会社がやってたんですね😳」「そうそうPhusion

参考: Welcome — Ruby Enterprise Edition


rubyenterpriseedition.comより

「ちなみにRuby Enterprise Editionは一応今でもrbenvにエントリぐらいはあります↓」「おぉっ」「でもさすがにOpenSSLのバージョンが今と違ってるでしょうからビルドは無理じゃないかな〜🤣: 試しに裏でビルドしてみよう」(しばらく経って)「やはりダメか😆」

ree-1.8.7-2011.03
ree-1.8.7-2011.12
ree-1.8.7-2012.01
ree-1.8.7-2012.02

参考: OpenSSLが古すぎてbundle updateできない

「今の時代にやんごとない事情でRuby 1.8を動かさないといけなくなったらDockerコンテナでやるとかになるでしょうね🐳」「Docker Hub見ると1.8コンテナ作ってる人がかろうじているけど、どこまで信用できるかわかりませんし😆」


つっつきボイス:「上はさっき流れてきたツイートです」「さらっと『全部のコミット読んでる』」「最強すぎ💪」「最後は眼ぢから👁」

Rails

今回は大半がRuby Weeklyのエントリになりました。軽く敗北感。

RailsのTimestampが切り詰められた(Ruby Weeklyより)

# 同記事より
{"created_at"=>2020-01-02 13:36:22.459149334 +0000, "updated_at"=>2020-01-02 13:36:22.459149334 +0000}
{"created_at"=>2020-01-02 13:36:22.459149000 +0000, "updated_at"=>2020-01-02 13:36:22.459149000 +0000}

つっつきボイス:「あ〜マイクロ秒以下が落ちちゃうヤツ↑あるある🤣」「🤣」「reloadすると変わる↓とある」

# 同記事より
before perform: 1577985089.0547702
after perform:  1577985089.0547702
after reload:   1577985089.05477

「Active Recordはsaveメソッドを呼ぶとINSERTなりUPDATEなりが行われるんですけど、DBに実際に保存した値を読み込み直すことまではしないので、saveした直後に値を参照すると、DBに保存された値そのものではなくRuby側で設定したときの値を返します」「後でreloadしてみると値が変わってたとは😇」「😳」「めったに踏みませんけど、たまに出くわしますね☺️」


目次より:

  • 問題: macOS上のdev環境ではテストが通るのにCIでほぼ失敗する(何とかログは取れた)
  • 調査
    • 桁落ちを発見
    • プリントデバッグでは再現せず、calendar.attributes['created_at']でattributeハッシュを直接フェッチすると再現した
  • 修正
    • DateTimeオブジェクトを生成して秒以下を丸め、このオブジェクトでタイムスタンプを明示的に設定した
  • 原因
    • Active RecordのタイムスタンプがTime.nowで設定されているがTimeの精度はOSに依存する
  • 疑問(説明求む)
    • calendar.attributes['created_at']だとリロードされたのにcalendar.created_atだとリロードされないのはなぜ?
    • Linuxと比較してみるとmacOSでの精度は6桁どまりという驚きの結果(7桁目以降は1と2と8と9しか現れない↓)
# 同記事より
10000.times.map { Time.now}.map{|t| t.to_f.to_s.match(/\.(\d+)/)[1] }.select{|s| s.size == 7}.group_by{|e| e[-1]}.map{|k, v| [k, v.size]}.to_h

# MacOS => {"9"=>536, "1"=>555, "2"=>778, "8"=>807}
# Linux => {"5"=>981, "1"=>311, "3"=>1039, "9"=>309, "8"=>989, "6"=>1031, "2"=>979, "7"=>966, "4"=>978}

sequenced: ARモデル向けのシーケンシャルID生成(Ruby Weeklyより)

# 同リポジトリより
class Question < ActiveRecord::Base
  has_many :answers
end

class Answer < ActiveRecord::Base
  belongs_to :question
  acts_as_sequenced scope: :question_id
end

つっつきボイス:「このgemの嬉しみってどのあたりでしょう?」「冒頭に書いてあるそのまんまですが、プライマリキーではない、スコープドのシーケンシャルIDを生成できるということですね☺️」

「使いみちとしては、たとえばですがUserモデルにhas_many :picturesがあったとすると、どのユーザーについてもユーザー独自の1個目の画像をURLで表現できるようにしたい、なんて場合があればでしょうね」

「Railsで普通にネステッドスコープを作ると、ネステッドの先もサロゲートキーになりますよね: 言い換えればそこは名前空間が同じになるので、ユーザーAにとっての画像1もユーザーBにとっての画像1も同じものを指してしまう」「ふむふむ」「このsequenced gemを使えば、/ユーザーA/1で取れる画像が必ずそのユーザーAにとっての1個目の画像になり、/ユーザーB/1だとそれとは異なるユーザーBにとっての1個目の画像になる、といった具合です」

「このgemを使うかどうかは別として、こういうのをやりたい場合があるのはわかりますね☺️」「自分で書くのもありな機能でしょうか?」「スコープドのシーケンシャルID生成ではシーケンス番号がかぶらないように保証するのが面倒くさくなりがちなので、このgemがトランザクション管理までやってくれるのであればワンチャンあるかも🤔」

「READMEの下の方にdata integrityとかあるしやってくれるかな?...とよく見ると『これはPostgreSQLでしかコンカレント安全でない』って書いてあるし😆」「😆」「というわけでぽすぐれ以外ではシーケンシャルがIDかぶる可能性あります: 以下のPARTITION BYあたりとかPostgreSQL向けっぽいですし↓」

# 同リポジトリより
# app/db/migrations/20151120190645_add_sequental_id_to_badgers.rb
class AddSequentalIdToBadgers < ActiveRecord::Migration
  add_column :badgers, :sequential_id, :integer

  execute <<~SQL
    UPDATE badgers
    SET sequential_id = old_badgers.next_sequential_id
    FROM (
      SELECT id, ROW_NUMBER()
      OVER(
        PARTITION BY burrow_id
        ORDER BY id
      ) AS next_sequential_id
      FROM badgers
    ) old_badgers
    WHERE badgers.id = old_badgers.id
  SQL

  change_column :badgers, :sequential_id, :integer, null: false
  add_index :badgers, [:sequential_id, :burrow_id], unique: true
end

「こういうことをしたいときが数年に1回ぐらいはありそう😆」「😆」「そのときにこのgemを思い出せるかどうかですけどっ😆」

Open API仕様記述ツール


同サイトより


つっつきボイス:「今日のWebチーム内発表がOpen APIの話題だったので先週(ウォッチ20200203)に引き続いて」「そうそう、Open API(Swagger)で仕様を書くことがあるんですけど、上のサイトはOpen APIのツール集ですね: Open APIは夢のツールとまではいきませんが😆」

「チーム内発表ではstoplightというツール↓が登場してましたね」「stoplightはちゃんとWYSIWYGエディタになってて、とにかくyamlを手書きしなくていいのが嬉しい❤️」


stoplight.ioより

「Swaggerエディタ↓って結局yamlを書かないといけないのがつらくありません?😆」「ある程度しょうがないかと思うけど生成されるyamlのファイル長すぎじゃね?って思ったりもするけど、Open APIがなかったらExcelで仕様書かないといけなくなりますし😆」


editor.swagger.ioより

参考: Open API仕様記述ツールを比較してみた - Qiita

「Qiitaにある中でAPI Blueprintは書いたことありますね: ちょっとMarkdown風ですが自由に書けすぎる感😆」


apiblueprint.orgより

Chrome 80のSameSite属性


つっつきボイス:「SameSiteはもう入ってきたんでしたっけ?」「Chrome 80は昨日見ている目の前でインストールされました」「SameSiteの動作変更は来週からみたい」「デフォルトのSameSiteは本来こうあるべきかなと思いますけど😆」「SameSite=None; Secureを付けると不具合を発生するブラウザって...」

「これで影響を受ける決済ってどのぐらいあるんでしょう?」「まあカード会社のサイトに行って戻るようなECサイトではそもそも普通POSTではやりませんし」「おぉ」

「今どきクロスサイトでPOSTして、しかもまた戻ってくるような実装ってあんまり思いつかない🤔: トラッキング系のシステムとかならもしかするとあるかもしれませんが、jnchitoさんの記事もテスト方法が中心で実装されている例については見当たらないようなので、後で参考記事↓と合わせて読んだ方がよさそう」

参考: Chrome 80が密かに呼び寄せる地獄 ~ SameSite属性のデフォルト変更を調べてみた - Qiita

長期間動き続けるジョブと戦う(Ruby Weeklyより)

割と長い記事です。

  • バックグラウンドジョブでやるべきとき:
    • リクエストがタイム・アウトする
    • メモリスパイクの発生
    • UXに影響する
  • ツール
    • Active Jobにするかどうか
    • Delayed JobかSidekiqか
  • バックグラウンドジョブの基礎
    • グローバルID
    • ジョブの事前条件をチェック(破壊的なアクションを叩かないよう注意)
    • 巨大なジョブを分割
  • ユーザーを上手に待たせる
    • 完了をメールで通知
    • ポーリングしてプログレスバーを表示
    • 合わせ技プラスアルファ
    • 上級技の紹介(取り扱い注意)
  • まとめ

つっつきボイス:「wrangleはざっくり『戦う』ぐらいの感じで😆: Ruby Weeklyのタイトルではtameになってましたが」「長期間のバックグラウンドタスクはいろいろ大変😅」

「メモリスパイク、どのツールにするか、Delayed JobかSidekiqか、ジョブはグローバルIDで管理せよ、ジョブは冪等に書け、でかいジョブは分割せよ...と、なかなかいい感じにまとまった記事👍」「Resqueは選択肢に入ってないらしい😆」「やっぱ古いか😆」

「ユーザーを待たせる間どうするかの話も押さえてありますね: すぐ終わるダウンロードならいいけど20分ぐらいかかるようなジョブだとユーザーが待ちきれなくてブラウザ閉じちゃったりするので😆、どうハンドリングするかとか」「😆」「対策としてメールで通知するか、ポーリングしてプログレスバー出すかとか実践的なことが書かれてますね😋」

「前に勉強会で話していただいたバッチ処理の話↓にも通じるところありそうですね」「たしかに近いかも☺️」(ここで当該社内向けスライドを一同で閲覧)

Webのバッチ処理とオンライン処理のポイントとシステムの応答性能を学ぶ#1(社内勉強会)

「ただこの記事が掲載されているboringrails.comは今年春に発売予定のRails本↓に収録するためにサンプル記事以外は非表示にしているようです😅(ウォッチ20190930)」「ありゃ😆」「もしかすると後で非表示になるかも」


boringrails.comより

その他Rails

つっつきボイス:「前にもウォッチ↓で取り上げた内容ですが一応リマインダーとして」「そうそう、Active SupportのdowncaseupcaseswapcaseとかがRuby本体でできるようになってましたね☺️」

週刊Railsウォッチ(20181022)Railsの名前空間地獄とrequire_dependency、PostgreSQL 11がリリース、clean-rails.orgほか


つっつきボイス:「短い記事です」「2.7のdeprecation warningを止める方法が3つ書かれてるけど結局RUBYOPTでやるのは一緒で、それをどう渡すかの違いだけ😆」

参考: 環境変数 (Ruby 2.7.0 リファレンスマニュアル)


つっつきボイス:「楽しい😋」「コミッター内でこれだけスタイルの好みが違うという😋」「hashブラケット前後のスペースを入れるかどうかは結構分かれますね〜☺️」「自分は入れる派」「linterにお任せ😆」


前編は以上です。後編は祝日をはさんで2/12(水)となります。

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

週刊Railsウォッチ(20200204後編)Ruby3.0の他のbreaking change、Rubyのシリアライザ、GitHubのcode ownersほか

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

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

Rails公式ニュース

Ruby Weekly

Hacklines

Hacklines


CONTACT

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