今更ながら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ガイドを参照したほうが良いきがした。
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: "" # e.g. 'us-east-1'
各種環境に利用するストレージサービスを設定
# development.rb 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 # 複数添付する場合は以下 # has_many_attached :avatars 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
<!-- users/new --> <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> <!-- users/show --> <%= @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 #<ActiveStorage::Attached::One:0x00007f918fae33f0 @name="avatar", @record= #<User:0x00007f918d780868 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 #<ActiveStorage::Blob:0x00007f9191124470 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
実際にアップロード処理を行なっている処理をみてみます。以下のコードの通り基本的には添付ファイルをもとにActiveStorage::Blob
のインスタンスを作成し、service
のupload処理を呼び出しています。
# https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/lib/active_storage/attached/changes/create_one.rb#L23 module ActiveStorage class Attached::Changes::CreateOne # :nodoc: def upload case attachable # アタッチ対象 # 普通にformからファイルをアップロードする場合は`ActionDispatch::Http::UploadedFile`になる。 when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile # ActiveStorage::Blobのインスタンスを生成し`upload_without_unfurling`を実行 blob.upload_without_unfurling(attachable.open) when Hash blob.upload_without_unfurling(attachable.fetch(:io)) end end # https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/app/models/active_storage/blob.rb#L246-L248 class ActiveStorage::Blob < ActiveStorage::Record def upload_without_unfurling(io) # :nodoc: service.upload key, io, checksum: checksum, **service_metadata end
各サービスのupload処理を実行してファイルを格納します。
# Disk # https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/lib/active_storage/service/disk_service.rb#L19 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)) # keyで指定したfolderにファイルを作成する ensure_integrity_of(key, checksum) if checksum end end # S3 # https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/lib/active_storage/service/s3_service.rb 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 # keyで指定bucketのobjectを作成しPUT 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
を参照
# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/controllers/active_storage/blobs/redirect_controller.rb class ActiveStorage::Blobs::RedirectController < ActiveStorage::BaseController include ActiveStorage::SetBlob def show expires_in ActiveStorage.service_urls_expire_in # デフォルト5分 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が呼ばれます。
# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service.rb#L112-L125 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の文字列が返却されてファイルを取得出来るというわけですね。
# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/disk_service.rb#L104-L110 # Disk # file名をもとにrails_disk_service_pathが呼ばれる。(ex: http://localhost:3000/rails/active_storage/disk/eyJfcmFpbH...281/sprite.png) 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 # https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/s3_service.rbhttps://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/s3_service.rb#L99-L107 # S3 # private_url: keyをもとに取得したobjectの署名付きURL(期限付き)で取得される。 # https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html # public_url : keyをもとに取得したobjectの公開URLが取得される。 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が生成されるため注意
# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/controllers/active_storage/blobs/proxy_controller.rb 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 # https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/controllers/concerns/active_storage/streaming.rb#L60 module ActiveStorage:: def send_blob_stream(blob, disposition: nil) # :doc: 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| # blobのダウンロードを実行 stream.write chunk end end end # https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/models/active_storage/blob.rb#L252 class ActiveStorage::Blob < ActiveStorage:: def download(&block) service.download key, &block end # Disk # https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/disk_service.rb#L26-L38 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) # fileをread rescue Errno::ENOENT raise ActiveStorage::FileNotFoundError end end end # S3 # https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/s3_service.rb 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) # keyからobjectを取得しgetしてbodyを取得 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便利ですね✨
参考
https://edgeguides.rubyonrails.org/active_storage_overview.htm