Tech Racho エンジニアの「?」を「!」に。
  • 開発

Rails: PaperclipからActiveStorageへの移行ガイド by thoughtbot(翻訳)

概要

MITライセンスに基いて翻訳・公開いたします。


https://github.com/thoughtbot/paperclipより

翻訳後に更新が入っていますのでご注意ください。

PaperclipからActiveStorageへの移行ガイド by thoughtbot(翻訳)

PaperclipとActiveStorageは、いずれも同じような問題に対して類似のソリューションを提供しますので、一方から他方への移行は単純なデータ書き換えとなります。

PaperclipからActiveStorageへの移行手順は次のとおりです。

  1. ActiveStorageデータベースマイグレーションを適用する
  2. ストレージを設定する
  3. データベースのデータをコピーする
  4. ファイルをコピーする
  5. アプリのテストを更新する
  6. アプリのビューを更新する
  7. アプリのコントローラを更新する
  8. アプリのモデルを更新する

1. ActiveStorageデータベースマイグレーションを適用する

ActiveStorageのインストール指示に沿って進めます。Gemfileにmini_magick gemを追加しておきたいと思うことでしょう。

rails active_storage:install

2. ストレージを設定する

こちらもActiveStorageの設定指示に沿って進めます(訳注: リンク先はedgeguidesです)。

3. データベースのデータをコピーする

ActiveStorageは、active_storage_blobsテーブルとactive_storage_attachmentsテーブルをファイルメタデータで検索できることを前提とします。Paperclipはこれらのファイルメタデータを、関連付けられたオブジェクトのテーブルに直接保存します。

avatarを1つ持つUserを例に説明します。Paperclipの場合は次のようになります。

class User < ApplicationRecord
  has_attached_file :avatar
end

Paperclipマイグレーションによって、次のようなテーブルが生成されます。

create_table "users", force: :cascade do |t|
  t.string "avatar_file_name"
  t.string "avatar_content_type"
  t.integer "avatar_file_size"
  t.datetime "avatar_updated_at"
end

これを以下のテーブルに変換します。

create_table "active_storage_attachments", force: :cascade do |t|
  t.string "name", null: false
  t.string "record_type", null: false
  t.integer "record_id", null: false
  t.integer "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

ファイルを元の置き場所から変えないことを前提にする場合、以下がマイグレーションとなります。それ以外の場合は、次のセクションを先に読んでマイグレーションを適宜変更してください。

Dir[Rails.root.join("app/models/**/*.rb")].sort.each { |file| require file }

