背景
-
ActiveStorage でアップロードしたファイルのダウンロードURLを発行したい、というシンプルな要望が来た
-
URL 生成のヘルパーを把握するついでに、ActiveStorage の Blob / Attachment / Attached という3層構造も学び直したのでまとめる
ActiveStorage の 3 層構造
ActiveStorage は「ファイル本体」と「紐付け情報」を分けて管理する
| 名前 | 役割 | DB上の表現 |
|---|---|---|
| Blob | ファイル本体の情報を持つ |
active_storage_blobs テーブル |
| Attachment | アプリのレコードと Blob を繋ぐ中間テーブル |
active_storage_attachments テーブル |
| Attached | アプリ側から操作する窓口オブジェクト | DB上の実体なし(関連メソッド経由) |
[アプリのモデル] [中間テーブル] [ファイル管理]
UserAttachment ──── ActiveStorage::Attachment ──── ActiveStorage::Blob
│
↓
実ファイル(S3 など)
なぜ本体とメタ情報を分けるのか
- バイナリデータを DB に入れると DB が肥大化して遅くなる
- 検索や絞り込みは DB 側でやりたい
- ファイル本体は専用のストレージサービス(S3 など)に置く方が効率的
なぜ中間テーブルが必要なのか
- 同じ Blob を複数モデルで使い回せる
- 1つのモデルに複数種類の添付(アバター / ヘッダー / …)をカラム追加なしで持てる
- どのレコードに何の名前で紐付いているかを柔軟に管理できる
ActiveStorage::Attachment の主要カラム:
| カラム | 内容 |
|---|---|
name |
"data_file" など、紐付けの役割名 |
record_type |
"UserAttachment" などモデル名 |
record_id |
紐付くレコードのID |
blob_id |
どの Blob を参照しているか |
has_one_attached :data_file が生やすもの
1行書くと、Rails が裏で関連メソッドを自動生成する
class UserAttachment < ApplicationRecord
has_one_attached :data_file
end
| メソッド | 返り値 | 用途 |
|---|---|---|
record.data_file |
Attached オブジェクト(窓口) | 普段の操作(filename取得、attach、url生成等) |
record.data_file_attachment |
ActiveStorage::Attachment レコード |
中間テーブルへの直接アクセス |
record.data_file_blob |
ActiveStorage::Blob レコード |
Blob レコードへの直接アクセス |
has_one_attached :〇〇 と書くと、自動的に 〇〇_attachment と 〇〇_blob という関連名が作られる
data_file メソッドが返す Attached オブジェクトは DB に対応する実体を持たず、ファイル操作の窓口として機能する。filename / url / attach / purge などの便利メソッドはここから生えている
ダウンロードURL生成の3つのヘルパー
ActiveStorage は URL 生成用のヘルパーを複数提供している。用途に応じて使い分ける
| ヘルパー | 返り値 | 主な用途 |
|---|---|---|
rails_blob_url(blob) |
フルURL(https://...) |
メール・外部通知・Slack投稿など、アプリ外に出すURL |
rails_blob_path(blob) |
パスのみ(/rails/...) |
自分のアプリ内のビューで <a href="..."> に埋め込む |
url_for(blob) |
デフォルトはパス、only_path: false でフルURL |
汎用ヘルパー。型に応じてポリモーフィックに解決 |
rails_blob_url
url = rails_blob_url(attachment.data_file)
# => "https://example.com/rails/active_storage/blobs/redirect/eyJfcmFpb.../report.pdf"
- フルURLが要る場面で使う
- 内部にホスト名を含めるため、
Rails.application.routes.default_url_options[:host]かconfig.action_mailer.default_url_optionsでホスト設定が必須 - 設定が抜けると
ArgumentError: Missing host to link to!で落ちる
rails_blob_path
path = rails_blob_path(attachment.data_file)
# => "/rails/active_storage/blobs/redirect/eyJfcmFpb.../report.pdf"
- ホスト設定不要なので楽
- 外部送信(メールや外部通知)には使えない
url_for(落とし穴あり)
url_for(attachment.data_file)
# => "/rails/active_storage/blobs/redirect/.../report.pdf" ← パス!
Rails 公式ドキュメントによれば url_for のデフォルトは only_path: true。つまりビュー内で呼ぶとパスを返す。フルURLが欲しい場合は明示する:
url_for(attachment.data_file, only_path: false)
url_for(attachment.data_file, host: 'example.com')
url_for がそもそも Blob を受け取れるのは、ActiveStorage 側がポリモーフィックルーティングに対応しているため。内部的には rails_blob_path / rails_blob_url に解決される
ただし「動く」のと「分かりやすい」のは別問題。ActiveStorage 専用の場面では rails_blob_url / rails_blob_path を直接呼ぶ方が意図が明確。url_for は引数の型で挙動が変わるため、コードを読む人が「これ何を返す?」と迷う
URLの構造
https://example.com/rails/active_storage/blobs/redirect/eyJfcmFpb.../report.pdf
└──────┬──────┘ └──────────────┬──────────────────┘ └──┬──┘ └────┬───┘
① ② ③ ④
| 部位 | 中身 | 役割 |
|---|---|---|
| ① ホスト | アプリのドメイン | どのRailsに問い合わせるか |
| ② パス | /rails/active_storage/blobs/redirect/ |
ActiveStorage 専用ルート |
| ③ 署名トークン | 長い英数字列 | Blob IDと有効期限を暗号化した署名 |
| ④ ファイル名 | report.pdf |
ブラウザのダウンロード表示用 |
③の署名トークンに「どのBlobか」「いつまで有効か」が暗号化されて入っている。改ざんしたら検証で弾かれる。これが「署名付きURL」と呼ばれる所以。
redirect モードの動き(デフォルト)
ユーザーが上のURLをクリックすると、こうなる:
- Rails が署名を検証、有効期限チェック
- Blob から S3 の保存先キーを取得
- S3 に対して短期署名付きURLを発行
- ブラウザに「S3のURLにリダイレクトせよ」と返す(HTTP 302)
- ブラウザは S3 のURLに再アクセスしてダウンロード
つまり Rails サーバーは「鍵」を渡すだけで、実ファイルは S3 から直接流れる設計。Rails サーバーに負荷がかからない
URL生成の実装上の注意
concern/ジョブ内ではフルパスで呼ぶ
URL ヘルパーは Rails.application.routes.url_helpers モジュールに住んでいる。コントローラやビューでは Rails が自動で include しているが、モデル・ジョブ・concern には include されていない
# ❌ そのままではエラー: undefined method `rails_blob_url`
rails_blob_url(attachment.data_file)
# ✅ フルパスで呼ぶ
Rails.application.routes.url_helpers.rails_blob_url(attachment.data_file)
concern で何度も使うなら include する手もある:
module Linkable
extend ActiveSupport::Concern
include Rails.application.routes.url_helpers
end
ホスト設定
フルURLを生成するには、Rails に自分のホスト名を教えておく必要がある。
# config/environments/production.rb
Rails.application.routes.default_url_options[:host] = 'example.com'
Rails.application.routes.default_url_options[:protocol] = 'https'
URLの有効期限
デフォルトの署名URL有効期限は5分。ジョブで生成→数時間後にユーザーがクリック、というケースでは短すぎることがある
# config/initializers/active_storage.rb
Rails.application.config.active_storage.urls_expire_in = 12.hours
期限の設計判断:
| 設定値 | 利点 | 欠点 |
|---|---|---|
| 5分(デフォルト) | URL漏洩リスク最小 | 非同期通知経由で切れることがある |
| 12時間 | 半日以内のクリックに対応 | 漏れたら半日アクセス可能 |
| 7日 | 長期メールでも対応 | リスク大、推奨せず |
業務系アプリなら 1〜24 時間 が現実的な範囲
N+1 問題と includes
添付ファイル付きのレコードを一覧表示するとき、何も考えずに書くと N+1 問題が発生する
includes なしの場合(添付が3個ある時):
クエリ1: 添付ファイル一覧(3件)
クエリ2: 添付1の中間テーブル
クエリ3: 添付1の Blob
クエリ4: 添付2の中間テーブル
...
→ 添付数に比例してクエリが増える
includes(data_file_attachment: :blob) を入れると:
クエリ1: 添付ファイル一覧
クエリ2: 関連する中間テーブルを一括取得
クエリ3: 関連する Blob を一括取得
3クエリで完結する
includes には DB 上の関連名を指定する
# ✅ 正しい
.includes(data_file_attachment: :blob)
# ❌ 動かない
.includes(data_file: :blob)
data_file は窓口メソッド(Attached オブジェクトを返す Ruby メソッド)であり、ActiveRecord の関連としては存在しない。includes は関連を辿る機能なので、DB 上に存在する関連名を指定する必要がある
具体的には data_file_attachment(中間テーブルへの has_one 関連)と、data_file_blob(through 経由で Blob を取る関連)の2つが自動定義されている
まとめ
- ActiveStorage は「ファイル本体」と「紐付け情報」を分けて管理する3層構造(Blob / Attachment / Attached)
-
has_one_attached :data_file1行で、窓口メソッド + 中間テーブル関連 + Blob 関連が自動生成される - ダウンロードURL生成は用途別に3つのヘルパーがある
- 外部送信なら
rails_blob_url(フルURL) - アプリ内ビューなら
rails_blob_path(パス) -
url_forはデフォルトonly_path: trueなので、フルURLが欲しい場合は明示が必要
- 外部送信なら
- concern/ジョブ内で URL ヘルパーを呼ぶときは
Rails.application.routes.url_helpers.を前置 - 一覧表示のN+1対策には
includes(data_file_attachment: :blob)を使う(窓口メソッド名data_fileは使えない)
感想
has_one_attached の裏で何が起きてるのか、ずっとフワッとした理解のままだったので良かった