PaperclipからActiveStorageに移行した話

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

なおこの記事は[Rails Advent Calendar 2020](https://qiita.com/advent-calendar/2020/rails)の18日目のエントリーです。

# 動機

2018年4月リリースのRails 5.2で[ActiveStorage](https://guides.rubyonrails.org/active_storage_overview.html)が登場し、ほどなく[Paperclip](https://github.com/thoughtbot/paperclip)のdeprecationが発表され早いもので2年以上が経ちました。Paperclipはこれ以上メンテされないわけなので、別の手段を検討していく必要があります。

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

# 検討した移行先

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


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

### Pros
- Railsの一部であり、今後Rails界隈でデファクトスタンダードとなっていくことが期待される
- 同様にアクティブなメンテナンスが継続する期待がある
- harutakaで既に部分的に使われている実績がある
- Paperclipがofficialに移行先として指定しており、[移行手順](https://github.com/thoughtbot/paperclip/blob/master/MIGRATING.md)もある

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

## [kt-paperclip](https://github.com/kreeti)
Kreeti社によりメンテされているPaperclipのforkです。

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

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

## [CarrierWave](https://github.com/carrierwaveuploader/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といった様々なストレージバックエンドを差し替えられるような作りとなっているので、

```ruby
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で

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

## URLを受け取りデータを保存する機能
Paperclipは、ファイルそのものではなくURLを受け取るとその[URLからデータをダウンロードし保存するという機能](https://github.com/thoughtbot/paperclip/wiki/Attachment-downloaded-from-a-URL)があります。これはPaperclipのIOAdapterのひとつ、[UriAdapter](https://github.com/thoughtbot/paperclip/blob/master/lib/paperclip/io_adapters/uri_adapter.rb)として実装されているのですが、同様の仕組みはActiveStorageにはないためパッチとして実装する必要があります。

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

```ruby
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](https://github.com/thoughtbot/paperclip/wiki/Interpolations)によりファイル保存先のpathを非常に柔軟性高く指定することを可能としています。一方、ActiveStorageはそういったカスタマイズの余地はなく、ファイルの保存先pathは常に`generate_unique_secure_token`により生成されたランダム生成された文字列となります。
ActiveStorageはかなり強い意志をもってこの対応を入れないことを選択しているようで、過去に寄せられている[PR](https://github.com/rails/rails/pull/33285)も却下しており将来的にも入る見込みはなさそうです…。

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

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

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

```ruby
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は[サムネイル画像の生成](https://github.com/thoughtbot/paperclip/wiki/Thumbnail-Generation)についてstyleという概念を持っており、生成する画像サイズに名前をつけることができます。

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

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

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

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

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

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

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

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

```ruby
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側で使われていたカラムにも値を書き込む処理を入れてみます。

モデルで

```ruby
has_one_attached :image
after_save { image.replicate_for_paperclip! }
```
としておいて、ActiveStorage::Attached::Oneをパッチし

```ruby
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アプリケーションをお持ちの方も少なからずおられると思うので、この記事が参考になれば幸いです。

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