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

週刊Railsウォッチ(20200622前編)AR attributes周りの高速化進む、Active RecordでUNIONクエリを書く、Cable Ready gemほか

こんにちは、hachi8833です。GitHub Marketplaceの無料カテゴリを覗いてみたらいろいろありすぎてまごつきました。


つっつきボイス:「GitHub Marketplaceをちゃんと見たのは初めてでした」「GitHubリポジトリと連携できるアプリとかアクションとかですね👀」「IDEまであるのね」「エコシステムがあるのはありがたい🙏」「人気も表示して欲しいかなとちょっと思いました」「人気の指標次第では殴り合いが始まりそうですけど😆」「Chrome拡張みたいに😆」「誰かマーケットプレースをキュレーションしてくれるといいな」「やりたい人ならいくらでもいそうですけど」

  • 各記事冒頭には⚓でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄

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

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

新機能: immutable_strings_by_default設定がActiveRecord::Baseに追加

全stringカラムをデフォルトでイミュータブルにできるそうです。Rails 4のときにマージされずにcloseした#29938↓に、MySQL向けの修正を加えたそうです。


つっつきボイス:「@kamipoさんによる修正です」「Active Recordのattribute:immutable_stringを指定できるようになったのか👀」「コンフィグにもimmutable_strings_by_defaultが入るのね😋」「この改修でちょっと速くなるみたい🚅」

Warming up --------------------------------------
           user.name    34.811k i/100ms
      user.fast_name    39.505k i/100ms
Calculating -------------------------------------
           user.name    343.864k (± 3.6%) i/s -      1.741M in   5.068576s
      user.fast_name    384.033k (± 2.7%) i/s -      1.936M in   5.044425s

29938にMySQLのbooleanシリアライズの修正を加えたもの。
attributeのパフォーマンス改善を長年手掛けてきたが、(ミュータブルな)string型キャストはattributeで効率のよくない部分のひとつだ。
インプレースの改変を検出するため、string型は値の読み込みや代入をすべてdupすることで元の値を改変から保護するが、(ミュータブルな)string型の効率が低い理由がこれ。
イミュータブルなstring型は、読み込まれたり代入されたりする値をすべてfreezeすることでdupを回避でき、それによってfrozenな値はインプレース改変できないようになる。
string型attributeひとつと、そのイミュータブルなstring型のブランチによるシンプルなベンチマークは以下のとおり。
同PRより大意

# 同PRより
ActiveRecord::Schema.define do
  create_table :users, force: true do |t|
    t.string :name
    t.string :fast_name
  end
end

class User < ActiveRecord::Base
  attribute :fast_name, :immutable_string
end

user = User.new

Benchmark.ips do |x|
  x.report("user.name") do
    user.name = "foo"
    user.name_changed?
  end
  x.report("user.fast_name") do
    user.fast_name = "foo"
    user.fast_name_changed?
  end
end

「これが既存のコードに影響を与えることはまずなさそう: Active Recordで取ってきたstringのインスタンス自体を破壊的に変更することは普通ないだろうなと思うのでこれでいいでしょうし」「速い方がいいですよね😋」

Springをboot.rbに移動


つっつきボイス:「bundle exec spring binstub--allが要らなくなった」「#39622でspringがコケてた問題を修正したそうです」

1. `gem 'spring', group: :development`を`Gemfile`に追加
2. `bundle install`でspringをインストール
-3. `bundle exec spring binstub --all`でbinstubをspring化する
+3. `bundle exec spring binstub`でspring binstubを生成する

「DHHの元のプルリク↓も少し変えてるみたい」

「spring gemはいつの間にか動いててよくわからない😅」「マイグレーションがspringでつっかかったことはときどきあります😢」「割と謎だけど、Dockerコンテナで動かしている分には関係なくなることも多いのであまり気にならないかな」「springがあれば起動速くなりますし😋」

