[Rails] ActiveStrogeによる無料利用クラウドストレージへのダイレクトアップロード

## 概要

Ruby on Rails 5.2でサポートされたActiveStorageを使ってみましたので、備忘録として残します。

## ActiveStorageとダイレクトアップロード

ActiveStorageは従来Carrierwave等のGemで実現されていたファイルアップロードをRails標準でサポートした機能です。アップロード先サーバとしてS3などクラウドストレージが利用できます。
ただし、そのままローカルの代わりにクラウドストレージを使うと、ファイルの転送経路としてクライアント→Webサーバ(Rails)→クラウドストレージと二段転送になってしまい、無駄な(かつ大量な)Webサーバ負荷が発生することになります。
これを解消するため、ファイルをブラウザからクラウドストレージのRESTインターフェースに直接送信するのがダイレクトアップロードです。

## 構成

| 項目 | 内容 | 理由等 |
|:-----------|:------------|:------------|
| Webサーバ | Heroku | 無料利用が可能なため |
| Webフレームワーク | Rails 5.2.1 |  |
| クラウドストレージ | GCS (Google Cloud Storage) | Always Free利用が可能のため |

インフラ構成に関しては再利用性に考慮し、Terraformを使ったコードで定義する形で進めました。
完成形コードは下記を参照ください。
https://github.com/o-t-k-t/incremental_todo

また、今回は単純なCRUD程度の機能は実装済みの前提で記載します。

## クラウドストレージのバケット作成

### Google Cloud Platformプロジェクトの作成

GCSバケットを定義するためのプロジェクトを作成します。まだ行なっていない場合はユーザ登録から必要となります。ここでは空のプロジェクトを作成するのみであるため、説明は省略します。

### GCPサービスアカウント作成

GCPプロジェクトに下記用途で外部APIから操作するためのアカウントを作成します。
それぞれ作成後に認証情報が入ったJSONファイルをダウンロードします。

- Terraform(バケット作成等の管理)
- Railsアプリ(バケットへのファイル読み書き)

#### Terraformアカウント

まずCompute Engine APIを有効化する必要があります。
(操作するのはGCSのみですが、バケット作成にCompute Engine APIが必要となります)
その上で、ストレージ管理者権限と、Compute管理者権限を与えたサービスアカウントを作成します。
このブラウザ操作の詳細はドキュメントを参照ください。

サービスアカウント作成
https://cloud.google.com/iam/docs/creating-managing-service-account-keys?hl=ja

API有効化
https://cloud.google.com/apis/docs/enable-disable-apis?visit_id=636838276690750507-878885287&rd=1

#### Railsアカウント

Terraformアカウントと同様の手順で、ストレージのオブジェクト管理者を設定

### TFファイルの作成

下記の通りGCS構成を記述します。

``` terraform:infra.tf

# Heroku側の設定

...

variable "gcs_credential_file" {} 

# GCP: object storage
provider "google" {
  credentials = "${var.gcs_credential_file}"
  project     = "【GCP Project ID】"
  region      = "us-central1"  # NOTE: 米国のみAlways Free対象
}

resource "google_storage_bucket" "【バケット名】" {
  name          = "【バケット名】"
  location      = "us-central1" # NOTE: 米国のみAlways Free対象
  storage_class = "REGIONAL"
}

```

バケットのロケーションで米国以外を指定するとAlways Free対象外となる点に留意ください。


構成を適用するにはGCPのAPIを使用するため、当然認証情報が必要になるのですが、Terraformのコードでは、バージョン管理下におけない認証情報などを、上記の`gcs_credential_file `のように値がない変数として宣言することで、別ファイル(.tfvars)から読み込ませられます。
デフォルトでは`terraform.tfvars`が読み込まれるため、ここにサービスアカウント認証情報を記述します。

```terraform.tfvars
gcs_credential_file = 【Terraform用サービスアカウントのJSONファイル格納パス】
```

これを下記コマンドで実行するとバケットが作成されます。

```
terraform apply
```

## Rails側でGCS使用設定

### GCSクライアントGemインストール

```:Gemfile

...

gem 'google-cloud-storage'

...

```

Gemfileに上記を追記し、インストール

```
bundle install
```

### ActiveStorageのインストール

下記でActiveStorage用のファイルが生成されます。

```
bundle exec rails active_storage:install
```

ファイルストレージ向け設定ファイルがあるので、下記を追記します。

