LoginSignup
8
5

More than 3 years have passed since last update.

PaperclipからActiveStorageに移行した話

Posted at

こんにちは。@mshibuyaです。
現在副業として株式会社ZENKIGENさんのお手伝いをしておりまして、Web面接サービスharutakaのRailsまわりの改善を担当しています。今回はそちらで行ったPaperclipからActiveStorageへの移行におけるあれこれの話をしたいと思います。

なおこの記事はRails Advent Calendar 2020の18日目のエントリーです。

動機

2018年4月リリースのRails 5.2でActiveStorageが登場し、ほどなくPaperclipのdeprecationが発表され早いもので2年以上が経ちました。Paperclipはこれ以上メンテされないわけなので、別の手段を検討していく必要があります。

harutakaにおいてもPaperclipを長く利用してきており、新たな手段への移行を模索し既に部分的にActiveStorageを利用する構成となっていました。とはいえPaperclipを利用する部分がそのまま残っておりActiveStorageと併存している状態もメンテナンス上好ましくないので、このたび全体を新方式へと刷新することとしました。

検討した移行先

ファイルアップロード機能を提供するライブラリとしていくつかの選択肢があるので、それぞれの特徴を整理し移行先候補を選定しました。

ActiveStorage

前述の通り、Railsの標準機能の一部として実装されたファイルアップロード機能です。

Pros

  • Railsの一部であり、今後Rails界隈でデファクトスタンダードとなっていくことが期待される
  • 同様にアクティブなメンテナンスが継続する期待がある
  • harutakaで既に部分的に使われている実績がある
  • Paperclipがofficialに移行先として指定しており、移行手順もある

Cons

  • PaperclipやCarrierWaveと比較して機能が劣る
  • Opinionatedな作りであり、思想にマッチしない使い方をすると苦労しそう

kt-paperclip

Kreeti社によりメンテされているPaperclipのforkです。

Pros

  • 実績がある枯れたライブラリであるPaperclipを踏襲している
  • harutakaでも主要部分に使われているため移行の手間が少ない
  • それなりに多機能

Cons

  • 本家によるメンテナンスではなくfork版であり、今後の先行きが不透明

CarrierWave

Paperclipに次いでメジャーなファイルアップロードライブラリですね。

Pros

  • 多機能
  • @mshibuyaがメンテナなのでなにかあっても安心?

Cons

  • 若干使い方が複雑
  • harutakaでの利用実績がなく、まったく新規での導入になる

以上を総合的に踏まえ、既存のPaperclipによる実装をActiveStorage化することでActiveStorageへの一本化を行うこととしました。

移行にあたっての方針

使い勝手をなるべく既存のものに近づけたい

フルタイムで開発に関わっているわけでない立場上、他の開発者の方々に過度に負担かけたくないという意図がありました。

何か問題があったときに切り戻し可能にしたい

画像や動画を保存・閲覧できる機能はharutakaの中でも重要度の高い部分であるので、今回の移行において不具合等が本番リリース後にあったようなときはすぐにPaperclip実装に切り戻して普通に利用を続けられることを目指しました。

S3に既に保存されているデータの移行はせずに済ませたい

保存されたデータの移行をするのは時間がかかり、その間のシステム利用を止めるか移行中のデータ更新を反映できる仕組みを用意する必要があるので考えることが増えるため、少なくとも移行初期のタイミングでは行いたくないと考えました。

そのため、不足する機能についてはActiveStorageにパッチを当てることでなんとかすることを目指すわけですが…

ActiveStorageに足りなかった機能

まぁここでActiveStorageのシンプルかつopinionatedな作りにより色々と足りない機能が出てきます。どんな機能が足りなかったか、それをどうしたかをご紹介していくことにします。

なおここで例示しているコードはActiveStorage 5.2を想定しています。他のバージョンではそのまま動かないかもしれないのでよしなに読み替えていただければと思います。

CloudFrontの署名付きURLを利用してのファイル配信

まず、ActiveStorageはS3をバックエンドにしてのデータ保存および配信にはもちろん対応しているのですが、意外にもCloudFrontを利用した配信については標準ではサポートしていません。

とはいえこれの解決策は比較的簡単です。ActiveStorageにはserviceとしてローカルディスク・S3といった様々なストレージバックエンドを差し替えられるような作りとなっているので、

require 'active_storage/service/s3_service'

module ActiveStorage
  class Service::CloudFrontService < ActiveStorage::Service::S3Service
    def url(key, expires_in:, filename:, disposition:, content_type:)
      instrument :url, key: key do |payload|
        generated_url = Aws::CF::Signer.sign_url "https://#{CLOUD_FRONT_HOST}/#{key}"
        payload[:url] = generated_url
        generated_url
      end
    end
  end
end

のようにS3Serviceを継承する形でCloudFrontServiceを作り、storage.ymlで

production:
  service: CloudFront
  access_key_id: xxx
  secret_access_key: xxx
  ...

とすると「S3にファイルを保存し、CloudFrontの署名つきURLで配信」という状態が作れます。
(※ここではcloudfront-signer gemを使っていて、その設定は別途必要です)

URLを受け取りデータを保存する機能

Paperclipは、ファイルそのものではなくURLを受け取るとそのURLからデータをダウンロードし保存するという機能があります。これはPaperclipのIOAdapterのひとつ、UriAdapterとして実装されているのですが、同様の仕組みはActiveStorageにはないためパッチとして実装する必要があります。

イメージこんな感じですね。ActiveStorage::Attachedをモンキーパッチします。