Rails 5より前の古いYAMLの読み込みを非推奨化

古いYAMLを使いたい人はLegacyYamlAdapterでやって欲しいそうです。


つっつきボイス:「古いyamlでdeprecation warningが出ると」「そろそろやっていい時期でしょうね」

# activerecord/lib/active_record/legacy_yaml_adapter.rb#L4
- module LegacyYamlAdapter
+ module LegacyYamlAdapter # :nodoc:
    def self.convert(klass, coder)
      return coder unless coder.is_a?(Psych::Coder)

      case coder["active_record_yaml_version"]
      when 1, 2 then coder
      else
+       ActiveSupport::Deprecation.warn(<<-MSG.squish)
+         YAML loading from legacy format older than Rails 5.0 is deprecated
+         and will be removed in Rails 6.2.
+       MSG
        if coder["attributes"].is_a?(ActiveModel::AttributeSet)
          Rails420.convert(klass, coder)
        else
          Rails41.convert(klass, coder)
        end
      end
    end

「その名もlegacy_yaml_adapter.rbなんてのがある↑」「Rails420とかRails41みたいに、書式が変わるたびにここに追加してたのね」「yamlのコンフィグファイルを使うテストで後方互換性をこうやって維持しているのか」「こういう実装は参考になる👍」「なるほど〜」「古い書式が必要ならLegacyYamlAdapterを使えばやれますし😋」

Arelで使われていないEquality#operatorを削除

# activerecord/lib/arel/nodes/equality.rb
module Arel # :nodoc: all
  module Nodes
    class Equality < Arel::Nodes::Binary
-     def operator; :== end
-
      def equality?; true; end

operatorメソッドは87b6856で追加され、Active Recordの12b3ecaでこのメソッドを参照している。
既にHomogeneousInが追加されているが、これにはInノードのようなoperatorはないので、operatorは以前のような互換性目的では動作しなくなっている。
これをやりたいときは代わりにequality?メソッドを使うべき。
同PRより大意


つっつきボイス:「operatorっていうメソッドがArelにあったんですね」「不要になったら消されるのが常」「homogeneousって何でしたっけ?」「『同じ』的な意味っぽいけど🤔」「辞書見ると『同種の』という堅苦しい意味でした」

各種高速化


つっつきボイス:「#39612によるとModel.find(1)はいいけど、Model.find(1).attr_nameLazyAttributeHashがあっても遅かったのね」「findはlazyになってたけどattributeがlazyになってなかったので修正したとかそういう感じでしょうね☺️」「なるほど!」「今ちょうどこういう感じのベンチマーク書いてるので参考になる↓🥰」

「ところでプルリクに貼られてるこのgist↑、折り畳めるようになってるんですけどmarkdownでどう書くんだろう?」「GitHubのgistだからGitHubだと書くだけで折りたたみ表示になるとか?🤔」「それっぽいですね」

生のデータベース値を元にattributes hashをインスタンス化するのはattribute周りで遅い部分のひとつだった。
これはattributeの改変検出で必要だったためで、言い換えれば改変が発生するまでは不要ということになる。
0f29c21で導入されたLazyAttributeHashはattributeに最初にアクセスするまでインスタンス化をlazyにする(つまりModel.find(1)は遅くならなくなったがModel.find(1).attribute名が遅かった)
このPRはLazyAttributeSetによるattributeのインスタンス化をよりlazyにし、attributeに最初に代入またはdirtyチェックするときまでattributeをインスタンス化しないようになる(つまりModel.find(1).attribute名は遅くなくなる)。
これによってreaonly(改変なし)のattributeアクセスがおよそ35%改善される。
同PR冒頭より


「#39645はdefine_methodを置き換えて速くしたそうです」「実はdefine_methodでメソッドを動的生成する必要がなかった感😆」「呼び出すメソッドが決まってれば動的にやらなくてもよかったと」「なるほど」

