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

Rails: Active Storageで知っておくべきアドバイス集(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

参考: Active Storage の概要 - Railsガイド

Rails: Active Storageで知っておくべきアドバイス集(翻訳)

今はカンファレンスの発表の準備中で、これまで私が積み重ねてきたアドバイスについて詳しく説明している余裕がありません。そういうわけで、いくつかのアドバイスを本記事にまとめておいて、スライドからリンクすることにします。

今日のお題はRuby on RailsのActive Storageです。Active Storageは、アプリケーションやデータベース・サーバーを詰まらせずに、写真や動画などのユーザー生成アセットを手軽に保存できる機能です。

Active Storageで何か作業するときは、その前に、Discussionにある以下の大変素晴らしい記事にぜひ目を通しておきましょう↓。

【保存版】Active Storageの内部詳細と、5年以上productionで運用して得た知見(翻訳)

この記事には、自分の足を銃で撃ち抜くような最も危険な部分を避けて、機能を最大限を活かす方法が事細かに書かれています。

それでは始めましょう。

🔗 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はよさそうです。

charkost/prosopite - GitHub

個別の添付ファイルには、メタプログラミングで生成されるマジックスコープがあります。私は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: バリアント処理を甘く見ないこと

プレビュー画像やバリアントを処理するには、帯域幅と計算リソースをかなり必要とします。デフォルトではvipsffmpegが必要になります(Herokuではactivestorage-preview buildpackを追加することになります)。

🔗 皆さんもいつか問題を踏むでしょう

私がActive Storageを数か月使ってみたところ、10数件のバグを踏みました。私も忙しい身なので、修正できたのはその中の1個か2個のバグだけで↓、ほとんどは回避する形で対応しました。

私からの一般的なアドバイスは、Active Storageが絡んでくるときは、普段よりもさらに慎重に作業することです。

  1. アセットをバックアップするための戦略を立てておくこと。
    私の場合、前処理用のバックグラウンドワーカーによってproduction環境のアセットが吹っ飛んでしまったためにバックアップ戦略が必要になった経験が既にあります。

  2. クラウドプロバイダの容量がどのぐらい消費されるかを監査して、データベースで認識されなくなったblobキーを探し、必要なら削除すること。

  3. production環境でおかしなことが起きていないかどうかを定期的にテストすること。

問題の一部は、Active Storageがまだ広く使われていないことに起因しています。特に、プレビュー可能な添付ファイルのうち、画像と画像以外のもの(PDFや動画など)を区別しようとする箇所のコードが頻繁に変更されたことが原因です。
問題によっては、問題となる領域が本質的にリソース食いなせいで障害が頻発していることが原因となっています。GLHF(Good luck, have fun: 幸運を祈ります、せいぜい楽しんでください)。

関連記事

ActiveStorageでアップロードしたファイルとプレビュー画像に認証をかける


CONTACT

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