こんにちは。@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アプリケーションをお持ちの方も少なからずおられると思うので、この記事が参考になれば幸いです。
皆様のファイルアップロードライフがよいものでありますように!