Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
「Rails 6.1で新しく入る機能について」iCARE Dev Meetup #12 の登壇内容 https://icare.connpass.com/event/183716/

Rails 6.1で新しく入る機能について

@willnet

最近のRailsリリース日

  • 6.0.0 (2019/08/06)
  • 5.2.0 (2018/04/09)
  • 5.1.0 (2017/04/27)
  • 5.0.0 (2016/06/30)

だいたい1年くらいでマイナー、メジャーバージョンが上がる

RailsConf(毎年だいたい4末くらいに開催)がターゲットになっていそう

ではそろそろ6.1.0でるの?というとまだっぽい雰囲気を感じる

6.1のリリースはまだだけど、この1年ですでにmasterにマージされた機能はたくさんあるので、今日はそのうちの一部を紹介します (6.0の機能を知りたい人はパーフェクトRuby on Railsという素敵な本があるのでそちらを御覧ください)

ビューで「どのテンプレートを呼び出しているか」がHTMLコメントで表示できるようになった

config.action_view.annotate_rendered_view_with_filenames = true
  • とすると有効になる(デフォルトだと config/environments/developmrnt.rb でコメントアウトされた状態になっている)
  • べんり!
  • ただし現時点ではERBのみ対応
    • PRチャンスかも

config/routes.rb の内容を別ファイルに分割できるようになった

  • config/routes.rb の内容を別ファイルに外だしできるようになった
  • 昔(Rails 4.0がリリースされる前)入った内容なんだけどDHHのお気に召さなかったようでrevert→6年たってDHHの気が変わった模様
  • これまでもゴニョゴニョすればできたけど、公式のやり方が提供されたので安心して使えるようになったのがよいですね

↓のように書く

# config/routes.rb
Rails.application.routes.draw do
  draw(:admin)
end

# config/routes/admin.rb
get :foo, to: 'foo#bar'

routes.rbが大量にあって、かつ分けやすい箇所がある(ex: 管理画面、 API)のであれば試してみるとよいのでは

ActiveRecordにsigned_idメソッドとfind_signedメソッドが追加

  • idを暗号化、かつ改ざん検知ができるトークンを付与したものを生成するsigned_idメソッドと、それを使ってfindできるfind_signedメソッドが追加されました
  • トークンに有効期限を含めることもできます
signed_id = User.first.signed_id(
  expires_in: 15.minutes, purpose: :password_reset
)

User.find_signed(signed_id) # => nil (purposeがない)

User.find_signed(signed_id, purpose: :password_reset) # => User.first

# 16分後...
User.find_signed(signed_id, purpose: :password_reset) # => nil (expireした)

User.find_signed!("bad data") # => エラー

コード例のように、パスワードリセット機能などを作るときに便利では

strict_loading の導入

strict_loadingメソッド経由で取得したオブジェクトは、以後SQLの発行を伴う関連を呼び出せない(呼び出すとエラー)。これによってincludesなどのeager loadを強制させることができる。

user = User.strict_loading.first
user.posts.to_a
#=> ActiveRecord::StrictLoadingViolationError

user = User.strict_loading.includes(:posts).first
user.posts.to_a #=> OK

関連先にもstrict_loadingは伝播する。

user = User.strict_loading.includes(:posts).first
user.posts.first.comments
#=> ActiveRecord::StrictLoadingViolationError

関連のオプションとして設定することもできる。↓のようにすると、posts関連はincludesやpreloadなど経由でしか呼び出せない。

class User < ApplicationRecord
  has_many :posts, strict_loading: true
end

user = User.first
user.posts.first #=> ActiveRecord::StrictLoadingViolationError

モデル単位で設定することもできる

class User < ApplicationRecord
  self.strict_loading_by_default = true
  has_many :posts
end

user = User.first
user.posts.first
# => ActiveRecord::StrictLoadingViolationError Exception

↓のどちらかで、AR全体にstrict_loading_by_defaultを設定することもできる

ActiveRecord::Base.strict_loading_by_default = true
Rails.application.config.active_record.strict_loading_by_default = true

