今更ながらActiveStrageの仕組みとか使い方とかを動かしながらちょっと勉強したのでメモ📝
ActiveStorageとは
Active Storageは、Amazon S3、Google Cloud Storage、Microsoft Azure Storageなどのクラウドストレージサービスへのファイルのアップロードや、ファイルをActive Recordオブジェクトにアタッチする機能を提供します。
Active Storage の概要 - Railsガイド
Rails 5.2で導入された簡単にファイルアップデート/参照が実装出来る機能です。
※2021/11/20現在、6.1.4のガイドよりも以下のedgeガイドのほうが内容がかなり充実しているので、edgeガイドを参照したほうが良いきがした。
edgeguides.rubyonrails.org
ActiveStrageの基本的な使い方
使い方は簡単でapplication.rb
でコア機能がrequireされていることを確認し、
require "active_storage/engine"
利用するサービスに合わせてstorage.yml
を作成
local:
service: Disk
root: <%= Rails.root.join("storage") %>
amazon:
service: S3
access_key_id: ""
secret_access_key: ""
bucket: ""
region: ""
各種環境に利用するストレージサービスを設定
Rails.application.configure do
config.active_storage.service = :local
end
以下のコマンドを実行し依存するtableを作成すれば準備OKです。
$ bin/rails active_storage:install
※S3等のクラウドストレージを利用する場合には対象のgemを、ダイレクトアップロードが必要な場合は、activestorage - npm、画像変換等を行う場合には、libvips等を適宜installしてください。詳しくはガイドを御覧ください。
あとは該当のmodelに画像添付用のDSLを記載することで、
class User < ApplicationRecord
has_one_attached :avatar
end
以下のような感じで画像のアップロードを参照を行う事ができます。
class UsersController < ApplicationController
def new
@user = User.new
end
def show
@user = User.find(params[:id])
end
def create
@user = User.new(user_params)
if @user.save
redirect_to user_path(@user)
else
render :new
end
end
private
def user_params
params.require(:user).permit(:email, :avatar)
end
end
<div class="user-new">
<%= form_with model: @user, method: :post, url: users_path do |f| %>
<%= f.text_field :email %>
<%= f.file_field :avatar %>
<%= f.submit %>
<% end %>
</div>
<%= @user.email %>
<%= image_tag url_for(@user.avatar) %>
ActiveStrageの仕組み
データ構造
ActiveStrageは以下の3つのtableを利用します。
- active_storage_attachments : アタッチ対象のテーブルとblobとのリレーションを定義
- active_storage_blobs: ファイル本体を表すテーブル
- (active_storage_variant_records) : 変換処理等に使用するが大枠には関与しないため一旦今回の記載では扱わない
それぞれの関係は以下の通りです
アタッチ対象のtable 1-N active_storage_attachments 1-1 active_storage_blobs
inspectで見るとどういうデータが格納されるのか分かりやすいです
$ User.last.avatar
@name="avatar",
@record=
id: 2,
email: "mail_234@example.com",
secret_digest: "[FILTERED]",
verified: true,
created_at: Sat, 20 Nov 2021 15:01:04.745761000 JST +09:00,
updated_at: Sat, 20 Nov 2021 15:03:49.002289000 JST +09:00>>
$ User.last.avatar.blob
id: 1,
key: "q71m490swybfjgnqpto4egnl1s86",
filename: "sprite.png",
content_type: "image/png",
metadata: {"identified"=>true, "analyzed"=>true},
service_name: "local",
byte_size: 1655,
checksum: "CSG/eyd0ZL4dTU/ccUUEYA==",
created_at: Sat, 20 Nov 2021 15:01:04.787685000 JST +09:00>
ファイルのアップロードの流れ
実際にアップロードの流れを追ってみます。
まずはmodelにhas_one_attached
使うと、定義したname(例: avator)で以下が定義されます。
- getter/setter
- active_storage_attachmentsとactive_storage_blobsのassociation
- active_storage_attachments, active_storage_blobsをeager lodingするscope
- 以下のcallback
- after_save: attachment_changesにアップロード・削除等用のClassのインスタンスを格納
- after_commit: attachment_changesから該当のkeyで取得し、uploadが実行できれば実行
github.com
実際にアップロード処理を行なっている処理をみてみます。以下のコードの通り基本的には添付ファイルをもとにActiveStorage::Blob
のインスタンスを作成し、service
のupload処理を呼び出しています。
module ActiveStorage
class Attached::Changes::CreateOne
def upload
case attachable
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
blob.upload_without_unfurling(attachable.open)
when Hash
blob.upload_without_unfurling(attachable.fetch(:io))
end
end
class ActiveStorage::Blob < ActiveStorage::Record
def upload_without_unfurling(io)
service.upload key, io, checksum: checksum, **service_metadata
end
各サービスのupload処理を実行してファイルを格納します。
module ActiveStorage
class Service::DiskService < Service
def upload(key, io, checksum: nil, **)
instrument :upload, key: key, checksum: checksum do
IO.copy_stream(io, make_path_for(key))
ensure_integrity_of(key, checksum) if checksum
end
end
module ActiveStorage
class Service::S3Service < Service
attr_reader :client, :bucket
attr_reader :multipart_upload_threshold, :upload_options
def initialize(bucket:, upload: {}, public: false, **options)
@client = Aws::S3::Resource.new(**options)
@bucket = @client.bucket(bucket)
@multipart_upload_threshold = upload.delete(:multipart_threshold) || 100.megabytes
@public = public
@upload_options = upload
@upload_options[:acl] = "public-read" if public?
end
def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
instrument :upload, key: key, checksum: checksum do
content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
if io.size < multipart_upload_threshold
upload_with_single_part key, io, checksum: checksum, content_type: content_type, content_disposition: content_disposition
else
upload_with_multipart key, io, content_type: content_type, content_disposition: content_disposition
end
end
end
ファイルの参照の流れ
url_for
参照する際にはurl_for
にActiveStorage::Attached
のインスタンスを渡すとデフォルトでは以下のようなURLが帰ってきます。
http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--27f4ef7c0fa6e180d8384cbb4a408db03d58a344/sprite.png
上記のURLはroutes上以下の定義となっており、active_storage/blobs/redirect#show
が呼ばれます。
rails_service_blob_path GET /rails/active_storage/blobs/redirect/:signed_id/*filename(.:format)
active_storage/blobs/redirect#show
active_storage/blobs/redirect#show
では@blob.url
にリダイレクトします。
※リダイレクトを回避するには後述のrails_storage_proxy_path
を参照
class ActiveStorage::Blobs::RedirectController < ActiveStorage::BaseController
include ActiveStorage::SetBlob
def show
expires_in ActiveStorage.service_urls_expire_in
redirect_to @blob.url(disposition: params[:disposition])
end
end
ActiveStorage::Blob#url
はserviceのurlを呼び出します。
class ActiveStorage::Blob < ActiveStorage::Record
def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
service.url key, expires_in: expires_in, filename: ActiveStorage::Filename.wrap(filename || self.filename),
content_type: content_type_for_serving, disposition: forced_disposition_for_serving || disposition, **options
end
下記の通りpublicオプションに応じて、各serviceのpublic_url/private_urlが呼ばれます。
module ActiveStorage
class Service
def url(key, **options)
instrument :url, key: key do |payload|
generated_url =
if public?
public_url(key, **options)
else
private_url(key, **options)
end
payload[:url] = generated_url
generated_url
end
end
各サービスではpublic_url/private_urlの実装は以下のとおりです。どちらでも最終的にはURLの文字列が返却されてファイルを取得出来るというわけですね。
module ActiveStorage
class Service::DiskService <
def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
generate_url(key, expires_in: expires_in, filename: filename, content_type: content_type, disposition: disposition)
end
def public_url(key, filename:, content_type: nil, disposition: :attachment, **)
generate_url(key, expires_in: nil, filename: filename, content_type: content_type, disposition: disposition)
end
module ActiveStorage
class Service::S3Service < Service
def private_url(key, expires_in:, filename:, disposition:, content_type:, **client_opts)
object_for(key).presigned_url :get, expires_in: expires_in.to_i,
response_content_disposition: content_disposition_with(type: disposition, filename: filename),
response_content_type: content_type, **client_opts
end
def public_url(key, **client_opts)
object_for(key).public_url(**client_opts)
end
rails_storage_proxy_path
またRails 6.1で追加されたrails_storage_proxy_path(@user.avatar)
を使用するとurl_forで発生していたリダイレクト処理を防ぐことが出来ます。処理としては以下の通りactive_storage/blobs/proxy#show
が実行されます。
rails_service_blob_proxy_path GET /rails/active_storage/blobs/proxy/:signed_id/*filename(.:format)
active_storage/blobs/proxy#show
active_storage/blobs/proxy#show
はblobのダウンロードが実行され、各サービスで定義されたdownloadが呼ばれてファイルがダウンロードされ、データがブラウザに送られます。
※リダイレクトは発生しないが、直接ファイルを閲覧できるURLが生成されるため注意
class ActiveStorage::Blobs::ProxyController < ActiveStorage::BaseController
include ActiveStorage::SetBlob
def show
if request.headers["Range"].present?
send_blob_byte_range_data @blob, request.headers["Range"]
else
http_cache_forever public: true do
response.headers["Accept-Ranges"] = "bytes"
response.headers["Content-Length"] = @blob.byte_size.to_s
send_blob_stream @blob
end
end
end
module ActiveStorage::
def send_blob_stream(blob, disposition: nil)
send_stream(
filename: blob.filename.sanitized,
disposition: blob.forced_disposition_for_serving || disposition || DEFAULT_BLOB_STREAMING_DISPOSITION,
type: blob.content_type_for_serving) do |stream|
blob.download do |chunk|
stream.write chunk
end
end
end
class ActiveStorage::Blob < ActiveStorage::
def download(&block)
service.download key, &block
end
module ActiveStorage
class Service::DiskService < Service
def download(key, &block)
if block_given?
instrument :streaming_download, key: key do
stream key, &block
end
else
instrument :download, key: key do
File.binread path_for(key)
rescue Errno::ENOENT
raise ActiveStorage::FileNotFoundError
end
end
end
module ActiveStorage
class Service::S3Service < Service
def download(key, &block)
if block_given?
instrument :streaming_download, key: key do
stream(key, &block)
end
else
instrument :download, key: key do
object_for(key).get.body.string.force_encoding(Encoding::BINARY)
rescue Aws::S3::Errors::NoSuchKey
raise ActiveStorage::FileNotFoundError
end
end
end
CDNからファイルを配信するときのtipsは以下に記載されています。
https://edgeguides.rubyonrails.org/active_storage_overview.html#putting-a-cdn-in-front-of-active-storage
おわりに
ActiveStrageでファイルをアップロードするとてっきりアップロード先のURLを取得しストレージサービスに直接リクエストが行くのかと思っていたのですが、一度Railsを経由して配信されるんですねー(知らなかった)
確かに一度Railsを経由することでS3のprivateなバケットからファイルを取得して公開等が出来るんですね。
Railsでリクエストを受け付ける点は注意ですが、ActiveStrage便利ですね✨
参考
blog.takeyuweb.co.jp
https://edgeguides.rubyonrails.org/active_storage_overview.htm