# activemodel/lib/active_model/type/helpers/accepts_multiparameter_time.rb#L4
  module Type
    module Helpers # :nodoc: all
      class AcceptsMultiparameterTime < Module
-       def initialize(defaults: {})
+         define_method(:serialize) do |value|
        module InstanceMethods
          def serialize(value)
            super(cast(value))
          end

-         define_method(:cast) do |value|
+         def cast(value)
            if value.is_a?(Hash)
              value_from_multiparameter_assignment(value)
            else
              super(value)
            end
          end

-         define_method(:assert_valid_value) do |value|
+         def assert_valid_value(value)
            if value.is_a?(Hash)
              value_from_multiparameter_assignment(value)
            else
              super(value)
            end
          end

-         define_method(:value_constructed_by_mass_assignment?) do |value|
+         def value_constructed_by_mass_assignment?(value)
            value.is_a?(Hash)
          end
...
Before:
Warming up --------------------------------------
type.serialize(time)    12.899k i/100ms
Calculating -------------------------------------
type.serialize(time)    131.293k (± 1.6%) i/s -    657.849k in   5.011870s

After:
Warming up --------------------------------------
type.serialize(time)    14.603k i/100ms
Calculating -------------------------------------
type.serialize(time)    145.941k (± 1.1%) i/s -    730.150k in   5.003639s

「Active Record、着々と速くなってますね💪」「Rubyのdefine_methodって便利なのでときどき使いたくなりますけどね😋」

セキュリティ修正: show_detailed_exceptionsをオンにしないとActionableErrorsを表示しないようにした


つっつきボイス:「こちらは今日(6/18)出した記事↓で扱ったセキュリティ修正です」「ああ、pendingしたマイグレーションに絡むヤツでしたっけ」「う、そうでしたか😅」

セキュリティアップデート: Rails 6.0.3.2および一部のgem

「このリンク先↓ちょっとだけ眺めたんですけど」「お、リンク切れ直ってますね」「pendingしたマイグレーションを実行できてしまうらしいんですけど、なぜexceptionを見ることで実行できたのかが今のところ謎なんですよね🤔」「あら〜」「productionで本当に実行できるとしたらエグいといえばエグいけど、実行できるのはpending中の定義済みマイグレーションだけみたいなので、ただちに大事になったりはしないんじゃないかな」「問題につながるのは、pendingマイグレーションが残った形でデプロイしたときなんでしょうし🤔」「pendingマイグレーションを実行できちゃうのはたしかにイケてませんし」「後で読んでみます👀」

信頼されていないユーザーがproduction環境でpendingマイグレーションを実行できる問題
これはRails 6.0.3.2より前のバージョンにおける脆弱性で、信頼されていないユーザーが、production環境で任意のpendingマイグレーションを実行できてしまう。
(中略)
影響
攻撃者はこのissueを用いて、productionモードで動くRailsアプリで任意のpending中マイグレーションを実行できるようになる可能性がある。ただし攻撃者が実行できるマイグレーションは、アプリケーションで定義済みであり、かつ未実行のものに限られる点が重要である。
groups.google.comより抜粋・大意

追記(2020/06/23)

y-yagiさんブログでこのissueがActionableErrorに関連していることを解説しているそうです。ありがとうございます!

Rails

Active RecordでUNIONクエリを書く(Hacklinesより)


つっつきボイス:「Active RecordでUNIONクエリですか」「生SQL使ったりいろいろ苦心してますね」「まあBase.connection.execute()でやれますけど」「ActiveRecordExtendedというgemも使ってみてる👀」「同じモデルをUNIONするのは比較的わかりやすいかな☺️」

# 同記事より
result = ActiveRecord::Base.connection.execute("SELECT users.* FROM users WHERE users.active = 't' UNION SELECT users.* FROM users WHERE users.manager = 't'")
result.to_a #=> Collection of users from the union query
# 同記事より
User.where.not(updated_at: nil).merge(
  User.union(
    User.where(name: "Jon")
  ).union(
    User.where(active: true)
  )
).order(start_date: :desc)

