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

週刊Railsウォッチ(20201201前編)switch_pointがActive Record 6.0でサポート終了、Rails DBトランザクションの落とし穴ほか

こんにちは、hachi8833です。本日よりTechRachoアドベントカレンダー2020が始まりました。どうぞよろしくお願いします。

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

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

今週は公式更新情報から見繕いました。


つっつきボイス:「6.1.0のマイルストーンが先週見た後で急に進捗進んでました」「リグレッションも出ているけど、issueはもう残り2件か」「リリースがだいぶ近づいてきたようですね」

なお、つっつきの翌日はissueが4件に増え、ウォッチ公開日には再び減って1件(99%完了)になっていました。

partitionedテーブル定義でschema:loadの問題を修正

# activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb#L47
          def add_table_options!(create_sql, o)
            create_sql = super
            create_sql << " DEFAULT CHARSET=#{o.charset}" if o.charset
            create_sql << " COLLATE=#{o.collation}" if o.collation
-           add_sql_comment!(create_sql, o.comment)
+           add_sql_comment!(super, o.comment)
          end

つっつきボイス:「テーブル定義にPARTITION BYを書けるのか↓」「これはいわゆるシャーディングの機能」「この機能を実際に使って起きた問題をissueにあげてくれたんでしょうね」

# activerecord/test/cases/adapters/mysql2/table_options_test.rb#53
  test "charset and partitioned table options" do
    @connection.create_table "mysql_table_options", primary_key: ["id", "account_id"], charset: "utf8mb4", collation: "utf8mb4_bin", options: "ENGINE=InnoDB\n/*!50100 PARTITION BY HASH (`account_id`)\nPARTITIONS 128 */", force: :cascade do |t|
      t.bigint "id", null: false, auto_increment: true
      t.bigint "account_id", null: false, unsigned: true
    end
    output = dump_table_schema("mysql_table_options")
    expected = /create_table "mysql_table_options", primary_key: \["id", "account_id"\], charset: "utf8mb4", collation: "utf8mb4_bin", options: "ENGINE=InnoDB\\n(\/\*\!50100)? PARTITION BY HASH \(`account_id`\)\\nPARTITIONS 128( \*\/)?", force: :cascade/
    assert_match expected, output
  end

参考: Shard (database architecture) - Wikipedia

「PARTITION BYはシャーディングのときにどのキーで分散させるかを指定するものだったと思います」「なるほど」

参考: PostgreSQL 12 ドキュメント: 5.11. テーブルのパーティショニング

新機能: Active Storageにstrict loadingを追加

# activestorage/lib/active_storage/attached/model.rb#L63
-       has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record, inverse_of: :record, dependent: :destroy
-       has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob", source: :blob
+       has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record, inverse_of: :record, dependent: :destroy, strict_loading: strict_loading
+       has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob", source: :blob, strict_loading: strict_loading

つっつきボイス:「strict_loading: trueを指定するとlazy loadingを禁止するようになったのか」

# activestorage/lib/active_storage/attached/model.rb#43
class User < ApplicationRecord
  has_one_attached :avatar, strict_loading: true
end

「これまではデフォルトでlazy loadingが効いていて恐らくストリーム処理されていたけど、ユースケースによってはファイルが完全にアップロードを完了してから処理したいというケースがあるので、それに対応したということだと思います」「後処理やバリデーションが重くて、しかも一般ユーザーが頻繁に巨大ファイルをアップロードするサービスでは、こういうオプションがあるといいでしょうね👍」

同じクラスへの複数のbelongs_toautomatic_inverse_ofの挙動を修正

このプルリクは、foreign_keysが同じautomatic_inverse_ofにバリデーションを追加する。
同じクラスへの複数のbelongs_toがクラスにあると、automatic_inverse_ofで誤ったinverse_nameを検索することがある。

class Room < ActiveRecord::Base
  belongs_to :user
  belongs_to :owner, class_name: "User"
end

class User < ActiveRecord::Base
  has_one :room
  has_one :owned_room, class_name: "Room", foreign_key: "owner_id"
end

user = User.create!
owned_room = Room.create!(owner: user)

p user.room

現在のautomatic_inverse_ofは、関連付けで見つかったリフレクションをバリデーションする。
しかし、このバリデーションは外部キーが同一であることをバリデーションしていない。
外部キーのバリデーションを追加することでこのissueを修正できる。
同PRより大意


つっつきボイス:「上のサンプルコードのように、Roomというクラス内にbelongs_to :userbelongs_to :owner, class_name: "User"という同じクラスを参照するbelongs_toが複数あって、さらにautomatic_inverse_ofを使ったときの挙動に問題があったのね」「選択肢が複数あるときにautomatic_inverse_ofが違う方を返すことがあったらしい」「修正は1行追加だけでした↓」

