Dragonfly attachmentを持つレコードをコピーする場合には注意が必要

Dragonfly については以前紹介した通りで、普段触っているアプリではこれを使っている。表示のタイミングで変換が走るのは高負荷のサイトには向かないかもしれないけど、使い勝手には満足している。

で、今回はこの Dragonfly を使っている場合に気をつける必要のあるユースケースを見つけたのでまとめておこうと思う。

ActiveRecord + Dragonflyでの画像の削除のタイミング

Paperclip もそうかもしれないけど、Dragonfly はよくできていて

  • レコードの削除
  • レコード内の Attachment の変更

のタイミングで Attach していたファイルをちゃんと掃除してくれる。これによって無駄なファイルが残ってしまうといったことがなくなる。

そしてこの処理は ActiveRecord で言うと

before_save

のタイミングで実行される。

削除時にはファイルの参照はチェックしないので自前で

今回ハマったのは

  • コピーしたレコードで
  • 画像を変更したら
  • 他のレコードも参照していたファイルが消えてしまった!

というものです。

上の説明を読んでいればとても当たり前の話なんだけど、まぁそこはそれ。回避するには以下のいずれかの方法がありそう。

  1. 参照カウンタよろしく、同一の uid を持つレコードが before_save の段階で 2以上あったら削除を無効化
  2. コピーの段階で実体のファイルもコピー

今回使ったのは 1 の方法。

削除を無効化する具体的な方法

※ 以下のコードは dragonfly 0.9.12 のものであり、また回避用のコードは実際に動いているものとよく似ているけれど動作検証はしていません。

dragonfly の画像の削除は知らない間に行われるので、callback で処理しているに違いない。ということで、before_* で検索してみる。

すると

lib/dragonfly/active_model_extensions/class_methods.rb

           before_save :save_dragonfly_attachments
           before_destroy :destroy_dragonfly_attachments

こんな記述が見つかる。探すと今度は

lib/dragonfly/active_model_extensions/instance_methods.rb

     def save_dragonfly_attachments
       dragonfly_attachments.each do |attribute, attachment|
         attachment.save!
       end
     end

     def destroy_dragonfly_attachments
       dragonfly_attachments.each do |attribute, attachment|
         attachment.destroy!
       end
     end

という記述が見つかる。これはそれぞれ

lib/dragonfly/active_model_extensions/attachment.rb

の中にある

     def destroy!
       destroy_previous!
       destroy_content(uid) if uid
     end

     def save!
       sync_with_model
       store_job! if job && !uid
       destroy_previous!
       self.changed = false
       self.retained = false
     end

のようだ。destroy! は期待通りに動いているので、save! でも同様に呼び出されている

     def destroy_previous!
       if previous_uid
         destroy_content(previous_uid)
         self.previous_uid = nil
       end
     end

があやしい。そこでこんなことをしてみた。

class Photo < ActiveRecord::Base

  image_accessor :image

  after_validation do
    if self.image_uid_was and
       self.class.where(:image_uid => self.image_uid_was).count > 1
      self.image.instance_eval {
        def destroy_previous!
          ;
        end
      }
    end
  end

end
  • 例として Photo という Model を用意し
  • その中の image という attribute に画像を保存
  • ファイルを変更しようとしており(photo_uid_was が nil でなかったら)、同じ uid を参照しているものが他に存在していたら
  • destroy_previous! を無効化

している。

今まさに保存しようとしているインスタンスだけ destroy_previous! の中身を空にしてしまう黒魔術。

すでに入っている before_save の callback の前にこれを追加することができるならそれでいいと思う。やり方が分からなかったので、before_save よりも前に呼ばれる after_validation に突っ込んだ。

after_validation から呼ぶなら before_save そのものを skip するという手も使えそうだけど、他に callback をセットしてるとそれもややこしいので、いちばん影響なさそうで手っ取り早い黒魔術で片付けることにした。

これでコピーしたレコードの画像を差し替えても他のレコードでロストしない。

More