「結局unionヘルパーを自力で書いてる↓」

# 同記事より
module ActiveRecordUnion
  extend ActiveSupport::Concern

  class_methods do
    def union(*relations)
      mapped_sql = relations
        .map(&:to_sql)
        .join(") UNION (")

      unionized_sql = "((#{mapped_sql})) #{table_name}"

      from(unionized_sql)
    end
  end
end

ActiveRecord::Base.send(:include, ActiveRecordUnion)

.join(") UNION (")という無理やりな書き方↓がちょっと微笑ましい」「あるある😆」

# 同記事より
mapped_sql = relations
  .map(&:to_sql)
  .join(") UNION (")

参考: PostgreSQL12 7.4. 問い合わせの結合

「普段からActive Recordを使っていると、SQLのINTERSECTとかUNIONってそれほど使う機会なさそうな気はしますけどね」「pluckでフェッチして差分取って検索し直すぐらいだったらSQLのINTERSECTで一発でキメたいときはあるといえばありますし」「まあこんなに苦労してやるぐらいだったら、記事冒頭みたいにBase.connection.execute()使うか(データベース)ビューを実行したくなりますね☺️」「わかります」

「ただBase.connection.execute()は返ってくる値が使いにくいので、できればビューを使いたいかな: でもビューだと今度は動的なことがやれなくなるのが痛し痒し...」「そうなんですよね😅」

RDBMSのVIEWを使ってRailsのデータアクセスをいい感じにする【銀座Rails#10】

parallel_tests: RSpecやTest::Unitをパラレル化(Ruby Weeklyより)

# 同記事より
rake parallel:test          # Test::Unit
rake parallel:spec          # RSpec
rake parallel:features      # Cucumber
rake parallel:features-spinach       # Spinach

rake parallel:test[1] --> force 1 CPU --> 86 seconds
rake parallel:test    --> got 2 CPUs? --> 47 seconds
rake parallel:test    --> got 4 CPUs? --> 26 seconds
...

つっつきボイス:「parallel_tests、見たことある気がする👁」「ウォッチでまだ扱ったことがなかったので入れてみました」「使ったことないけど歴史長そう🏯」「RSpec用に似たようなgemって他にもあったと思いますし」

後で調べるとinitial commitは2009年でした。

「今は更新されなくなって無料になったRailscasts↓へのリンクがREADMEに貼ってあるぐらいなので相当昔からあるのはたしか」「ほんとだ」「これも今は使われなくなったzeus gemとかがあった時代ですし」「なつかしい」「間違いなく老舗ですね☺️」

「このparallel_tests gemとか、クックパッドさんが出してるrrrspecというRSpec並列化gem↓あたりが老舗かな」「なるほど」

RaiilsのActiveModel::Errorで各バリデーションエラーをカプセル化


つっつきボイス:「この記事ではRails自体の機能でやってるんですが、これも知らなかったので😅」「どれどれ、errorsの構造がこういう配列になったのね↓」