ActiveStorage::Attached.prepend Module.new {
  def create_blob_from(attachable)
    case attachable
    when String
      uri = URI.parse(attachable) rescue nil
      if uri.is_a?(URI::HTTP)
        file = DownloadedFile.new uri
        ActiveStorage::Blob.create_after_upload! \
          io: file.io,
          filename: file.filename,
          content_type: file.content_type
      elsif attachable.present?
        super
      end
    else
      super
    end
  end
}

class DownloadedFile
  attr_reader :io

  def initialize(uri)
    @uri = uri
    @io = uri.open
  end

  def content_type
    @io.meta["content-type"].presence
  end

  def filename
    CGI.unescape(@uri.path.split("/").last || '')
  end
end

S3への保存先pathのカスタマイズ

PaperclipはURL Interpolationによりファイル保存先のpathを非常に柔軟性高く指定することを可能としています。一方、ActiveStorageはそういったカスタマイズの余地はなく、ファイルの保存先pathは常にgenerate_unique_secure_tokenにより生成されたランダム生成された文字列となります。
ActiveStorageはかなり強い意志をもってこの対応を入れないことを選択しているようで、過去に寄せられているPRも却下しており将来的にも入る見込みはなさそうです…。

なのでパッチしてなんとかします。モデル側でこのようにkeyを生成するprocを渡せるようにした上で、

  has_one_attached :image, key: -> (filename) { "files/image/#{record.class.generate_unique_secure_token}/#{filename}" }

このprocをActiveStorage::Blobまで引き回し

ActiveSupport.on_load(:active_storage_blob) do
  prepend Module.new {
    def key
      self[:key] ||= if attachment
        key_proc = options[:key] # 引き回してきたやつ
        (key_proc && attachment.instance_exec(filename, &key_proc)) || super
      else
        super
      end
    end
  }
end

と値がなければprocをinstance_execするようにして望み通りのkeyを生成します。

名前つきのstyle

Paperclipはサムネイル画像の生成についてstyleという概念を持っており、生成する画像サイズに名前をつけることができます。

has_attached_file :photo, styles: {thumb: "100x100#"}

ActiveStorageももちろんサムネイル生成に対応しているのですが、こちらは画像保存時ではなく利用時に動的にサイズを渡し生成する方式です。

<%= image_tag user.avatar.variant(resize: "100x100").service_url %>

でも、名前がついている方が用途がわかりやすいですし変に似たようなサイズの画像が乱立してしまうのを防げるので、こうできるようにしたいですよね?

<%= image_tag user.avatar.variant(:thumb).service_url %>

そこでパッチします。モデル側から

  has_one_attached :image, variants: {thumb: "100x100#"}

こんな風に指定できるようにした上でまたこのoptionsをActiveStorage::Blobまで引き回して

ActiveSupport.on_load(:active_storage_blob) do
  prepend Module.new {
    def variants
      options[:variants] || {} # 引き回したやつ
    end

    def variant(style_or_transformations)
      if style_or_transformations.try(:to_sym) == :original
        self
      elsif variable? && variants[style_or_transformations]
        Variant.new(self, variants[style_or_transformations])
      else
        super
      end
    end
  }
end

とすることで実現できます。

Paperclipのカラムに値を保存する

ActiveStorage実装をリリースしてしばらく使った後になにか問題が発覚して切り戻しを行う場面を想定します。ストレージバックエンドであるS3はPaperclip/ActiveStorageで共通して使うので問題ないとして、ActiveStorage移行後なので新規にアップロードされたファイルについてはActiveStorage側のテーブル(モデルでいうとActiveStorage::AttachmentおよびActiveStorage::Blob)には値が入っているものの、Paperclip側で使われていた各モデルのカラム(*_file_name, *_file_size…など)には値が入らない状態になります。

これでは切り戻しの際にはActiveStorage側からPaperclip側へ逆データ移行する作業が必要になってしまいます。それを防ぐため、ActiveStorage側にファイルをアップロードしたらPaperclip側で使われていたカラムにも値を書き込む処理を入れてみます。

モデルで

has_one_attached :image
after_save { image.replicate_for_paperclip! }

としておいて、ActiveStorage::Attached::Oneをパッチし

ActiveStorage::Attached::One.prepend Module.new {
  def replicate_for_paperclip!
    return unless attached?
    attributes = {}
    attributes["#{name}_file_name"] = filename.to_s if record.attributes.has_key?("#{name}_file_name")
    attributes["#{name}_content_type"] = content_type if record.attributes.has_key?("#{name}_content_type")
    attributes["#{name}_file_size"] = byte_size if record.attributes.has_key?("#{name}_file_size")
    attributes["#{name}_updated_at"] = blob.created_at if record.attributes.has_key?("#{name}_updated_at")
    record.assign_attributes(attributes)
    record.save! if record.changed?
  end
}

これでActiveStorageアップロード時にPaperclip側カラムにも値を埋めておけるようになります。

まとめ

PaperclipからActiveStorageへの移行を行ったこと、そこでActiveStorageに不足している機能をどのように補ったかをご紹介しました。上記方針により、アプリケーションの土台に関わる大きな変更ながらもなるべく低リスクで実施可能なよう作り上げることができたのではないかと考えています。
(とはいえ本番リリースはまだこれからなのですが。何も問題起こらないといいな…)

Paperclipを使い続けてきており、今後どうするか決まっていないRailsアプリケーションをお持ちの方も少なからずおられると思うので、この記事が参考になれば幸いです。

皆様のファイルアップロードライフがよいものでありますように!

8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5