RailsでShrineを使ってファイルをS3にダイレクトアップロードするときの注意点

# 概要
RailsでShrineを使ってファイルをS3にダイレクトアップロードするときの注意点あれこれ。
それとそもそもShrineって何っていう人のための軽い紹介とか。

# TL;DR
Shrine結構いいぞ。

# Shrineそのものについての解説
まずはShrineというgem自体についてです。

## 概略
http://shrinerb.com
https://github.com/janko-m/shrine
Shrineは

> File Attachment toolkit for Ruby applications

です。要はRubyアプリケーションにおいてファイルを何かにアタッチ（追加）するために使えるツールキットです。
執筆時点でのバージョンは`v2.5.0`ですが、ほぼ毎月のようにバージョンアップが行われているのでご利用の際は最新版のバージョンを確認するようにしてください。
特徴としてはコアを小さく保って多くの（ほとんど基本的と呼べる）機能をプラグインに分離していることです。

## 作者
作者のJanko Marohnić氏（janko-m）はもともと[Refile](https://github.com/refile/refile)というgemに積極的にコントリビュートしてコアチームに招かれてもいたのが、バックグラウンド処理ができないために新しくShrineを作ることにしたようです。

## 思想
どうやら作者は[Roda](https://github.com/jeremyevans/roda)の大ファンのようで、RodaとRefileからかなり影響を受けているようです。その結果、Shrineは概略でも述べたように、ごく小さいコアと豊富なプラグインというアーキテクチャを持つことになりました。
[この記事](https://twin.github.io/better-file-uploads-with-shrine-motivation/)では既存のファイルアップロード系gemへの不満点（あくまで彼なりの）が詳述されているので、こちらを読むと思想背景がよりよくわかると思います。

## 機能
いわゆるファイルアップロードに関する機能はだいたい網羅されているのですが、あえて特徴的な機能を挙げるとすれば以下のようになります。

+ Rails以外のフレームワークをサポート
+ プラグインにより多くのストレージをサポート
+ ストレージを`cache`と`store`の２段階に分けることによる最適化
+ S3へのダイレクトアップロードをサポート

「ストレージ」というのはどこに実ファイルを置くのかという概念で、デフォルトではファイルシステムとS3が使えますが外部プラグインを導入すると様々な種類のストレージを使うことができます（例えば[SQL](https://github.com/janko-m/shrine-sql)など）。
今回はS3へのダイレクトアップロードを実装したので、それの注意点を書きます。

# RailsでShrineを使う
多分多くの読者は（あるいは私も）Railsを使うことが多いとおもわれるので（RodaとかHanamiとか、面白そうだけど業務で使う気にはなかなかなれない…）、まずはRailsでShrineを使う方法を軽く説明します。

## 基本

[READMEのquick-start](https://github.com/janko-m/shrine#quick-start)をコピペすればまあ動きます。…と書くだけだと味気ないので、モデル層にかかわらない設定部分だけを下記に抜粋。

```rb:Gemfile
gem 'shrine'
```

```rb:config/initializers/shrine.rb
require "shrine"
require "shrine/storage/file_system"

Shrine.storages = {
  cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
  store: Shrine::Storage::FileSystem.new("public", prefix: "uploads/store"),
}

Shrine.plugin :activerecord
```

注意すべきは最後の`.plugin`メソッドで、アプリケーション全体で使用したいプラグインはここでまとめて設定するといいようです。逆に、モデルローカルで使用したいプラグインは該当するUploaderクラス内で`plugin`メソッドを呼べばいいと思われますが、ひとまずはプラグインの設定はここに書けばいいでしょう。

## DBスキーマ
ここはShrineの中でも数少ない融通が効かない部分で、**「利用するUploaderが持つAttachmentに渡した引数の文字列＋"_data"」という名前のtext型カラムを作成する**必要があります。「？？？」となったあなた、あなたは正しい。
例えば`Photo`クラスに`ImageUploader`クラスを利用させたい場合（すでに名前が混乱気味ですが、公式ドキュメントからしてこうなので…）、

```rb:app/models/photo.rb
class Photo < ApplicationRecord
  Include ImageUploader[:image]
end
```

```rb:app/uploaders/image_uploader.rb
class ImageUploader < Shrine
end
```

```rb:db/schema.rb
# 抜粋
create_table :photos do |t|
  t.text :image_data
end
```

のようになるのですが、ややこしいことに、`image_data`の`image`は`ImageUploader[:image]`のシンボル部分から抜き出されています。

# S3ダイレクトアップロードについて

## 必要性
RailsアプリをHerokuで動かしている場合、画像はS3に最終的にアップロードされることになります。この場合、ファイルの大きさやインターネット回線の状況によってはRailsアプリからS3へのアップロードに失敗する可能性があります。あるいはEC2を使っている場合でも、ファイルのアップロード先はどのみちS3になる可能性が高いです。であれば、ブラウザからファイルを直接S3にアップロードすることができるならば、リソースの節約になると同時にユーザー体験の向上も見込めて一石二鳥です。

## 簡易実装
[このドキュメント](http://shrinerb.com/rdoc/files/doc/direct_s3_md.html)にあるように実装していきます。
勘所は以下の３つ。

```rb:Gemfile
gem 'roda'
```

```rb:config/initializers/shrine.rb
plugin :direct_upload
```

```rb:config/routes.rb
Rails.application.routes.draw do
  mount ImageUploader::UploadEndpoint => "/images"
end
```

最後が重要で、こう書くと`GET /images/cache/presign`というルーティングが生成されます。このURLを叩くと、フロント側でS3にファイルをアップロードするのに必要な認証情報その他を含んだJSONが返ってきます。

## 簡易実装の問題点
あとはフロント側の実装だけ…なのですが、サンプルコードを眺めてみると何やら怪しい感じがします。

```
# GET /images/cache/presign
{
  "url" => "https://my-bucket.s3-eu-west-1.amazonaws.com",
  "fields" => {
    "key" => "b7d575850ba61b44c8a9ff889dfdb14d88cdc25f8dd121004c8",
    "policy" => "eyJleHBpcmF0aW9uIjoiMjAxNS0QwMToxMToyOVoiLCJjb25kaXRpb25zIjpbeyJidWNrZXQiOiJzaHJpbmUtdGVzdGluZyJ9LHsia2V5IjoiYjdkNTc1ODUwYmE2MWI0NGU3Y2M4YTliZmY4OGU5ZGZkYjE2NTQ0ZDk4OGNkYzI1ZjhkZDEyMTAwNGM4In0seyJ4LWFtei1jcmVkZW50aWFsIjoiQUtJQUlKRjU1VE1aWlk0NVVUNlEvMjAxNTEwMjQvZXUtd2VzdC0xL3MzL2F3czRfcmVxdWVzdCJ9LHsieC1hbXotYWxnb3JpdGhtIjoiQVdTNC1ITUFDLVNIQTI1NiJ9LHsieC1hbXotZGF0ZSI6IjIwMTUxMDI0VDAwMTEyOVoifV19",
    "x-amz-credential" => "AKIAIJF55TMZYT6Q/20151024/eu-west-1/s3/aws4_request",
    "x-amz-algorithm" => "AWS4-HMAC-SHA256",
    "x-amz-date" => "20151024T001129Z",
    "x-amz-signature" => "c1eb634f83f96b69bd675f535b3ff15ae184b102fcba51e4db5f4959b4ae26f4"
  }
}
```

```js
$.getJSON('/images/cache/presign', options, function(result) {
          data.formData = result['fields'];
          data.url = result['url'];
          data.paramName = 'file';
          data.submit();
        });
```

上のサンプルは[jQuery-File-Upload](https://github.com/blueimp/jQuery-File-Upload)の例なのですが、`data`はFormらしい何かであることがわかります（正確にはFormそのものではないようです。出処はjQuery-File-Uploadのコールバック関数の第２引数です）。
S3はファイルのアップロードに`POST`と`PUT`を用意しているのですが、上のJSONは`POST`でしか使えません。`PUT`でファイルをアップロードしたい場合、別の方法でpresignデータを手に入れる必要があります。
ドキュメントでは[Dropzone](http://www.dropzonejs.com/)も使えると書いてあるし、事実使えたのですが、そのためには自力でpresign実装を書く必要がありました。実装の場所はサーバー側でなくても大丈夫ではあるようなのですが、サンプルに合わせる意図も含めてサーバー側で実装することにしました。

# 結局何が問題なのか
フロント側がどうやらjQuery-File-Uploadになっているような感じの情報しかウェブ上にないこと、これに尽きる感じです。
公式ドキュメントのみならず、チュートリアルやサンプルコードでもDropzoneを使ったものは見つかりませんでした。実装も、フォームでPOSTする以外の方法はあまり手厚くサポートされていない印象です。
とはいえ、モダンなフロントエンドだとjQueryに依存しない場合も多くなってきていますし（そういえば[Rails自体もjQueryへの依存をなくす](qiita.com/itkrt2y/items/7e999836f460fb9c005d)のでしたね）、Reactのようにフォームを弄るのが簡単ではないようなケースもあることを考えると、もうちょっと多様な方法（というより`PUT`）が簡単にできるようになっているといいなと思います。

# まとめ
現時点でShrineは普通に使えるレベルの品質に達しているのではないかと思います（この問題を探るのにソースコードも結構読みましたが、依存関係も少なくコードは比較的シンプルでした）。メンテナンスもきっちりされており、現時点でファイルアップロードを実装するなら使用を検討できるでしょう（類似のコンセプトを持つRefileがあまりメンテナンスされていないのも追い風）。
しかし、モダンなフロントエンドからのS3へのダイレクトアップロードを実装する場合、案外一筋縄ではいかない、ということがありました。