Madogiwa Blog

主に技術系の学習メモに使っていきます。

今更ながらActiveStorageの仕組みとか使い方とかメモ📝

今更ながらActiveStrageの仕組みとか使い方とかを動かしながらちょっと勉強したのでメモ📝

ActiveStorageとは

Active Storageは、Amazon S3Google 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: "" # 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
    • after_save: attachment_changesにアップロード・削除等用のClassインスタンスを格納
    • after_commit: attachment_changesから該当のkeyで取得し、uploadが実行できれば実行

github.com

実際にアップロード処理を行なっている処理をみてみます。以下のコードの通り基本的には添付ファイルをもとに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_forActiveStorage::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便利ですね✨

参考

blog.takeyuweb.co.jp

https://edgeguides.rubyonrails.org/active_storage_overview.htm