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

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

# 前提

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

```ruby: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. Active Storageのファイルをローカルにダウンロードする
1. rubyzipを使ってZIPファイルを生成する

それぞれ実装していきます。

### Active Storageのファイルをローカルにダウンロードする
ActiveStorageには`download`メソッドがあるので、それを使ってファイルを取得してローカルに保存します。

https://apidock.com/rails/v6.1.3.1/ActiveStorage/Blob/download

以下が実装です。

```ruby
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`に記載されている方法を参考に実装します。

https://github.com/rubyzip/rubyzip

以下が実装です。

```ruby
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ファイルが作られることが確認できました。

## その２

[その1]で動くものはできましたが、Active Storageのファイルを一度ローカルに保存する処理が冗長に感じます。

改めてziprubyのREADMEを眺めると、`OutputStream`というzipファイルに直接データを流し込めそうな機能が記載されています。
`Modify docx file with rubyzip`にサンプルコードが記載されていたので下記に転記します。
下記はワードファイルを変更する処理ぽいですが、参考にすればファイルをローカルに保存せずにzipファイルを作れそうです。

```ruby
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) }
```

サンプルを参考に書き直してみます。以下が実装です。

```ruby
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に直接流し込んでいるため、ローカルへの保存がなくなりました。
また、冗長な処理がなくなったおかげでコードもスリムになりました！

## その３

[その2]でローカル保存が不要になり処理がスリムになりましたが、コードを眺めると微妙な箇所があります。

それは`buffer.string`の箇所です。
この処理でzipの元となるデータがstringとして一括でメモリに展開されてしまうため、データ量が多い時にメモリを圧迫してしまう可能性があります。
今回実現したい仕様の場合、容量の大きいファイルを複数選択されることでデータ量が多くなる可能性があります。
ということで、一括でメモリに展開されてないように修正します。

何か良い方法がないか探るため`OutputStream`クラスを確認してみます。
確認したところ、[open](https://github.com/rubyzip/rubyzip/blob/v2.3.2/lib/zip/output_stream.rb#L50)というメソッドを見つけました。
下記にopenメソッドを転記しました。write_bufferとは異なり保存するzipファイルのパスを指定するようです。

```ruby
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を使って書き直してみます。以下が実装です。

```ruby
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}`というスコープが自動で定義されるので使います。
下記のようにデータ取得部分を修正しました。

```diff
- 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を付与することで回避。

```diff
- out.put_next_entry(user.avatar.filename.to_s)
+ out.put_next_entry("#{user.id}_#{user.avatar.filename}")
```

### 最終系
最終的には下記の実装になりました。

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