1
Help us understand the problem. What are the problem?

posted at

Active Storageの複数ファイルを1つのzipファイルに圧縮する効率の良い方法

とある案件で、Active Storageで管理しているファイルを複数選択して、1つのzipファイルに圧縮する要件があったので実装方法を検討しました。

前提

この記事では下記のUserモデルのsave_selected_avatarsメソッドの実装方法を検討します。

user.rb
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

まずは愚直に実装してみます。下記の手順を考えてみました。

  1. Active Storageのファイルをローカルにダウンロードする
  2. 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

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
1
Help us understand the problem. What are the problem?