```yaml:config/storage.yml
google:
  service: GCS
  project: 【GCP Project ID】
  credentials: <%= ENV["GCS_CREDENTIAL"] %>
  bucket: 【バケット名】
```

このファイルでは複数のファイルストレージの情報が記述できます。
これをRails実行環境ごとに指定することにより、Railsのアップロード先サーバを設定できます。
今回は実験用なので、develop環境でGCSを使用します。

```Ruby:config/environments/develop.rb
  config.active_storage.service = :local
```

上記を下記に変更。

```Ruby:config/environments/develop.rb
  config.active_storage.service = :google
```

また、ActiveStrageはレコードとファイルの関連を管理するために専用のテーブルを必要とします。インストール時にマイグレーションファイルが生成されているので、データベースに反映します。

```
bundle exec rails db:migrate
```

### 機能追加

#### モデル定義

ここではUserモデルに対する画像のアップロード機能を追加します。

まず、下記のよう```has_one_attached```の記述を追記します。ActiveRecordの`belongs_to`などと似たスタイルとなっており、これはインスタンスメソッドなども同様です。

```Ruby:app/models/user.rb
class User < ApplicationRecord
  ...
  has_one_attached :avatar
  ...
end
```

これにより`avatar`メソッドコールによりUserモデルのファイルにアクセスできるようになります。
また1レコード対多ファイルとする場合は`has_many_attached`で定義できます。

#### バリデーション

ActiveStorageには現状、類似機能のGemのような__バリデーションアシスト機能がありません。__
このため全てカスタムバリデーションで記述します。

一例ですが、下記します。

```Ruby:app/models/user.rb
  validate :requre_avatar_size_within_limit
  validate :requre_avatar_is_image_file

  ...

  private

  def requre_avatar_size_within_limit
    return unless avatar.attached?
    return if avatar.byte_size <= 2.megabytes

    avatar.purge
    errors.add(:attachments, 'が大きすぎます')
  end

  def requre_avatar_is_image_file
    return unless avatar.attached?
    return if avatar.content_type.in?(%w[image/png image/jpg image/jpeg image/gif])

    avatar.purge
    errors.add(:attachments, 'がサポートされないファイル形式です')
  end
end
```

purgeは対象ファイルを削除するメソッドです。通信を伴う処理になるので、別途Active Jobで非同期に処理するpurge_laterを用いるほうがバリデーションエラー時の応答は速くなります。

#### ビュー実装

フォーム実装はほぼActiveRecord属性と同様に記述できます。

```haml:app/views/users/new.html.haml
  = form_with(model: user, url: user_path, local: true) do |f|
    .form-group
      = f.label :name, class: 'label'
      = f.text_field :name, class: 'form-control'
...
    .form-group
      = f.label :avatar, class: 'label'
      = f.file_field :avatar, class: 'form-control'

```

表示も同様。

```haml:app/views/users/show.html.haml
= user.avatar.variant(resize: '300x300')
```

### ダイレクトアップロード設定

ここまででGCSアップロードは実装できているのですが、前述のとおりダイレクトアップロードを設定します。

#### CORS
ダイレクトアップロードではブラウザはWebサーバーが配信したHTMLから、Webサーバーと、それと異なるドメイン(バケット)に対しHTTPアクセスをする必要があります。しかし仮にこれを一律OKとしてしまうと、ユーザがログイン中に、何らかの方法で攻撃者が別サイト常にバケットへのフォームを入力させれば、自由に操作できていまいます。

これを防ぐため、そもそもブラウザには一度に同じドメイン以外にアクセスを行わない制限があります。これを今回のような用途で部分的に解除するのがCORS(Cross-Origin Resource Sharing)です。

#### GCSバケット設定

```Terraform:infra.tf
resource "google_storage_bucket" "【バケット名】" {
  name          = "【バケット名】"
  location      = "us-central1" # NOTE: 米国のみAlways Free対象
  storage_class = "REGIONAL"

  cors {
      method = ["*"]
      origin = ["【WebサーバのURL】"]
      response_header = ["Content-Type", "Content-MD5"]
      max_age_seconds = 3000
  }
}
```

#### Rails設定

下記のようにフォームヘルパーのオプション```direct_upload```で指定します。

```haml:app/views/users/new.html.haml

    .form-group
      = f.label :avatar, class: 'label'
      = f.file_field :avatar, direct_upload: true, class: 'form-control'

```