これはあえてstrict_loadingにしたくないんですよ、という場合は次のようにできる

user = User.strict_loading(false).first
user.posts.to_a #=> ok

strict_loadingを使うとpreloadが強制されて、N+1を防ぐことができるぞ!というのは便利そうだけど、代わりに↓のように無駄にオブジェクトを作ってしまうケースが出てきそうですね。なにも考えずにコードを書けるようになるわけではない。

user = User.includes(:posts).first
user.posts.last
#=> 一つだけPostがあればいいのに全件分のPostオブジェクトを生成してしまう!

複数DBのsharding対応が入った

  • Rails6.0でプライマリ/レプリカの複数DBに対応した
  • Rails6.1ではそれを拡張して、shardingにも対応

6.0では次のように、プライマリ/レプリカを指定していた

class ApplicationRecord < ApplicationRecord
  connects_to database: { writing: :primary, reading: :secondary }
end
  • connects_toメソッドに、次のようにshardingの定義を書けるようになった。
    • 見てわかるようにshardingとprimary/replicaを併用できる
    • 6.0の記法からから1階層増えた感じ
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to shards: {
    default: { writing: :primary, reading: :primary_replica },
    shard_one: { writing: :primary_shard_one, reading: :primary_shard_one_replica }
  }
end

6.0のときと同じようにconnected_toでDBを切り替える

ActiveRecord::Base.connected_to(shard: :default) do
  @id = Record.create! # デフォルトのshardのDBでレコードを作成する
end

ActiveRecord::Base.connected_to(shard: :shard_one) do
  Record.find(@id) # もう一つのshardからfindしているのでレコードは見つからない
end

roleとshardを一度に両方切り替えることもできる

ActiveRecord::Base.connected_to(role: :reading, shard: :shard_one) do
  Record.first
end

Deprecation warningの代わりに例外を発生させることができるようになった

  • Railsがバージョンアップしたときにdeprecatedになったメソッドを実行すると、deprecation warningなメッセージが表示される
  • メッセージを表示する代わりに、例外を発生させることができるようになった

次のように、対象となるdeprecation warningsを指定できる

  • warningの文章の一部を文字列もしくはシンボルで指定する
  • warningの文章にマッチする正規表現を指定する
ActiveSupport::Deprecation.disallowed_warnings = [
  "bad_method",
   :worse_method,
  /(horrible|unsafe)_method/,
]

全部対象にしたい場合は次のようにする

ActiveSupport::Deprecation.disallowed_warnings = :all

対象になったらどうするか、を次のように指定できる。production以外は例外を発生させて、productionはログに残すだけの例。

if Rails.env.production?
  ActiveSupport::Deprecation.disallowed_behavior = [:log]
else
  ActiveSupport::Deprecation.disallowed_behavior = [:raise]
end

deprecatedで例外を発生させる設定をデフォルトにしたんだけど、ここはまだdeprecatedなメソッドを使いたい、というようなときには明示的にそれを許可することもできる。

ActiveSupport::Deprecation.allow do
  User.do_thing_that_calls_bad_and_worse_method
end

引数で許可するものを絞り込むこともできる

ActiveSupport::Deprecation.allow [:bad_method, "worse_method"] do
  User.do_thing_that_calls_bad_and_worse_method
end

条件を満たすときだけ許可することもできる

ActiveSupport::Deprecation.allow [:bad_method], if: Rails.env.production? do
  User.do_thing_that_calls_bad_method
end

deprecation warningに頑張って対応したPRをマージしたあとに、すぐまた別のPRでdeprecation warningが発生する、ということを防ぐことができて便利!

Active Storageでアップロードしたファイルへ直接アクセスできるURLを使えるようになった

Active Storageでアップロードしたファイルへのアクセスは次のような挙動だった

  1. まずクライアントがRailsサーバへリクエストを送る
  2. 時間制限(デフォルト5分)つきのファイルへのURLを生成してリダイレクトする
  3. ファイルをダウンロードする

