ハトネコエ Web がくしゅうちょう

プログラミングやサーバー関連、チームマネジメントなど得た技術のまとめ

refile から shrine へ移行する方法を徹底解説

最近はレガシーなプロダクトの改善活動に没頭しているハトネコエです。

さて、そのプロダクトでは refile が使われていました。

の三点セットです。

もともと refile は CarrierWave の後継として同じ作者により作られたようですが、
上の3つの gem すべてが 2015年を境に更新されなくなり、 CarrierWave は2019年の今でも更新が続くという、
なんともかわいそうな結果になってしまったライブラリです。

1. 動機

さて、refile の欠点は、複数サイズのアップロードが出来ないことです。
基本的に、元の画像を拾ってきて refile-mini_magick を介してリサイズした画像を表示する、という思想なのですが、
これではユーザーが投稿した画像を並べるときにファイル取得に時間がかかってしまう場合があります。
また、ページ表示のたびに1枚1枚リサイズ処理をかけるのは、サーバーのCPUやメモリにも負担を与えていそうです。

できれば、アップロード時に複数サイズを上げるようにしたいな、と思い代替となるライブラリを探しました。
その中で、 shrinerefile からの移行方法についてもドキュメントに書かれており
「これは親切だな! 私でもできそう!」と思い、移行してみることにしました。

なお、 refile は依存ライブラリの関係で Rails 5 に上げることの障害ともなります。
こちらで紹介されているような、
モンキーパッチをあてたフォークリポジトリを用意して Gemfile に取り入れるなどで応急処置はできますが、
できれば早く refile から脱却するのが良いでしょう……。

2. shrine 導入の注意点

「よーし shrine にするぞー!」と思っても、shrine の最新版を入れることは出来ません。
なぜなら、 refile-s3 は aws-sdk-core v2 を使っていて
shrine の最新版である3系では aws-sdk-core v3 を使うためです。

以下のように、rubygems.org を使うのではなく GitHub から
特定のコミットIDを指定して aws-sdk v3 に対応した refile-s3 を取ってくる方法があると、この記事を書いているときに気付きましたが、
気付いていなかったので、私は shrine v2 を入れることで対応しました。
(とはいえ特定のコミットIDを指定して入れるのは挙動が大丈夫か怖いですね……)

gem 'refile-s3', github: 'refile/refile-s3', ref: '768d60d4e5e'

以下のように Gemfile に足しました。

gem 'shrine', '~> 2.19.0'
gem 'aws-sdk', '~> 2.11.421'

注意するのは、shrine のドキュメントは現在、基本的に shrine v3 に向けて書かれていることです。
shrine v2 と v3 では、プラグインの名前など異なります。

https://github.com/shrinerb/shrine/tree/v2.19.3/doc

ここにあるドキュメントを見て、 shrine v2 でのやり方を見ながら設定していきました。

3. refile から shrine へ

https://github.com/shrinerb/shrine/blob/v2.19.3/doc/refile.md#migrating-from-refile

基本的にはここに書かれています。

refile のための image_id カラムがあるテーブルに image_data カラムを追加し、
(hogehoge_id であれば hogehoge_data カラムを追加)
config/initializers/shrine.rb には例えば以下のように記述します。

require "shrine"
require "shrine/storage/file_system"
require "shrine/storage/s3"

# アップロードするディレクトリの指定
if Rails.env.test?
  Shrine.storages = {
    cache: Shrine::Storage::FileSystem.new("public", prefix: "uploads/cache"),
    store: Shrine::Storage::FileSystem.new("public", prefix: "uploads/store"),
  }
else
  s3_options = {
      bucket: ENV["AWS_S3_BUCKET_NAME"],
      region: ENV["AWS_REGION"],
      access_key_id: ENV["AWS_ACCESS_KEY_ID"],
      secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"],
  }

  Shrine.storages = {
      cache: Shrine::Storage::S3.new(prefix: "cache", **s3_options),
      store: Shrine::Storage::S3.new(prefix: "store", **s3_options),
  }