# activerecord/lib/active_record/reflection.rb#L626
        def valid_inverse_reflection?(reflection)
          reflection &&
+           foreign_key == reflection.foreign_key &&
            klass <= reflection.active_record &&
            can_find_inverse_of_automatically?(reflection)
        end

新機能: FFmpegのシーン検出機能による動画プレビューの自動頭出し


つっつきボイス:「RailsであのFFmpegを使うのか!」「フェードインで始まる動画だとプレビューが真っ黒になってしまうので、シーンの始まる位置を検出して変えられるようになったそうです」「Active Storageにデフォルトで動画プレビュー機能があるとは知らなかった」

参考: FFmpeg - Wikipedia

「FFmpegのバージョンも指定されている↓」

# guides/source/active_storage_overview.md#L554
-WARNING: Extracting previews requires third-party applications, FFmpeg for
+WARNING: Extracting previews requires third-party applications, FFmpeg v3.4+ for

FFmpegよもやま話

「これはFFmpeg職人の出番かな」「FFmpegは今でこそエンジニア以外も使うツールになっていますけど、昔は知る人ぞ知るマニアックなツールでしたね」「懐かしいです」「FFmpegはかなりいろんな画像・音声処理をこなすことができて、画像を集めてアニメーションGIFを作ることも、動画からアニメーションGIFを生成することもできたと思います」「ここで使っているような、指定のキーフレームを画像として取り出したりすることもできます」「FFmpegはスイスアーミーナイフ的に強力なツールなので、その分コマンドラインオプションがすごく長くなったりしましたね」「そうそう」

「Active StorageをインストールするとFFmpegも入るんでしょうか?」「FFmpegはRails外部のソフトウェアなので自動では入らないでしょうね」「コマンドラインで呼び出すと思われるので、あればそれを使うし、入ってなければエラーになるんじゃないかな」「なるほど」「FFmpegが入っているかどうかをチェックするロジックぐらいはありそう(ffmpeg_exists?)」

「FFmpegは普通のWebサーバーにはなかなか入ってないと思います」「それにFFmpegで動画コーデックを使う場合はコーデックのライセンスをチェックしないといけなくなるんですよ」「あ、たしかに」「コーデックなども含めたFFmpeg環境を完全に再現するのは地味に面倒」「WindowsやLinuxだとコーデックがひととおり入ったパッケージがあったと思います」「サイズ大きくなりそう…」

参考: コーデック - Wikipedia
参考: おすすめのコーデックパック - k本的に無料ソフト・フリーソフト — Windows用
参考: Install Multimedia Codecs Ubuntu 20.04 LTS – Linux Hint — Linux用

「動画コーデックの中にはライセンスがかなり複雑なものもあった覚えがあります」「昔は設定をあれこれこねくり回しながら使ってましたけど、最近はこの辺であまり悩まなくなった気がします」「QuickTime動画を再生するためだけにAppleの有料プレーヤーを買ったこともあります」「有料版、そういえばありました」「国によってメジャーなコーデックが違ったりするんですよ」

参考: QuickTime - Wikipedia

Rails

BasecampのHEY stack


つっつきボイス:「HEY.comはBPS社内Slackに貼っていただいたやつです」「BasecampのHEYというサービスはこれまで今ひとつわからなかったんですが、複数のメールボックスをここに集約するといい感じにメールボックスを横断的に検索したり管理したりできるようですね」

「そのHEY.comのGemfileをDHHが公開しています↓」「ありがたい🙏」

HEY stackのGemfile

「このGemfileで興味深いのは、たとえばsasscやsassc-railsを使っているところ↓: Webpackerは入っているけど、sassはWebpackerではなくsprocketsでコンパイルさせている」「ホントだ」「sassの中にRubyのコードを仕込むのであればsprocketsで処理するしかないでしょうね」

# 同Gistより
# JavaScript and assets
gem 'webpacker', '~> 5.1.1'
gem 'sprockets', github: 'rails/sprockets'
gem 'sprockets-rails', github: 'rails/sprockets-rails'
gem 'jbuilder', '~> 2.9', '>= 2.9.1', github: 'rails/jbuilder'
gem 'sassc-rails', '~> 2.1'
gem 'sassc', '<= 2.1'
gem 'local_time', '~> 2.0'
gem 'turbo', github: 'basecamp/turbo'

sass/sassc - GitHub
sass/sassc-rails - GitHub

「他にも見慣れないgemがいくつか見える」「ジョブの処理にresqueを使ってますね」「同じくジョブ処理でよく使われるsidekiqだと、マルチプロセスなどの高度な機能を使う場合は有料版を買うことになりますけど(機能表)、resqueはオープンソースのみなのでHEY.comで採用したのかもしれないと想像してみました」

