Rails: Active Storageで知っておくべきアドバイス集(翻訳)
今はカンファレンスの発表の準備中で、これまで私が積み重ねてきたアドバイスについて詳しく説明している余裕がありません。そういうわけで、いくつかのアドバイスを本記事にまとめておいて、スライドからリンクすることにします。
今日のお題はRuby on RailsのActive Storageです。Active Storageは、アプリケーションやデータベース・サーバーを詰まらせずに、写真や動画などのユーザー生成アセットを手軽に保存できる機能です。
Active Storageで何か作業するときは、その前に、Discussionにある以下の大変素晴らしい記事にぜひ目を通しておきましょう↓。
この記事には、自分の足を銃で撃ち抜くような最も危険な部分を避けて、機能を最大限を活かす方法が事細かに書かれています。
それでは始めましょう。
🔗 1: 添付ファイルは個別にモデルでラップすること
ある添付ファイルが、モデルの他の機能(振る舞いやバリデーションなど)をいつ必要とするか、事前に予測しようがありません。そこで、私は添付ファイルの種類ごとにモデルにラップすることで、将来に備えています。
サンプルを以下に示します。このモデルは、私が手がけているBuild with Beckyという筋トレ動画サービスの個別の動画の添付ファイルを表しています。
module Build
class MovementVideo < ApplicationRecord
include Attachable
belongs_to :movement, touch: true
end
end
🔗 2: 添付ファイルの名前とバリアントとバリデーションを統一すること
上のクラスは中身が空ですが、その理由は、添付ファイルの基本的な操作を行うAttachable
というconcernを別途作ってあるからです。
module Attachable
extend ActiveSupport::Concern
included do
has_one_attached :file, dependent: :purge_later do |attachable|
attachable.variant :preview, resize_to_fill: [400, 400], preprocessed: true
attachable.variant :still, format: "jpg", resize_to_limit: [2000, 2000], saver: {quality: 85}, preprocessed: true
end
validate :file_seems_legit
def preview_image_representation(process: false, variant: :preview)
return if file.blank?
representation = file.representation(variant)
representation = representation.processed if process
representation
rescue ActiveStorage::Preview::UnprocessedError
nil
end
def feed_ready?
file.attached? &&
file.representation(:preview).key.present? &&
file.representation(:still).key.present?
rescue ActiveStorage::Preview::UnprocessedError
false
end
# バリアントを再処理するジョブがエンキューされるようにする
# `force: true`を指定しない限り、バリアントが未処理の場合にのみ再処理を行う
def reprocess_variants!(force: true)
file_attachment.send(:named_variants).each do |name, named_variant|
if named_variant.preprocessed?(self) && (force || file.representation(name).key.blank?)
file_attachment.blob.preprocessed(named_variant.transformations)
end
end
end
def video?
file.attached? && file.video?
end
def media_type
if file.video?
:video
elsif file.image?
:image
end
end
def aspect_ratio
return unless file.metadata.key?(:width) && file.metadata.key?(:height)
file.metadata[:width] / file.metadata[:height].to_d
end
def file_seems_legit
if !file.attached?
errors.add(:file, "must be attached")
elsif !file.content_type.match?(/^(image|video)\//)
errors.add(:file, "must be an image or video")
elsif file.image? && file.byte_size >= 8.megabytes
errors.add(:base, "images must be smaller than 8 MB")
elsif file.video? && file.byte_size >= 1.gigabyte
errors.add(:base, "videos must be smaller than 1 GB")
end
end
end
end
このconcernでいろんな処理が行われているのは明らかですね。主な処理は以下のとおりです。
has_one_attached
を呼び出している。
このときhas_many_attached
は絶対に使わないこと。Movement
モデルで複数の動画が必要になったら、代わりにMovementVideo
モデルを作ってそちらに対してhas_many
を指定すること。私を信じてください。-
:file
で添付ファイル名に名前を付けている。
さまざまな種類の添付ファイルがすべてfile
というファイル名になっていることを前提にできると、驚くほど便利であることを知りました。 -
バリアントのセットを統一的な形で定義してから、前処理を非同期でスケジューリングしている。
-
基本的なpresenceルール、typeルール、sizeルールについてバリデーションを行っている。
-
添付ファイルが画像か動画かがすぐわかるようになっている。
-
feed_ready?
メソッドは、添付ファイルのバリアントが処理済みかどうかをチェックするときに大変便利です。
処理は非同期で、(特に巨大な動画では)処理に時間がかかる可能性があるので、このメソッドを使えば、未処理の添付ファイルをスキップすることも、アプリケーションサーバー上で同期的な処理がトリガーされる(これは非常に良くありません)リスクを回避することも可能になります。
ほどんどのアプリでは、これらの処理内容を個別の添付ファイルごとに変更する正当な理由がある可能性は低いでしょう。これらの処理があらゆる場所で重複していると、重要な処理(サイズ上限のバリデーションなど)を見落としてしまうリスクが生じます。
🔗 3: ダイレクトアップロードを有効にすること
Active Storageのダイレクトアップロードは、ほとんどの場合に有効にしておきたい機能です。有効にすることで、ユーザー接続が不安定な状況で8GBもある動画ファイルをアップロードしてもアプリケーションサーバーが詰まらないようにできます。
また、ダイレクトアップロードを実際に有効にすると、送信後のモデル永続化でバリデーションが失敗すると、アップロードした添付ファイルは再レンダリング時にデフォルトで孤立ファイルになってしまいます。これはマズい状態です。
これを避けるには、私が編み出したハックを用いて、hidden入力と、正確な添付ファイル名を持つfile入力をフォームに表示する方法があります。
以下の_direct_upload_file_field.html.erb
パーシャルは、再送信時にファイル添付が失われないようにしつつ、別の添付ファイルも選択できるようにします。
<%# 何ともバカバカしいことですが、direct_uploadでファイルをアップロードしてから
サーバー側でバリデーションや保存に失敗すると、再レンダリング時にblob IDをチェックしたり、フォームのhiddenフィールド内に再度埋め込んだりするのは
アプリケーション開発者の責任になります。
ありがたいことに、これは2つの入力(thisフィールドとfileフィールド)が同じ名前でも動作します。%>
<%= f.file_field name, direct_upload: true, **local_assigns[:input_options] %>
<% if f.object.new_record? && f.object.send(name).blob.present? %>
<%= f.hidden_field name, id: nil, value: f.object.send(name).blob.signed_id %>
<% unless local_assigns[:hide_reassurance] %>
<span class="font-bold text-danger">
Don't worry we haven't lost your upload of <span class="font-mono"><%= f.object.file.blob.filename %></span>, you don't need to to upload it again
</span>
<% end %>
<% end %>
🔗 4: あらゆるものをincludes
しておくこと
N+1クエリ問題がキライな方は、添付ファイルを参照するルーティングをすべて監査することを習慣づけておく方がよいでしょう。Prosopiteというgemはよさそうです。
個別の添付ファイルには、メタプログラミングで生成されるマジックスコープがあります。私はfile
という名前を使うようにしているので、そのおかげでMovementVideo.with_attached_file
のようにメソッド名の末尾が常にfile
で終わるようになります。もちろん、普段の私はネステッドモデルのさまざまなレイヤから読み込んでいるので、このヘルパーは、Arelのmerge
メソッドをよほど多用するのでない限りさほど役に立ちません。そこで私は、includes
に渡す必要のあるネストの深いすべてのハッシュを組み立てるために、以下のような独自のヘルパーを書きました。
# app/lib/includes_hashes.rb
module IncludesHashes
# ここはwith_all_variant_recordsから切り出した
# https://github.com/rails/rails/blob/f4a9b7618fc32f0d3b2c0ff03a3f34f4964cc553/activestorage/app/models/active_storage/attachment.rb#L45
INCLUDES_WITH_ATTACHED_FILE = {
file_attachment: {
blob: {
variant_records: {image_attachment: :blob}
}
}
}.freeze
INCLUDES_WITH_ATTACHED_FILE_PREVIEW = {
file_attachment: {
blob: {
preview_image_attachment: {blob: {variant_records: {image_attachment: :blob}}}
}
}
}.freeze
def self.includes_with_attached_file_hash(image: false)
INCLUDES_WITH_ATTACHED_FILE.deep_merge(
image ? {} : IncludesHashes::INCLUDES_WITH_ATTACHED_FILE_PREVIEW
)
end
end
こうすることで、以下のようなincludes
を組み立てて、N+1のリスクなしで添付ファイルやバリアントをeager-loadingできるようになります。
Movement.includes(
video: IncludesHashes.includes_with_attached_file_hash,
thumbnail: IncludesHashes.includes_with_attached_file_hash(image: true),
)
🔗 5: アプリサーバーをバイパスするためにCDNへのリンクを生成すること
productionのサーバーでプロキシモードやリダイレクトモードを使うのは、どうやらBad Idea™のようです。
プロキシモードにするとアプリケーションサーバーがブロックされ、リダイレクトしたときにSafariでおかしなバグが発生します。これは、1ページ内に表示される100個の画像がすべてリダイレクトで解決されるような状況では、非常に厄介な問題になる可能性があります。
個人的に好きな方法は、アセットのCDNを指すURLを生成する方法です。私はAmazon Cloudfrontを使っていて、その背後にはAmazon S3のバケットがあります。
これらをすべて正しく設定できる、正しいけど謎めいた方法が存在します。しかしDevOpsコンサルタントたる私にとってはどうでしょうか?
サーバー側でアセットを配信していないので、Rails側ではほとんど何も設定する必要はありません。私は8か月前に以下のルーティングを定義していたのですが、本記事を書くまでそのことを忘れていました。
# config/routes.rb
direct :public_cdn do |representation, options|
if Rails.configuration.active_storage.service == :amazon
"https://#{ENV["CDN_HOST"]}/#{representation.key}"
else
url_for(representation)
end
end
この方法によって、development環境とproduction環境の両方で正しく解決されるURLをpublic_cdn_url(attachment_holder.file)
で生成できるようになります。
🔗 6: バリアント処理を甘く見ないこと
プレビュー画像やバリアントを処理するには、帯域幅と計算リソースをかなり必要とします。デフォルトではvips
とffmpeg
が必要になります(Herokuではactivestorage-preview buildpackを追加することになります)。
🔗 皆さんもいつか問題を踏むでしょう
私がActive Storageを数か月使ってみたところ、10数件のバグを踏みました。私も忙しい身なので、修正できたのはその中の1個か2個のバグだけで↓、ほとんどは回避する形で対応しました。
- Eagerly load preview images (N+1) by tenderlove · Pull Request #50758 · rails/rails
- Fixes race condition for multiple preprocessed video variants by searls · Pull Request #51030 · rails/rails
私からの一般的なアドバイスは、Active Storageが絡んでくるときは、普段よりもさらに慎重に作業することです。
- アセットをバックアップするための戦略を立てておくこと。
私の場合、前処理用のバックグラウンドワーカーによってproduction環境のアセットが吹っ飛んでしまったためにバックアップ戦略が必要になった経験が既にあります。 -
クラウドプロバイダの容量がどのぐらい消費されるかを監査して、データベースで認識されなくなったblobキーを探し、必要なら削除すること。
-
production環境でおかしなことが起きていないかどうかを定期的にテストすること。
問題の一部は、Active Storageがまだ広く使われていないことに起因しています。特に、プレビュー可能な添付ファイルのうち、画像と画像以外のもの(PDFや動画など)を区別しようとする箇所のコードが頻繁に変更されたことが原因です。
問題によっては、問題となる領域が本質的にリソース食いなせいで障害が頻発していることが原因となっています。GLHF(Good luck, have fun: 幸運を祈ります、せいぜい楽しんでください)。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
参考: Active Storage の概要 - Railsガイド