end

Shrine.plugin :activerecord
Shrine.plugin :validation_helpers

# CDN
# public: true を付けないとGET時にも X-Amz-Algorithm X-Amz-Credential などのパラメーターが付いたままになってしまう
if Rails.env.production?
  cdn_host = "http://xxxyyyzzz.cloudfront.net"
  Shrine.plugin :default_url_options, cache: { public: true, host: cdn_host }, store: { public: true, host: cdn_host }
end

# Refile からの移行用
module RefileShrineSynchronization
  def write_shrine_data(name)
    if read_attribute("#{name}_id").present?
      data = {
        storage: :store,
        id: send("#{name}_id"),
        metadata: {
          size: (send("#{name}_size") if respond_to?("#{name}_size")),
          filename: (send("#{name}_filename") if respond_to?("#{name}_filename")),
          mime_type: (send("#{name}_content_type") if respond_to?("#{name}_content_type")),
        }
      }

      write_attribute(:"#{name}_data", data.to_json)
    else
      write_attribute(:"#{name}_data", nil)
    end
  end
end

その上で、

class PhotoAlbum < ActiveRecord::Base
  attachment :image
end

のようなクラスがあるのなら、以下のように5行加えます。

class PhotoAlbum < ActiveRecord::Base
  attachment :image

  include RefileShrineSynchronization

  before_save do
    write_shrine_data(:image) if changes.key?(:image_id)
  end
end

これによって、refile による画像アップロードがおこなわれた際に、
shrine で扱う image_data カラムも同時に更新されるようになります。

attachment のある全てのモデルにこの追加をおこなったら、 まずは一度本番稼働させると良いでしょう。

4. image_id を image_data にコピー

3. の作業でユーザーがこれからアップロードするぶんはコピーされますが、
今までの image_id も image_data へ移行しなければいけません。

例えばこのようなスクリプトを用意して、移行させることが出来ます。

include RefileShrineSynchronization

def migrate(model_class: nil, column: :image)
  return if model_class.nil?

  puts "Start migrate #{model_class}"
  model_class.find_each do |model|
    if model.send("#{column}_id").present?
      model.write_shrine_data(column)
      model.save!
    end
  end
  puts "End migrate #{model_class}"
end

migrate(model_class: PhotoAlbum)
migrate(model_class: UserProfile, column: :avatar_image)

scripts/migrate_refile_to_shrine.rb として作ったとしたなら、

bundle exec rails runner scripts/migrate_refile_to_shrine.rb

で実行します。

5. attachment_image_tag 及び attachment_url からの卒業

ここが一番大変です。
こういうものがあるわけです。(※slim記法です)

= attachment_image_tag photo_album, :image, :fill, 320, 320, fallback: "blank.png"

fill なのでつまり、アスペクト比を保ったまま幅と高さを最低 320px に拡大し、
その上で 320x320 の正方形に切り取っているという処理です。

けっこう悩みましたが、最近のCSSには object-fit というものがあると知れたので、
なんとか以下のように書き直すことが出来ました。

= image_tag (photo_album.image_url || "blank.png"), style: "width: 320px; height: 320px; object-fit: cover;"

ただしこれをやると img の width, height を上書きすることになるので、
実は fill, 320, 320 で処理させてるけど、
CSS は width: 100%, height: 100% で、外側のコンテナサイズ的に 200px x 200px で表示されているのが元の表示という罠もあったりして、
実際の画面表示を確認することになりけっこうしんどかったです。

attachment_image_tag, attachment_url を無くすのが、移行の一番しんどい作業だと思います。

6. 複数サイズのアップロード

5. の作業のあと、もしくはある程度並行しておこなえるといいのが、
複数サイズのアップロード機能。

https://github.com/shrinerb/shrine/blob/v2.19.3/doc/plugins/versions.md に詳しく書かれています。

Gemfile に image_processingmini_magick を追加したあと、
app/uploaders/image_uploader.rb に、例えば以下のように記述します。