# 同Gistより
# Jobs
gem 'resque', '~> 2.0.0'
gem 'resque-multi-job-forks', '~> 0.5'
gem 'resque-pool', github: 'nevans/resque-pool'
gem 'resque-scheduler', github: 'resque/resque-scheduler'
gem 'resque-pause', github: 'basecamp/resque-pause'
gem 'resque-web', require: 'resque_web'
gem 'resque-scheduler-web', github: 'mattgibson/resque-scheduler-web'
gem 'sinatra', github: 'sinatra/sinatra'

resque/resque - GitHub
mperham/sidekiq - GitHub

「resqueのリポジトリを見ると、同じくジョブ処理用のdelayed_jobとの比較は載っているけど、sidekiqとの比較は載ってなかった」「resqueは、delayed_jobから強くインスパイアされて作ったと書かれてる、へ〜」「知らなかった」「世の中的にはsidekiqの方がメジャーな印象があって自分も最近はよく使いますけど、sidekiqは企業がサポートしているという安心感があるからよく使われているのかもしれないとちょっと思いました」

collectiveidea/delayed_job - GitHub

「GistのコメントのFAQを見ると、sidekiqの機能は不要、resqueで十分だからというDHHのツイート↓が引用されてる」「sidekiqは高機能ですけど、それが不要ならresqueで十分というのは理解できる」

別のコメントには、なぜMySQLにしたのかというFAQでもDHHのツイート↓が引用されてますね」「PostgreSQLとMySQLについて特に思うことはない、だからMySQLを使っているという感じかな」

MySQLの使いどころ

「HEY.comでMySQLを使う理由について今自分が思ったのは、データがものすごく多くて、しかもメールという書式の整ったデータだからという要因もあるんじゃないかということですね: そのような場合はMySQLの方が速くなる見込みがあるんですよ」「おぉ」

「MySQLは、しくみや特性を理解して正しく用いればPostgreSQLより速くなることがよくあります: 全般にPostgreSQLは複雑なクエリを書いたときに速度を出しやすいけど、クエリが比較的単純な場合や複雑なクエリが不要な場合はMySQLの方が速度を出しやすい傾向がありますね(※あくまで経験による主観です)」「なるほど!」

「あと考えられるとすれば、HEY.comを作ったときにAWS Auroraを使いたかったのかもしれませんね」

参考: Amazon Aurora(高性能マネージドリレーショナルデータベース)| AWS

「MySQLもPostgreSQLもRailsでは広く使われているので、データの特性やユースケースなどに応じて適している方を使えばよいと思います」「その意味で、MySQLかPostgreSQLかという二者択一を迫る問いはあまり重要ではないでしょうね」

Rails DBトランザクションの落とし穴(RubyFlowより)


つっつきボイス:「トランザクションのいい書き方と悪い書き方のコード例が紹介されています」「以下のように↓トランザクションでStandardErrorを書くことも一応できるけど、StandardErrorだとエラーが意味する範囲が広すぎるので、独自のエラークラスを作ってActiveRecord::RecordInvalidで出力する方がずっとよいという話、たしかに」「独自のエラークラスを定義するのはちょっとだけ手間ですが」

# 同記事より
record = MyModel.last
error_for_user = nil

begin
  ActiveRecord::Base.transaction do
    # ...
    record.save!
  end
rescue StandardError => e
  # do something with exception here
  error_for_user = "Sorry your transaction failed. Reason: #{e}"
end

puts error_for_user || "Success"

「ダメな例のひとつが、データベースレベルのエラーであるActiveRecord::StatementInvalidをトランザクションの中でrescueすること↓」「トランザクションの外でbeginendを書いて、トランザクションの外でrescueせよという話、これもそのとおり」「beginendの場所が違うということですね」

# 同記事より: 悪例
record = MyModel.last
error_for_user = nil


ActiveRecord::Base.transaction do
  begin
    # ...
    record.save!
  rescue ActiveRecord::StatementInvalid => e # DON'T DO THIS !
    error_for_user = "Sorry your transaction failed. Reason: #{e}"
  end
end

puts error_for_user || "Success"

「よい例として、トランザクションの中でraise ActiveRecord::Rollbackして明示的にトランザクションをロールバックする↓: これもよく使います」

# 同記事より
def add_bonus(tomas)
  ActiveRecord::Base.transaction do
    raise ActiveRecord::Rollback if john.is_not_cool?
    tomas.update!(money: tomas.money + 100)
  end
end


begin
  add_bonus(tomas)