class ConvertToActiveStorage < ActiveRecord::Migration[5.2]
  require 'open-uri'

  def up
    # PostgreSQLの場合
    get_blob_id = 'LASTVAL()'
    # MariaDBの場合
    # get_blob_id = 'LAST_INSERT_ID()'
    # SQLiteの場合
    # get_blob_id = 'LAST_INSERT_ROWID()'

    active_storage_blob_statement = ActiveRecord::Base.connection.raw_connection.prepare(<<-SQL)
      INSERT INTO active_storage_blobs (
        key, filename, content_type, metadata, byte_size,
        checksum, created_at
      ) VALUES (?, ?, ?, '{}', ?, ?, ?)
    SQL

    active_storage_attachment_statement = ActiveRecord::Base.connection.raw_connection.prepare(<<-SQL)
      INSERT INTO active_storage_attachments (
        name, record_type, record_id, blob_id, created_at
      ) VALUES (?, ?, ?, #{get_blob_id}, ?)
    SQL

    models = ActiveRecord::Base.descendants.reject(&:abstract_class?)

    transaction do
      models.each do |model|
        attachments = model.column_names.map do |c|
          if c =~ /(.+)_file_name$/
            $1
          end
        end.compact

        model.find_each.each do |instance|
          attachments.each do |attachment|
            active_storage_blob_statement.execute(
              key(instance, attachment),
              instance.send("#{attachment}_file_name"),
              instance.send("#{attachment}_content_type"),
              instance.send("#{attachment}_file_size"),
              checksum(instance.send(attachment)),
              instance.updated_at.iso8601
            )

            active_storage_attachment_statement.
              execute(attachment, model.name, instance.id, instance.updated_at.iso8601)
          end
        end
      end
    end

    active_storage_attachment_statement.close
    active_storage_blob_statement.close
  end

  def down
    raise ActiveRecord::IrreversibleMigration
  end

  private

  def key(instance, attachment)
    SecureRandom.uuid
    # または以下を使用
    # instance.send("#{attachment}_file_name")
  end

  def checksum(attachment)
    # ディスク上に保存されたローカルファイル
    url = attachment.path
    Digest::MD5.base64digest(File.read(url))

    # 別のコンピュータに保存されたリモートファイル
    # url = attachment.url
    # Digest::MD5.base64digest(Net::HTTP.get(URI(url)))
  end
end

4. ファイルをコピーする

上記のマイグレーションではファイルはそのままになります。しかしPaperclipとActiveStorageでは、ファイルのデフォルトの置き場所が異なっています。

Paperclipの場合、デフォルトでは以下のように置かれます。

public/system/users/avatars/000/000/004/original/the-mystery-of-life.png

ActiveStorageの場合は以下のようになります。

storage/xM/RX/xMRXuT6nqpoiConJFQJFt6c9

このxMRXuT6nqpoiConJFQJFt6c9active_storage_blobs.keyの値に該当します。上のマイグレーションでは単にファイル名を使いましたが、代わりにUUIDを使うこともできます。

外部のファイルホスト(S3、Azure Storage、GCSなど)に配置されているファイルの移行は、本ドキュメントの冒頭の説明の範囲外となります。ローカルディスクストレージがあるかどうかの探索は以下のようになります。

#!bin/rails runner

class ActiveStorageBlob < ActiveRecord::Base
end

class ActiveStorageAttachment < ActiveRecord::Base
  belongs_to :blob, class_name: 'ActiveStorageBlob'
  belongs_to :record, polymorphic: true
end

ActiveStorageAttachment.find_each do |attachment|
  name = attachment.name

  source = attachment.record.send(name).path
  dest_dir = File.join(
    "storage",
    attachment.blob.key.first(2),
    attachment.blob.key.first(4).last(2))
  dest = File.join(dest_dir, attachment.blob.key)

  FileUtils.mkdir_p(dest_dir)
  puts "Moving #{source} to #{dest}"
  FileUtils.cp(source, dest)
end

5. アプリのテストを更新する

have_attached_fileに代わる独自のマッチャーを書く必要があります。以下は、Paperclipが提供するマッチャーとコンセプト的に近いマッチャーです。

RSpec::Matchers.define :have_attached_file do |name|
  matches do |record|
    file = record.send(name)
    file.respond_to?(:variant) && file.respond_to?(:attach)
  end
end

6. ビューを更新する

Paperclipの場合は以下のような感じになります。

image_tag @user.avatar.url(:medium)

ActiveStorageの場合は以下のような感じになります。

image_tag @user.avatar.variant(resize: "250x250")

7. コントローラを更新する

コントローラの更新は不要なはずです。しかし、上述のデータベーススキーマをチェックしてみるとJOINがあるかもしれません。

たとえばコントローラが次のようになっているとします。

def index
  @users = User.all.order(:name)
end

ビューは次のようになっているとします。

<ul>
  <% @users.each do |user| %>
    <li><%= image_tag user.avatar.variant(resize: "10x10"), alt: user.name %></li>
  <% end %>
</ul>

ループ内で添付ファイルを毎回読み込んでいるので、これではN+1問題が発生してしまいます。

そこで、コントローラやモデルが変更なしでも問題なく動作しているとしても、ループを再チェックして必要ならincludesを足したいと思うことでしょう。ActiveStorageは、avatar_attachmentリレーションシップとavatar_blobリレーションシップをhas-oneリレーションに追加し、avatar_attachmentsリレーションとavatar_blobsリレーションをhas-manyに追加します。

def index
  @users = User.all.order(:name).includes(:avatar_attachment)
end

8. アプリのモデルを更新する

Rails edgeguidesのファイルをレコードに添付する方法に沿って進めます。たとえば、avatarを1つ持つUserは次のように表されます。

class User < ApplicationRecord
  has_one_attached :avatar
end

すべてのリサイズは、ビューでvariantとして行われます。

9. Paperclipを除去する

GemfileからPaperclip gemを削除してbundleを実行します。テストを実行して完了を確認してください。

関連記事

Rails 5.2を待たずに今すぐActiveStorageを使ってみた(翻訳)

[Rails 5]実は不要なgem・使われなくなりつつあるgem(2017年版)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。