require "image_processing/mini_magick"

class ImageUploader < Shrine
  plugin :processing
  plugin :versions

  process(:store) do |file, context|
    versions = { original: file }

    file.download do |original|
      # loader(page: 0) により、アニメーションPNG画像がアップされた際にエラー吐かないように
      pipeline = ImageProcessing::MiniMagick.loader(page: 0).source(original)
      versions[:large]  = pipeline.auto_orient.resize_to_limit!(1280, 1280)
      versions[:medium] = pipeline.auto_orient.resize_to_limit!(640, 640)
      versions[:small]  = pipeline.auto_orient.resize_to_limit!(240, 240)
    end

    versions
  end

  Attacher.validate do
    validate_max_size 10 * 1024 * 1024, message: "のファイルサイズを10MB以内に収めてください"
    validate_mime_type_inclusion %w(image/jpeg image/png), message: "は JPEG, PNG 形式のみアップロード可能です"
  end
end

PhotoAlbum モデルが 3. の作業で以下のようになっていたと思います。

class PhotoAlbum < ActiveRecord::Base
  attachment :image

  include RefileShrineSynchronization

  before_save do
    write_shrine_data(:image) if changes.key?(:image_id)
  end
end

これを以下のようにします。

class PhotoAlbum < ActiveRecord::Base
  include ImageUploader[:image]
end

そうです、 refile からの完全な卒業です。
(ここ、安全に移行できる方法があったら教えてください……)

モデルをこのように変えると、 attachment_image_tag などの attachment 系がある view でエラーを吐くようになるのでお気をつけください。
なので、 5. の作業を終わらせてからが一番いいですね。

= f.attachment_field :image

= f.file_field :image, accept: "image/jpeg, image/png"

などと書き換えるのも忘れずにおこないましょう。

5. の作業で image_url を使うよう書き直したと思いますが、
ファイルにアクセスする方法が変わることにも気をつけましょう。

versions plugin を用いる場合、

= image_tag photo_album.image_url

のようにはアクセスできません。
must call Shrine::Attacher#url with the name of the version とエラーが出てしまいます。

= image_tag photo_album.image[:medium].url

もしくは

= image_tag photo_album.image_url(:medium)

という形で、バージョンを指定してあげる必要があります。

おすすめは後者の書き方で、 versions が存在しない image_data の場合、
image_url を見てくれます。

ちょっと説明しづらいんですが、
この 6. の作業終了と次の 7. の作業終了の合間にユーザーがアクセスしてきてもエラーが起きない、ということだと理解してもらえればと思います。

7. すべての画像のアップロードし直し

難度の高い 5. , 6. のタスクを終わらせて、
いよいよここまで来れたならほとんど終了です。

4. でおこなった image_id カラムからのコピー作業の結果、
image_data カラムの中身は以下のようになっています。

"{\"storage\":\"store\",\"id\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"metadata\":{\"size\":null,\"filename\":null,\"mime_type\":null}}"

一方、 versions plugin とともにアップロードがおこなわれた image_data カラムは
以下のようになります。

"{\"original\":{\"id\":\"yyyyyyyyyyyyyyyyyyyyyyyy.png\",\"storage\":\"store\",\"metadata\":{\"filename\":\"aaaaaaaaaaaaaaaaaaaa.png\",\"size\":3139040,\"mime_type\":\"image/png\"}},\"large\":{\"id\":\"zzzzzzzzzzzzzzzzzzzzzzzz.png\",\"storage\":\"store\",\"metadata\":{\"filename\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png\",\"size\":4239003,\"mime_type\":null}},\"medium\":{\"id\" # 以下省略

だいぶ構造が変わりますね。

現在のままでもサイトは動きますが、サイトのスピードアップのためにも、
過去のすべての画像に関しても複数サイズの画像を生成しましょう。

https://github.com/shrinerb/shrine/blob/v2.19.3/doc/regenerating_versions.md#adding-versions の記事を参考に、
以下のようにスクリプトを組み立てました。

