こんにちは! SmartHR エンジニアの @gongoZ です。
Ruby on Rails 5.2 より、ファイルアップロードおよびそれらのファイルと ActiveRecord との関連付けを容易にする ActiveStorage という仕組みが導入されました。
お手軽で便利な仕組みなのですが、とある問題に遭遇した際「ここ、もう少しシュッと書けないものだろうか…?」と試行錯誤しました。 本記事ではその問題となったケースと、実際に行った解決策について紹介していきたいと思います。
概要
- 紹介したいこと
- ActiveStorage のファイルを削除する場合に ActiveRecord トランザクションと組合わせる場合は、 purge よりも detach が相性が良い
- ActiveStorage::Blob.unattached を活用していく
- 想定する環境
- Ruby on Rails 5.2.1
前提(例題)
突然ですが、下記のような User クラスを想定します。
class User < ApplicationRecord has_one_attached :avatar validates :name, presence: true end user = User.create(name: '堀裕子') user.avatar.attach( io: File.open('/path/to/顔写真.png'), filename: '顔写真.png' )
本記事では ActiveStorage の詳しい説明は割愛しますが、この User クラスと attach した ActiveStorage、アップロードした実体ファイルは概ね下図のような関係になります。
📝 アップロードしたファイルは Amazon S3 に保存することを想定しています
例えばこの状態で、アップロードしたファイルを削除する purge
メソッド:
User.find(1).avatar.purge
を実行すると、各テーブルは下記のように変化します。
User.find(1)
が持つ avatar に紐付いていた attachments を削除- attachments が紐付いていた blobs を削除
- blobs が参照していたファイルを削除
ここでは ActiveRecord クラスとアップロードしたファイル、それらを関連付ける2つのテーブルが存在することを覚えておいてください。
遭遇した問題
ActiveStorage は上記の特性を持つという前提で、実際に私が悩んだロジックについて紹介していきます。
1. purge を無かったことにしたい
下記の仕様を満たしたい! というケースにおいて、つい書いてしまいがちなコードについて考えます。
user = User.find(1) # # 指定したユーザの avatar を削除しつつ name を変更したい # しかし name 更新に失敗したら画像削除は無かったことにしたいな # ApplicationRecord.transaction do user.avatar.purge # (1) user.name = params[:name] # == '' user.save! end # (2) rollback user.avatar.attached? # => true user.avatar.download # (3) ... ????
一見問題なさそうですが、致命的なバグが存在しています。
上記のコードだと (2) で rollback が発生し、その効果で active_storage_attachments
や active_storage_blobs
のレコードは復活しますが、 実体ファイルは復活しません 。
実体ファイルはローカルディスクや S3など、RDB のトランザクションの外で削除処理が発生しているため、 ApplicationRecord.transaction do ; end
では rollback できません。こうなってしまうと、いわゆるデータ不整合状態となります。
user.avatar.attached? # => attachments や blobs レコードは存在するため true user.avatar.download # => S3 なら Aws::S3::Errors::AccessDenied # => ローカルなら Errno::ENOENT
📝 余談
実は save!
と purge
の順序を逆にすると、このケースは解決します 🤗
ApplicationRecord.transaction do - user.avatar.purge user.name = params[:name] user.save! + user.avatar.purge end
2. purge を禁止したい
それでは次のケースはどうでしょうか。
class User < ApplicationRecord has_one_attached :avatar validates :name, presence: true validate :avatar_attached def avatar_attached errors.add(:avatar, :presence) if avatar.blank? end end
このように ActiveStorage もバリデーションの対象になっていると、save!
と purge
の順番を変えるだけで解決できません。
ApplicationRecord.transaction do user.name = params[:name] user.save! # # この purge で ActiveRecord::RecordInvalid は発生しないものの、 # 「avatar は存在すべき」というルールからは外れることになる # user.avatar.purge end
解決案
紹介した「問題になりやすいケース」の解決案として、本記事では下記に紹介する2つの技を合体させて対応していきます。
1. purge ではなく detach を使う
ActiveStorage には、ファイル削除の API として purge
の他に detach というものが存在します。
user = User.find(1) ApplicationRecord.transaction do user.avatar.detach # これ!! (1) user.name = params[:name] # == '' user.save! end # (2) rollback
detach
はactive_storage_attachments
のみを削除します- つまり ActiveRecord の rollback で回収できる
attached?
メソッドはactive_storage_attachments
レコードが存在しない時点でfalse
を返します- つまり「ファイルがアップロードされているか?」の validation を実施できる
この特性を利用することで、ファイルの削除(正確には実体ファイルとの関連付けの解消)を ActiveRecord のトランザクションに含めて記述できるようになり、より直感的になりました。
2. 浮いてしまったデータを定期的に削除する
上図を見るとわかりますが、 detach
を使うと active_storage_blobs
およびそれが参照している実体ファイルは残ったままになります。このままではディスクリソースの無駄遣いなので、定期的に掃除する必要があります。
その時に使えるコードがこちら:
ActiveStorage::Blob.unattached.find_each(&:purge)
ActiveStorage::Blob.unattached
は上図のような宙ぶらりんとなった blobs を取得する scope です。こいつを使って取得した blobs に対して purge
を呼び出すことで、定期的に掃除することが可能となります。
まとめ
本記事では ActiveStorage のファイル削除を、より便利にするための一例を紹介しました。
ActiveStorage はまだまだ新しい仕組みで、ActiveStorage 特有のバリデーション(ファイルサイズや拡張子の限定など)も、まだ自前で書く必要があります。
また、今回紹介できなかった便利機能などもあるため、上記の改善も合わせてより使い勝手も上がっていくことを期待しています。