Rails初心者がActiveStorage-S3 Variantについて調べてみた

## この記事について

以前に ActiveStorage-S3のダイレクトアップロード関連調査時に、ついでに調べた ActiveStorage の Variant 挙動についてまとめました。
特に、S3へのファイル保存時にどうしても任意のprefixをつけたかったので、その点を中心に調査を行いました。
初心者なので本当にそれでいいかはわからないですが、とりあえず意図したprefixをつける方法を見つけたので、記録に残します。

https://qiita.com/zagvym/items/a4f0524c94007317ac95

### 調査時環境

- Rails ~> v6.1
- ruby ~> v3.0
- aws-sdk-s3 >= v1.112, <v2.0


## ActiveStorage Variant について

ActiveStorage経由でアップロードした画像ファイルなどを、指定したサイズや形式に自動変換してくれる機能です。サムネイル画像化の際などに用いられる機能だと思われます。
`user.avatar` というユーザに紐づく画像ファイルがあるときに、 `user.avatar.variant` のように呼び出すと、いい感じに画像変換と変換結果ファイルの管理を行なってくれるようです。

利用例: オリジナルのアバター画像を100x100のサイズに変換して出力 （[出典](https://guides.rubyonrails.org/v6.1/active_storage_overview.html#transforming-images)）

```erb
<%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) %>
```


## ファイルアップロード時の挙動

view側で variant が指定されている場合、 model に対して添付ファイル（attachment）をアップロードした後、初回のviewアクセス時に variant が自動生成されます。

参考: https://guides.rubyonrails.org/v6.1/active_storage_overview.html#transforming-images
> When the browser hits the variant URL, Active Storage will lazily transform the original blob into the specified format and redirect to its new service location.

ActiveStorageのストレージとしてAWS S3を指定している場合、オリジナルのファイルと同様にS3に自動的にvariantファイルがアップロードされます。

この時生成される variant は、オリジナルのファイルとは別の ActiveStorage::Blob として扱われます。[以前に投稿した内容](https://qiita.com/zagvym/items/a4f0524c94007317ac95#s3%E3%81%B8%E3%81%AE%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E4%BF%9D%E5%AD%98%E6%99%82%E3%81%AE%E3%83%91%E3%82%B9%E5%90%8Dobject-key%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)の通り、ActiveStorage-S3は、特に指定がない場合 SecureRandomの文字列が、そのまま S3 のファイルパス（Object Key）として使用されます。
つまり、放っておくとS3のルートパス直下に一見謎なファイルがどんどん作成されていきます。

例: S3バケットのルートパス直下に溜まり続けるvariantファイルたち

```
$ aws s3 ls --human-readable s3://${MY_S3_BUCKET}
2022-02-28 19:33:52    8.6 KiB h5b4ml7pzgc0pfj6z0vd1nxhwbk8
2022-02-28 19:33:41    7.2 KiB n7cm3voxj45bh40tt42jdvu9pg6t
2022-02-24 19:49:56    9.7 KiB ng4daiibkb1ishea5bur1kknewd4
2022-02-28 19:33:53    8.7 KiB oqehnilywpwl4e9hxhnm3f66t1g7
...
```

### ActiveStorage-S3サービス利用時の処理フロー

AcitveStorage-S3のvariant生成フロー概要は下記の通りです。

1. オリジナルファイルをS3からダウンロードしてきて、Rails appが起動するマシン上に一時ファイルとして保存
2. Analyzerに渡してファイルタイプをチェックして、variant作成対象かどうか判定
3. Transformerに渡して、変換結果を新しいfile objectとして出力
4. 変換結果の file object を VariantRecordに変換
4.  DB Transaction を開いて関連レコードをCREATE
    - ActiveStorage::VariantRecord -> ActiveStorage::Blob -> ActiveStorage::Attachment
5. レコードがCOMMITされたら、S3に変換結果ファイルをアップロード

この時、S3へのファイルアップロードは、[ActiveStorage::Service::S3Service](https://github.com/rails/rails/blob/v6.1.4.6/activestorage/lib/active_storage/service/s3_service.rb#L8-L36) の upload() メソッドを呼び出すことで実現しています。
そして、この時作成された VariantRecordの `key` 列の値がS3のObject keyとしても利用されます。

## variant のS3保存時のファイル名にprefixを指定する

[以前の投稿](https://qiita.com/zagvym/items/a4f0524c94007317ac95#%E3%83%80%E3%82%A4%E3%83%AC%E3%82%AF%E3%83%88%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89)では、ダイレクトアップロード時にもS3ファイルパスprefixをつける方法を紹介しました。

しかし、前述の通りvariantの生成はファイルアップロードとは独立したフローのため、折角オリジナルファイルにつけたprefixがvariantの変換結果ファイルには反映されません。
そこで、なんとかvariantにも任意のprefixをつける方法を考えました。

ひとまず思いついたのが、Railsの不思議な力（モンキーパッチ）を利用して、 vagrantのModelを改造する、というものです。

[ActiveStorage::VariantWithRecord](https://github.com/rails/rails/blob/v6.1.4.6/activestorage/app/models/active_storage/variant_with_record.rb) の `transform_blob()` メソッドをoverrideすれば、変換結果のダイレクトアップロード時と同様に path prefix を指定可能です。

`app/models/active_storage/variant_with_record.rb`  改造例（抜粋）
ここでは、variation.transform によるオリジナルファイルの変換処理結果のfile object をActoveStorage::Blobにwrapする際に、明示的にkeyをセットするようにしています。

```ruby
 private
   def transform_blob
      blob.open do |input|
        variation.transform(input) do |output|
          # Variant file の Blob.key を明示的に指定
          # prefix: "activestorage/variants"
          #   => v_key: "activestorage/variants/xxxxxxxx/yyyyyyyy"
          v_key = File.join(@prefix, extract_blob_id(blob), ActiveStorage::Blob.generate_unique_secure_token) # 例: ${PREFIX}/xxxxxxxx/yyyyyyyy
          yield key: v_key, io: output, filename: "#{blob.filename.base}.#{variation.format.downcase}",
            content_type: variation.content_type, service_name: blob.service.name
        end
      end
    end

    def extract_blob_id(blob)
      File.basename(blob.key) # 例: /activestorage/blobs/service/xxxxxxxx => xxxxxxxx
    end
```

↑の例では、あらかじめ指定しておいたprefix文字列 `@prefix` と、変換元ファイル (`blob`) の情報を組み合わせて、実際に設定する prefix 文字列を作成しています。
`blob.key` で元ファイルのkey情報（== S3 Object Key）を取得できるので、この辺をうまく調整すれば、例えば元ファイルと同じprefixをつける、といった対応も可能そうです。

### 調査で苦労した点

RailsのデバッグログのSQLを見て、何者かがS3にアクセスしたり ActiveStorage::Blob Recordを作ったりしているのはわかりましたが、具体的にどのファイル、どのクラスがその操作を行なっているのかログに出す方法がわからず、改造対象を特定するのに手間取りました。

Gemで取り込んだ外部がSQLを発行した場合に、そのrubyファイル名やクラス名、行数をRailsのログに出力するいい方法をご存知の方がいれば、教えていただけると嬉しいです。