rescue ActiveRecord::Rollback => e
  puts "Sorry your transaction failed. Reason: #{e}"
end

#transactionMyModel.transactionActiveRecord::Base.transactionは互いにエイリアスなので動作は全部同じという話、おっしゃるとおり」「自分は昔からActiveRecord::Base.transactionで書くのが好きなんですが、SQLのトランザクションを理解していれば、そもそもトランザクションの単位がモデルではないことがわかるので、その意味でもモデルを指定しないこの書き方が好きですね」「なるほど」

理由は、トランザクションの単位はモデルではなく、データベースコネクションを単位とするからだ。
ActiveRecord::Transactions::ClassMethodsより大意

「次はネステッドトランザクションは避けることをおすすめしたいという話」「たしかにネステッドトランザクションはいろいろ難しい: たとえばこの記事↓にもありますけど、ネストの内側でActiveRecord::Rollbackしても握りつぶされる😇」

参考: ネストしたトランザクション内で ActiveRecord::Rollback を raise しても握りつぶされるだけだ | TECHSCORE BLOG
参考: 【翻訳】ActiveRecordにおける、ネストしたトランザクションの落とし穴 - Qiita

「そもそもネステッドトランザクションは、RDBMSの実行レベルでサポートされていないことがほとんどです↓」「あ〜」「上の記事にrequires_new: trueを使えば真のネステッドトランザクションを使えると書かれてはいるけど、それでも大変…」(中略)

ほとんどのデータベースは真のネステッドトランザクションをサポートしていない。執筆時点では、真のネステッドトランザクションをサポートしていることがわかっているのはMicrosoft SQL Server(MS-SQL)だけだ。
ActiveRecord::Transactions::ClassMethodsより大意

「記事の最後で、以下のようにトランザクションの中にsaveだけを書く意味はないとあるのはどういうことだろう?🤔」

ActiveRecord::Base.transaction do
  user.save!
end

「お、引用されているAPIドキュメント↓によると、savedestroyは自動的に設定されているコールバックごとトランザクションでラップされるのか!」「Active Recordよくできてる〜」「それならたしかに上のように書く必要はありませんね😋」

#saveおよび#destroyのどちらも、ひとつのトランザクション内にラップされ、バリデーションやコールバックで行うあらゆる操作はこのトランザクションの保護下に置かれます。これによって、トランザクションが依存する値をチェックするためのバリデーションを使うことも、after_*などのコールバックで例外をraiseしてロールバックすることもできます。
ActiveRecord::Transactions::ClassMethodsより大意

「これはなかなかいい記事だと思います👍」


同記事で参照されているAPIドキュメントを翻訳しました↓。

Rails APIドキュメント: Active Recordのトランザクション(翻訳)

rails-erd: ER図を生成

voormedia/rails-erd - GitHub


つっつきボイス:「rails-erdは前からあったような気がしたんですが、ウォッチで扱ったことがありそうでなかったので取り上げてみました」「こういうER図を生成するのね↓」「使いたい人はどうぞ」


voormedia.github.ioより

「ちなみにJetBrainsのRubyMineでも右クリックでダイアグラムを生成してくれますヨ↓(rails-erdとは違う図ですが)」「なるほど、IDEならgemをインストールせずにできますね」

Rails: RubyMineでテーブル設計する

switch_pointはActive Record 6.1以降をサポートせず

eagletmt/switch_point - GitHub


つっつきボイス:「BPS社内Slackにebiさんが貼ってくれた記事です」「switch_pointは、Railsでマルチプルデータベースを行うときによく使われるgem」「たしかにRailsが公式にマルチプルデータベースをサポートするようになればswitch_pointでやる必要はなくなりますね」

「記事によると、Rails 6.1でついにswitch_pointが壊れるようになったのか…」「Rails 6.0まではswitch_pointが動いてたというのもスゴい」「少し前にRailsにR/W(リード/ライト)Splittingが入っていたぐらいなので、Railsのマルチプルデータベース機能とswitch_pointの共存はヤバそう」

「シャーディングも含めてswitch_pointの機能がRailsで使えるようになってきたので、今後新しいRailsプロジェクトでマルチプルデータベースが必要なときはRailsの機能を使うことになるでしょうね」「マルチプルデータベース機能の開発リソースがswitch_pointとRailsで分かれてしまうのはもったいないので、switch_pointが6.1以降のサポートを終えたことでRailsでの開発にリソースが集約されることが期待できそう」


前編は以上です。

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

週刊Railsウォッチ(20201124)strict loading violationの振る舞いを変更可能に、Railsモデルのアンチパターン、quine-relayとさまざまなクワインほか

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

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

Rails公式ニュース

RubyFlow

160928_1638_XvIP4h


CONTACT

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