user.errors.where(:contact_number)
=> [<#ActiveModel::Error attribute=contact_number, type=not_a_number, options={:value=>"abcdefghijk}>]

「どっかで見たな〜これ」「記事によるとこの#32313は昨年入ってたそうです↓」「ですよね、最近こうなった覚えがありますし😋」「ActiveModel::ErrorsオブジェクトからActiveModel::Errorオブジェクトの配列に変わった」

# 同記事より
user.errors.add(:contact_number, :too_short, count: 10)
=> <#ActiveModel::Error attribute=contact_number, type=too_short, options={:count=>10}>

user.errors.where(:contact_number)
=> [<#ActiveModel::Error attribute=contact_number, type=not_a_number, options={:value=>nil}>, <#ActiveModel::Error 
attribute=contact_number, type=too_short, options={:count=>10}>]

user.errors.added?(:contact_number, :too_short, count: 10)
=> true

user.errors.added?(:contact_number, :too_short)
=> false

user.errors.delete(:contact_number, :too_short, count: 10)

user.errors.where(:contact_number)
=> [<#ActiveModel::Error attribute=contact_number, type=not_a_number, options={:value=>nil}>]

user.errors.match?(:contact_number, :not_a_number)
=> true

user.errors.match?(:contact_number, :too_long)
=> false

「データモデルとしてはこの方が本来望ましい形ですね👍」「おぉ😍」

delete_allの挙動にびっくりした話(Hacklinesより)


つっつきボイス:「delete_allしてみたらUPDATEクエリが走ってびっくりしたそうです↓」「ああ、has_manyのときは参照のidがNULLでUPDATEされるみたいなヤツですね」「記事のAPIドキュメントにもそう書かれてますね😳」


同記事より

:dependentオプションで指定した戦略に応じてコレクションのレコードをすべて削除する。:dependentが指定されていない場合はデフォルトの戦略に従う。
has_many関連付けの場合、デフォルトの削除戦略は:nullifyになる。これは外部キーをNULLに設定する。

同記事で引用したdelete_allのAPIドキュメントより

「そういえばdelete_alldestroy_allだと、前者はコールバックしないんだったっけ?」「そうそう、destroy_allはコールバックを実行するんだった↓」

destroy_allはコールバックがある分、削除するレコード数が増えると当然めちゃ遅くなる可能性がある」「コールバック動かすにはeachすることになりますし」「ですよね」「delete_allは1つのSQLクエリでやるからコールバックはできない分、当然速い🚅」「delete_alldestroy_allの2つのメソッドがあるのには理由があるということで☺️」

メモ: レコードごとのインスタンス化、コールバック実行、削除は、多数のレコードを一気に削除しようとすると時間がかかることがある。1レコードにつき少なくともSQLのDELETEクエリがひとつ発行される(コールバックでさらに発行される可能性もある)。多数の行を短時間で削除したい場合、関連付けやコールバック関連の懸念がないのであればdelete_allを使う。

同記事で引用したdestroy_allのAPIドキュメントより

Cable Readyでブラウザをリアルタイム更新(Ruby Weeklyより)


つっつきボイス:「Screencastsによる手順解説ですけど、最近Cable Readyを割と見かけるなと思って」「Action Cableといい感じに連携するヤツでしたっけ」


同リポジトリより

cable_readyっていうメソッド名なのね」「こういう書き方するのか〜」「include CableReady::Broadcasterしてるし」「チラ見した限りでは、Action CableがJSで出してきたブロードキャストを受けていい感じにリアルタイム更新してくれそう😋」

# サンプルコードより
class Card < ApplicationRecord
  include CableReady::Broadcaster

  after_update do
    cable_ready["cards"].morph(
      selector: "#" + ActionView::RecordIdentifier.dom_id(self),
      html: ApplicationController.render(self)
    )
    cable_ready.broadcast
  end
end

「JSのmorph処理をモデルに書くとか、フロントエンジニアに睨まれそうな書き方😆」「morph?」「具体的には知りませんけど、名前からしていわゆるモーフィング的な動きをやれるヤツでしょうね」「あ、モーフィングですか」「引数がselectorhtmlですし、おそらく当該セレクタの内容をこの内容でモーフィング的に書き換えるんだろうな〜と推測しました」「なるほど!」

「まあApplicationRecordを継承したモデルにこういう処理を書くのはちょっと考えちゃいますけど😅」


前編は以上です。

おたより発掘

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

週刊Railsウォッチ(20200616後編)本番環境をFullstaq Rubyに換えた理由、CSRF発生フローチャート、DBのトランザクション分離レベル比較ほか

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

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

Rails公式ニュース

Ruby Weekly

Hacklines

Hacklines


CONTACT

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