ActiveStorage CloudFront

# この記事を見てわかること

- ファイルアップロードの共通基盤として導入したActiveStorageについて
- CloudFrontの設定について

---

# ActiveStorageとは(ざっくり)

- Rails5.2からRailsの標準としてサポートされたファイル添付ライブラリ
- ActiveRecordにファイルを一つまたは複数添付できる
- クラウドストレージサービスにファイルを簡単にアップロードできる
- on-demandでの画像の変換や動画、PDFのプレビューなども行える

---

# ActiveStorage ~導入と構成~

↓を実行すると

```shell
rails active_storage:install
rails db:migrate
```

↓のスキーマが増えます

```rb
  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にファイルを一つ添付する

```rb
class User < ApplicationRecord
  has_one_attached :avatar
end
```
↓こんなリレーションになる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/419792/52000f26-388f-e388-d4a7-83d7ff51c217.png)

[公式のREADME](https://github.com/rails/rails/tree/master/activestorage)より

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

```rb
gem 'aws-sdk-s3', require false
```

config/storage.yml

```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

```rb
  # ymlで定義したサービス名を指定
  config.active_storage.service = :production
```

あとはAWS側でバケットの作成と以下のポリシーをアタッチしてやる
- s3:ListBucket
- s3:PutObject
- s3:GetObject
- s3:DeleteObject

---

# ActiveStorage ~使ってみよう~

- ファイルをattachする

```rb
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の場合

```rb
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')
```

削除系

```rb

# avatarと実際のリソースファイルを同期的に破棄する
user.avatar.purge

# Active Jobを介して、関連付けられているモデルとリソースファイルを非同期で破棄
user.avatar.purge_later
```

ただし、purgeは若干問題があるらしい
https://tech.smarthr.jp/entry/2018/09/14/130139
なのでdetachを使おう

```rb
# 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とファイルはトランザクションの影響を受けない範囲で確実に削除した方が良さそう

ファイルへのアクセス

```rb
# 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


```rb
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を生成している

```rb
  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

```