この手法は制限付きのリンクがいい感じに生成されてべんりなのだけど、広く公開して問題ないようなファイルだと無駄が多い。

↓のようにするとファイルの中身を直接返すURLを生成する(設定でデフォルトの挙動を変更することもできる)。

<%= image_tag rails_storage_proxy_path(@user.avatar) %>

Active Storageのサムネイル管理方法の変更と改善

これまで、Active Storageでサムネイルを表示するときには次のような挙動になっていた(ストレージがS3だと仮定)

  1. サムネイルがS3に存在するかどうかをチェック
  2. 存在しなければ元画像をダウンロードしてサムネイルを作りS3にアップロードする
  3. サムネイルの画像用のURLを作成する
  • この方式は次の問題があった
    • 毎回S3にサムネイルが存在するか確認しにいくので、その分レスポンスを返すまでに時間がかかる
    • S3の仕様的に、存在確認後にすぐアップロードすると、しばらくの間アップロードしたのにファイルがない、ということになる
      • たぶん存在確認の結果をしばらくキャッシュしてるんじゃないかな…
  • これを解決するために、サムネイルが存在しているかどうかを保持しておくテーブルが新設された
  • Rails.application.config.active_storage.track_variants = trueとすると新しいやりかたが有効になる

ActiveModel::Errorsの改善

ActiveModel::Errorsとは、user = User.new; user.errors のようにすると返ってくるオブジェクト

  • ActiveModel::Errorsは実質的に2つのHashオブジェクトから構成されている
    • messages
      • ex: {:email=>["を入力してください"]}
    • details
      • ex: {:email=>[{:error=>:blank}]}
  • Hashをゴニョゴニョするのがめんどくさいケースが有る
    • ex: 特定のメッセージに紐づくdetailを取得したいときに、messagesのindexを取得してdetails[:email][index]としなければいけない
  • 今回の修正により、ActiveModel::Errorsは実質的にActiveModel::Errorオブジェクト(新設)の集合となった
  • これによりエラー内容に対しての細かい操作が、よりオブジェクト指向っぽい書き方ができるようになった(Hashもオブジェクトではあるけどね)
    • 関連先のエラーもいい感じに表現できている(ActiveModel::Errorsのネストができる)
    • Errorsにwhereメソッドが生えた
      • model.errors.where(:name, :foo, bar: 3).first
    • messageに紐づくdetailが簡単に取得できるようになった
  • 非互換な変更なので対応が必要なものもある
    • book.errors[:title] << 'is not interesting enough.' のような、直接Hashをいじるような操作はdepricatedになった
      • book.errors.add(:title, 'is not interesting enough.') のように書く
  • book.errors.each do |attribute, error_message| ... は depreated
    • book.errors.each do |error| のように変更する
  • 関連モデルを大量に保存するようなアプリケーションだと、エラー内容の解析やらなんやらが楽になってよいのでは

クエリメソッドmissingの追加

関連先が存在しないレコードを取得したいとき、次のようなコードをかくと思います。

Post.left_joins(:author).where(authors: { id: nil })

これを次のように短縮して書けるようになりました

Post.where.missing(:author)

次のように引数に複数の関連先を書くこともできる(authorとcomments両方とも存在しないレコードが返る)

 Post.where.missing(:author, :comments)

check制約に対応した

add_check_constraint :products, "price > 0", name: "price_check"
remove_check_constraint :products, name: "price_check"

create_table :distributors do |t|
  t.string :zipcode
  t.check_constraint "zipchk", "char_length(zipcode) = 5"
end
  • MySQLの場合は8.0.16以降サポート
    • そもそもMySQLは8.0.16までcheck制約に対応していなかった
  • これまでも直接SQLを発行すればcheck制約を追加できたけど、その場合はschema.rbをやめてstructure.sqlにする必要があった

enumにデフォルト値を設定できるようになった

class Book < ActiveRecord::Base
  enum status: [:proposed, :written, :published], _default: :published
end

Book.new.status # => "published"

