この記事を見てわかること
- ファイルアップロードの共通基盤として導入したActiveStorageについて
- CloudFrontの設定について
ActiveStorageとは(ざっくり)
- Rails5.2からRailsの標準としてサポートされたファイル添付ライブラリ
- ActiveRecordにファイルを一つまたは複数添付できる
- クラウドストレージサービスにファイルを簡単にアップロードできる
- on-demandでの画像の変換や動画、PDFのプレビューなども行える
ActiveStorage ~導入と構成~
↓を実行すると
rails active_storage:install
rails db:migrate
↓のスキーマが増えます
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.string "key", null: false
t.string "filename", null: false
t.string "content_type"
t.text "metadata"
t.bigint "byte_size", null: false
t.string "checksum", null: false
t.datetime "created_at", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
使用する時は
ActiveStorage::Attachment
とActiveStorage::Blob
を理解するのが大事!
- AttachmentはpolimorpicなN:Nの中間テーブル
- Blobはリソースファイルのメタデータ
ex) Userにファイルを一つ添付する
class User < ApplicationRecord
has_one_attached :avatar
end
Active Storageの他のRailsのattachment solutionsとの重要な違いはActive Recordによるbuilt-inのBlobとAttachmentモデルを使用すること。
つまり、既存のモデルにファイルのための追加のカラムを用意する必要がない。
polymorphic associationによりmodelとAttachmentを関連付けし、Blobの実態と紐付ける。
Blobはファイル名やcontent-typeなどのメタデータを保持し、ストレージ内でのidentifyなkeyを持つ。(S3だとkeyがそのままファイルのパスになる)
Blobはバイナリデータそのものは持たない。Blob(と実態のファイル)はimmutableであることを想定している。一つのファイルに一つのBlob。BlobとAttachmentsはhas_many関係であり、Blobを変更したいなら、既存のものを変更するよりも新規に作った方がよい。
ActiveStorage ~Config S3を使う場合~
見れば大体わかる
Gemfile
gem 'aws-sdk-s3', require false
config/storage.yml
local:
service: Disk
root: <%= Rails.root.join('tmp/storage') %>
production:
service: S3
bucket: production-assets-bucket
# AWS SDK documentationに記載されている認証オプションを使う場合以下は省略可
access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %>
secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %>
region: <%= ENV['AWS_REGION'] %>
# あとはミラーリングとかも設定できる
s3_west_coast:
servie: S3
bucket: xxx
access_key_id: ''
secret_access_key: ''
region: ''
s3_east_coast:
service: S3
...
# ファイルはprimaryから提供される
# この機能はダイレクトアップロードと一緒には使えない
amazon:
service Mirror
primary: s3_east_coast
mirrors:
- s3_west_coast
config/environments/production.rb
# ymlで定義したサービス名を指定
config.active_storage.service = :production
あとはAWS側でバケットの作成と以下のポリシーをアタッチしてやる
- s3:ListBucket
- s3:PutObject
- s3:GetObject
- s3:DeleteObject
ActiveStorage ~使ってみよう~
- ファイルをattachする
class User < ApplicationRecord
has_one_attached :avatar
end
# userのparamsにして作成できる
user = User.create!(params.require(:user).permit(:avatar))
# 既存のuserにattachするには
user.update_attributes(params.require(:user).permit(:avatar))
# or
user.avatar = params[:avatar]
user.save # rails6からsaveのタイミングでストレージに保存されるようになった
# or
user.avatar.attach(params[:avatar]
user.avatar.attached? # => true
# Fileオブジェクトからもattach可能
# content_typeはoptionalだが付けといた方がいいらしい
user.avatar.attach(io: File.open('path/to/example.jpg'), filename: 'example.jpg', content_type: 'image/jpg')
# csv形式のstringとかであればこうすれば良い
process.file.attach(io: StringIO.new(csv_text), filename: 'process.csv', content_type: 'text/csv')
- has_many_attachedの場合
class Comment < ApplicationRecord
has_many_attached :images
end
# createは大体おなじ
comment = Comment.create!(params.require(:comment).permit(images: []))
# updateするとファイルが置き換わる(Rails6.0から)
comment.update_attributes(params.require(:comment).permit(images: []))
# attachするとファイルが追加される
comment.images.attach(parmas[:images])
comment.images.attached? # => true
# Fileオブジェクトからもattach可能
comment.images.attach(io: File.open('path/to/example.jpg'), filename: 'example.jpg', content_type: 'image/jpg')
削除系
# avatarと実際のリソースファイルを同期的に破棄する
user.avatar.purge
# Active Jobを介して、関連付けられているモデルとリソースファイルを非同期で破棄
user.avatar.purge_later
ただし、purgeは若干問題があるらしい
https://tech.smarthr.jp/entry/2018/09/14/130139
なのでdetachを使おう
# ActiveStorage::Attachmentだけを削除している
user.avatar.detach
# 全てのimagesのAttachmentsが削除される
comment.images.detach
# 現状個別にAttachmentをdeleteしてやっている
image = comment.images.find_by(id: params[:image_id])
image.try(:delete)
# user.destroyではAttachmentは削除されるが
# Blobとリソースファイルは削除されないので問題ない
user.destroy
Blobとファイルはトランザクションの影響を受けない範囲で確実に削除した方が良さそう
ファイルへのアクセス
# at view
# S3がpublicアクセス可能ならばこれでいい
url_for(user.avatar)
# ActiveStorageは基本的に認証はサポートしないらしい
# 署名付きURLとか使いたい場合は自前の対応が必要
# 今回はcdn_signed_url_forというhelperメソッドを用意した
# has_one_attachedとhas_many_attachedを同列で引数で扱うのが苦しかったので
# attachmentを渡すようにした
cdn_signed_url_for(user.avatar.attachment)
# downloadしてバックエンド側で利用することもできる
# 但し、常にbinaryで取得する
binary = user.avatar.download
# UTF-8のstringに変換する
encode = NKF.guess(binary)
utf8_text = binary.force_encoding(encode).encode(Encoding::UTF_8)
ActiveStorageの微妙なところ
- 画像をリサイズしてからアップロードみたいなことには対応してない
- preview表示時に都度リサイズすることはできる
- 100x100とかにリサイズしてからアップロードみたいがしたければviewかcontrollerで頑張ってやる必要がある
- 今回はjqueryのdark powerを使った
- 認証とか基本サポートしないスタイル
- 今後もこのスタンスでいくのかは不明
- S3のパスを自由に設定できない
- PaperClipとかCarrierWaveとかはできるっぽい
- 移行するのが面倒な原因のような...
- トランザクションのロールバック時にバケットの状態とblobsがずれる危険性がある
- しょうがない感はある
CloudFront
これを参考にやりました
https://dev.classmethod.jp/articles/cf-s3-deliveries-use-signurl/
実際に見てみよう!
特記事項
- 署名付きurlを使う場合は以下のクエリ文字列パラメータを削除してからURLの残りをオリジンに転送する
- Expires
- Key-Pair-Id
- Policy
- Signature
- クエリ文字列はCDNのキャッシュに影響するので上記以外のクエリを使う場合は順番を固定した方がキャッシュのヒット率が上がる
- 独自ドメインを使用することもできるが今回は対応しなかった
- ファイルダウンロードを許可するために独自のpolicyを設定している
- policyをstagingとproductionで共有しているが、開発時は分けた方がいいかも
- cloudfront用のWAFはGlobalに置いてやる必要がある
署名付きURLの発行
アプリを経由せずにCDNから直接ダウンロードするためにContent-Dispositionヘッダーを使用
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Disposition
class UrlSigner
include Singleton
def cdn_file_url_for(file)
root + file.key
end
def signed_url_for(file, expires_at: default_expires_at)
signer.signed_url(cdn_file_url_for(file), expires: expires_at)
end
# 日本語のファイル名でのDLをCloudFront->S3経由で行うためには
# CloudFrontで一度unescapeされたリクエストがS3に渡されるので
# CGI.escapeをfilename(日本語)に対して2度行う必要がある
def signed_download_url_for(file, filename: nil, expires_at: default_expires_at)
name = CGI.escape(filename || file.filename.to_s)
download_query = "?response-content-disposition=attachment#{CGI.escape(";filename=#{name};filename*=" + "utf-8''#{name}")}"
signer.signed_url(cdn_file_url_for(file) + download_query, expires: expires_at)
end
# 以下略...
end
helper
local環境ではCDNの署名付きurlは使えないのでRailsのリダイレクトurlを生成している
def cdn_signed_url_for(attachment, download: false, options: {})
return '' if attachment.blank?
if Rails.env.production? || Rails.env.staging?
download ? UrlSigner.instance.signed_download_url_for(attachment) : UrlSigner.instance.signed_url_for(attachment)
else
url = rails_blob_url(attachment, options)
download ? url + "?response-content-disposition=attachment&filename=#{attachment.filename}" : url
end
end