require "image_processing/mini_magick"

class ReuploadImages
  def exec(model_class: nil, column: :image)
    return if model_class.nil?
    puts "##### #{model_class} #####"

    model_class.find_each do |model|
      begin
        attacher, attachment = model.send("#{column}_attacher"), model.send(column)
        if attacher.stored? && !attachment.is_a?(Hash)
          puts "Start #{model.id}"
          update_attach(attacher, attachment)
        else
          puts "Skipped #{model.id}"
        end
      rescue => e
        puts "!! Error in #{model_class} ID: #{model.id} !!"
        puts e
      end
    end
  end

  def update_attach(attacher, attachment)
    versions = { original: attachment }

    attachment.download do |original|
      pipeline = ImageProcessing::MiniMagick.loader(page: 0).source(original)
      versions[:large]  = attacher.store!(pipeline.auto_orient.resize_to_limit!(1280, 1280), version: :large)
      versions[:medium] = attacher.store!(pipeline.auto_orient.resize_to_limit!(640, 640), version: :medium)
      versions[:small]  = attacher.store!(pipeline.auto_orient.resize_to_limit!(240, 240), version: :small)
    end

    attacher.swap(versions)
  end
end

reupload = ReuploadImages.new
reupload.exec(model_class: PhotoAlbum)
reupload.exec(model_class: UserProfile, column: :avatar_image)

scripts/reupload_images.rb に作り、

bundle exec rails runner scripts/reupload_images.rb

で走らせるイメージです。
データ行の数によっては、分割して実行させるといいかもしれません。

8. おしまい!!(9. の追記が増えました)

長い道のりでしたが、あとは refile 系を Gemfile から削除し、
shrine v3 へのバージョンアップをドキュメントを読みながら進めていけば、
すっかり shrine への移行が完了です。

shrine v3 への移行ドキュメント : https://shrinerb.com/docs/upgrading-to-3

shrine はドキュメントが充実していて、それに助けられる部分が多いです。
この記事を書いている段階では私は 5. 〜 6. のステップにいるのですが、
なんとかして終わらせて画像の読み込みスピードを改善したいと思います。

ここまで読んでいただき、どうもありがとうございました!

Ruby on Rails 6 実践ガイド (impress top gear)

Ruby on Rails 6 実践ガイド (impress top gear)

9. CloudFront 設定の変更【追記:2020/01/05】

いよいよほとんどの作業が終わった段階でハマったところがあったので記します。

refile と shrine の挙動の違いによる、CloudFront に必要な設定変更です。

refile では CloudFront の Origin をサイトURLにしていたと思いますが、
shrine を使う上では Origin を S3 のアドレスにする必要があります

また、S3 の画像が公開設定でない場合、
以下の記事を参照し、CloudFront から S3 へのアクセス許可を付与してください。

S3の特定バケットへのアクセスを特定のCloudFrontからのみ許可する。 - Qiita

コードをちゃんと追ってないので不正確かもですが、
refile ではデフォルトでは /attachments にルーティングを付与していて
CDN に画像がなければ S3 にアクセスし、ファイルを取得し、必要あれば加工して表示する、という一連の流れを踏むわけです。

というわけで サイト → CloudFront → キャッシュなければサイト → S3 → サイトで加工 → ユーザーへ という通信の流れです。

一方、 shrine ではデフォルトではルーティングを作成していません。

というわけで サイト → CloudFront → キャッシュなければS3 → ユーザーへ という通信の流れにしないといけません。

Origin をサイトURLのままにしておくと、サイトにアクセスしたところでその画像はないので 404 が返ります。
めちゃくちゃ悩みました。『S3 404 エラー』で検索したりしてましたが、S3の問題ではなかったですね(笑)

対応としては、CloudFront の Distribution を新たに作って、
そちらの CDN ホストを shrine では扱うようにしました。

いやぁ〜、難しかった。
追記は以上です!