キーが_defaultなのは「既存のカラムにdefaultがあったときに壊れるから」とのこと

CSRFトークンのエンコード形式の変更

  • CSRFトークンのエンコード方式がbase64からurlsafe版のbase64に変更されました
    • base64はa-z,A-Z,0-9,+,/でエンコードする方式
    • +/はURLエンコードしないといけない→それぞれ-_に変更した
    • Rubyにはurlsafe版のbase64エンコード用メソッドがあるBase64.urlsafe_encode64
  • CSRFトークンをcookieとしてそのまま送りたいようなケースで、クライアント側でエンコード・デコードが必要になるのがだるいのでこうなったとのこと

僕らの生活的にはあまり影響はないのだけど、「6.1アップグレード時にCSRFトークンのエンコード方式が変わる」というのは影響がある

  • ローリングアップデートなどで6.0と6.1のサーバが混在しているタイミングがあると、エンコード形式とデコード形式が変わってCSRFトークンのチェックがうまくいかずにエラーになる
  • 6.1にアップグレードしたあとに障害があり、6.0にロールバックしたらエンコード形式とデコード形式が変わって(ry
  • 6.0形式でエンコードされたものを6.1でデコードすることはできるようになっている

Rails.application.config.action_controller.urlsafe_csrf_tokens = trueで挙動を切り替えることができるので、いったんfalseの状態で6.1にアップグレードし、折を見て一気にtrueにすればローリングアップデートでも大丈夫

cookie関連の設定を、ローリングアップデートでも適用できるようになった

cookieのシリアライズの設定をmarshalからhybridに変更するとき、hybridはjsonでシリアライズするので、ローリングアップデートするとjsonでシリアライズしたものをmarshal設定でデシリアライズしてエラーになる可能性がある

  • Rails5.1->5.2でcookiesの暗号化形式が変わった
    • Rails.application.config.action_dispatch.use_authenticated_cookie_encryption で設定可能
    • ↑を変更してローリングアップデートしようとすると、新しい暗号化形式で暗号化したものをで古い暗号化形式で復号化しようとしてエラーになる
  • 6.1では古い設定のサーバに新しい設定のリクエストがきてもよしなにしてくれるようになり、深く考えずにローリングアップデートできるようになった

ActiveRecordのmergeメソッドの挙動変更

  • Rails6.1までのmergeは同じカラムがmergeされるときに、Hashのように後勝ち(mergeの引数側が採用される)になるケースと、ならないケース(両方のクエリが統合される)がある
  • 後勝ちになったりならなかったりするのは意図的なものではないので後勝ちになるように修正する
  • 6.1では後勝ちにならないケースでdeprecateメッセージが表示され、6.2でデフォルト後勝ちになる
  • 6.1で6.2の挙動(後勝ちにしたければrewhereオプションを指定する)
# Rails 6.1 で後勝ちになるパターン(IN句)
Author.where(id: [david.id, mary.id]).merge(Author.where(id: bob)) # => [bob]

# Rails 6.1 で両方の指定が採用されるパターン
# where id between "davidのid" and "maryのid" and id = bobのid
Author.where(id: david.id..mary.id).merge(Author.where(id: bob)) # => []

# 6.1で6.2相当の挙動を使うにはrewhereを使う
Author.where(id: david.id..mary.id).merge(Author.where(id: bob), rewhere: true) # => [bob]

# Rails 6.2ではどのようなケースでも後勝ち
Author.where(id: [david.id, mary.id]).merge(Author.where(id: bob)) # => [bob]
Author.where(id: david.id..mary.id).merge(Author.where(id: bob)) # => [bob]

後勝ちにしない、を明示的にするためのメソッドも追加ずみ

david_and_mary = Author.where(id: [david, mary])
mary_and_bob   = Author.where(id: [mary, bob]) # => [bob]

david_and_mary.merge(mary_and_bob) # => [mary, bob]

david_and_mary.and(mary_and_bob) # => [mary]
david_and_mary.or(mary_and_bob)  # => [david, mary, bob]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.