とある案件で、Active Storageで管理しているファイルを複数選択して、1つのzipファイルに圧縮する要件があったので実装方法を検討しました。
前提
この記事では下記のUserモデルのsave_selected_avatars
メソッドの実装方法を検討します。
class User < ApplicationRecord
has_one_attached :avatar
class << self
def save_selected_avatars(user_ids)
# TODO: user_idsで指定したユーザーのavatarを1つのzipファイルに圧縮して保存する
end
end
end
なお、この記事では下記のバージョンを使って動作確認しています。
ZIPファイルを生成する処理はrubyzipのGemを使います。
- ruby 3.1.1
- rails, activestorage 7.0.2.3
- rubyzip 2.3.2
実装方法検討
その1
まずは愚直に実装してみます。下記の手順を考えてみました。
- Active Storageのファイルをローカルにダウンロードする
- rubyzipを使ってZIPファイルを生成する
それぞれ実装していきます。
Active Storageのファイルをローカルにダウンロードする
ActiveStorageにはdownload
メソッドがあるので、それを使ってファイルを取得してローカルに保存します。
以下が実装です。
def save_selected_avatars(user_ids)
# Active Storageのファイルをローカルにダウンロードする
tmp_dir = Rails.root.join('tmp', 'avatars')
FileUtils.mkdir_p(tmp_dir)
where(id: user_ids).each do |user|
File.open(tmp_dir.join(user.avatar.filename.to_s), 'wb') do |file|
user.avatar.download { |chunk| file.write(chunk) }
end
end
# TODO: rubyzipを使ってZIPファイルを生成する
end
rubyzipを使ってZIPファイルを生成する
次にzipファイルを生成する処理を実装します。
前提に記載した通りrubyzip
を利用します。
READMEのBasic zip archive creation
に記載されている方法を参考に実装します。
以下が実装です。
def save_selected_avatars(user_ids)
# Active Storageのファイルをローカルにダウンロードする
tmp_dir = Rails.root.join('tmp', 'avatars')
FileUtils.mkdir_p(tmp_dir)
where(id: user_ids).each do |user|
File.open(tmp_dir.join(user.avatar.filename.to_s), 'wb') do |file|
user.avatar.download { |chunk| file.write(chunk) }
end
end
# rubyzipを使ってZIPファイルを生成する
Zip::File.open(Rails.root.join('tmp', 'avatars.zip'), create: true) do |zipfile|
Dir.glob(tmp_dir.join('*')).each do |path|
zipfile.add(File.basename(path), path)
end
end
end
上記の実装をローカルで動作確認したところ、正しくzipファイルが作られることが確認できました。
その2
[その1]で動くものはできましたが、Active Storageのファイルを一度ローカルに保存する処理が冗長に感じます。
改めてziprubyのREADMEを眺めると、OutputStream
というzipファイルに直接データを流し込めそうな機能が記載されています。
Modify docx file with rubyzip
にサンプルコードが記載されていたので下記に転記します。
下記はワードファイルを変更する処理ぽいですが、参考にすればファイルをローカルに保存せずにzipファイルを作れそうです。
buffer = Zip::OutputStream.write_buffer do |out|
@zip_file.entries.each do |e|
unless [DOCUMENT_FILE_PATH, RELS_FILE_PATH].include?(e.name)
out.put_next_entry(e.name)
out.write e.get_input_stream.read
end
end
out.put_next_entry(DOCUMENT_FILE_PATH)
out.write xml_doc.to_xml(:indent => 0).gsub("\n","")
out.put_next_entry(RELS_FILE_PATH)
out.write rels.to_xml(:indent => 0).gsub("\n","")
end
File.open(new_path, "wb") {|f| f.write(buffer.string) }
サンプルを参考に書き直してみます。以下が実装です。
def save_selected_avatars(user_ids)
buffer = Zip::OutputStream.write_buffer do |out|
where(id: user_ids).each do |user|
out.put_next_entry(user.avatar.filename.to_s)
user.avatar.download { |chunk| out.write(chunk) }
end
end
File.open(Rails.root.join('tmp', 'avatars.zip'), 'wb') { _1.write(buffer.string) }
end
サンプルを少し修正するだけでサクッと実装することができました。README等にサンプルプログラムが載っていると親切でいいですね!
Active Storageのdownloadで取得したデータをOutputStreamに直接流し込んでいるため、ローカルへの保存がなくなりました。
また、冗長な処理がなくなったおかげでコードもスリムになりました!
その3
[その2]でローカル保存が不要になり処理がスリムになりましたが、コードを眺めると微妙な箇所があります。
それはbuffer.string
の箇所です。
この処理でzipの元となるデータがstringとして一括でメモリに展開されてしまうため、データ量が多い時にメモリを圧迫してしまう可能性があります。
今回実現したい仕様の場合、容量の大きいファイルを複数選択されることでデータ量が多くなる可能性があります。
ということで、一括でメモリに展開されてないように修正します。
何か良い方法がないか探るためOutputStream
クラスを確認してみます。
確認したところ、openというメソッドを見つけました。
下記にopenメソッドを転記しました。write_bufferとは異なり保存するzipファイルのパスを指定するようです。
def open(file_name, encrypter = nil)
return new(file_name) unless block_given?
zos = new(file_name, false, encrypter)
yield zos
ensure
zos.close if zos
end
openを使って書き直してみます。以下が実装です。
def save_selected_avatars(user_ids)
Zip::OutputStream.open(Rails.root.join('tmp', 'avatars.zip')) do |out|
where(id: user_ids).each do |user|
out.put_next_entry(user.avatar.filename.to_s)
user.avatar.download { |chunk| out.write(chunk) }
end
end
end
これでzipのデータが一括でメモリに展開されずにチャンクごとにファイルに書き込まれるようになりました。
その4
[その3]で仕様を満たしてメモリも圧迫しない実装ができたと思ったのですが、テストしていたら2つ微妙な点が見つかりました。
1点目
active_storage_attachmentsやactive_storage_blobsの読み込みがファイルの数だけ実行される(N+1)。
以下にログの一部を載せています。
ActiveStorage::Attachment Load (1.0ms) SELECT `active_storage_attachments`.* FROM `active_storage_attachments` WHERE `active_storage_attachments`.`record_id` = 1141 AND `active_storage_attachments`.`record_type` = 'User' AND `active_storage_attachments`.`name` = 'avatar' LIMIT 1
ActiveStorage::Blob Load (0.8ms) SELECT `active_storage_blobs`.* FROM `active_storage_blobs` WHERE `active_storage_blobs`.`id` = 1141 LIMIT 1
Disk Storage (8.6ms) Downloaded file from key: v5domfta2q1btbbmyrugwc91j36l
ActiveStorage::Attachment Load (0.9ms) SELECT `active_storage_attachments`.* FROM `active_storage_attachments` WHERE `active_storage_attachments`.`record_id` = 1142 AND `active_storage_attachments`.`record_type` = 'User' AND `active_storage_attachments`.`name` = 'avatar' LIMIT 1
ActiveStorage::Blob Load (0.7ms) SELECT `active_storage_blobs`.* FROM `active_storage_blobs` WHERE `active_storage_blobs`.`id` = 1142 LIMIT 1
Disk Storage (4.0ms) Downloaded file from key: 0kgojfdxhitq25s8lnholxlx5pyq
...
これはプリロードしておけば解決です。
Active Storageのモデルにはwith_attached_#{file}
というスコープが自動で定義されるので使います。
下記のようにデータ取得部分を修正しました。
- where(id: user_ids).each do |user|
+ where(id: user_ids).with_attached_avatar.each do |user|
この対応でN+1は解消しました。
以下は解消後のログです。
ActiveStorage::Attachment Load (4.1ms) SELECT `active_storage_attachments`.* FROM `active_storage_attachments` WHERE `active_storage_attachments`.`record_type` = 'User' AND `active_storage_attachments`.`name` = 'avatar' AND `active_storage_attachments`.`record_id` IN (1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179)
ActiveStorage::Blob Load (1.2ms) SELECT `active_storage_blobs`.* FROM `active_storage_blobs` WHERE `active_storage_blobs`.`id` IN (1170, 1171, 1172, 1173, 1174, 1175, 1176, 1177, 1178, 1179)
Disk Storage (2.9ms) Downloaded file from key: e0cdhumkm4ecwqvzfkx0g74h4dg4
2点目
同じファイル名のファイルがあると、上書きされてしまうことがわかりました。
今回はファイル名にuser_idを付与することで回避。
- out.put_next_entry(user.avatar.filename.to_s)
+ out.put_next_entry("#{user.id}_#{user.avatar.filename}")
最終系
最終的には下記の実装になりました。
def save_selected_avatars(user_ids)
Zip::OutputStream.open(Rails.root.join('tmp', 'avatars.zip')) do |out|
where(id: user_ids).with_attached_avatar.each do |user|
out.put_next_entry("#{user.id}_#{user.avatar.filename}")
user.avatar.download { |chunk| out.write(chunk) }
end
end
end