Rails/Vue 編集時に画像をD&Dで入れ替えした時のActive Storageの保存方法

この記事は[フィヨルドブートキャンプ Part 2 Advent Calendar 2022]の24日目の記事です🎄

昨日23日目は、wataさんのフィヨルドブートキャンプ内で「1分間スピーチ会」を開いてみた 、はるぐち ゆうまさんのためして分かる、N+1問題とその解決方法 - プログラミング漫遊記でした。

フィヨルドブートキャンプ Part 1 Advent Calendar 2022 - Adventar
フィヨルドブートキャンプ Part 2 Advent Calendar 2022 - Adventar

はじめに

saeyamaと申します。
フィヨルドブートキャンプに籍をおいている現役生です。
今、最終課題の自作サービスに取り組んでおります。
この記事は自作サービスでいくつか技術検証をした内の一部をまとめたものです。
色々ご意見あるかと存じますが、レビュー前のため、お手柔らかに見ていただけると幸いです🙇‍♀️

自作サービスの簡単な説明

自作サービスはバックエンドはRails、フロントエンドはVueです。
サービス内容は簡単にいうとネイリストのためのデザインノート(メモ)のようなものです。
機能の一つとして複数の画像と動画が保存できる仕様になっていてActive Storageを使用しています。

そして、登録する画像はフロント側でD&Dで順番の入れ替えができる仕様になっています。
なぜ、順番を入れ替える必要があるかというと、

  • 1枚目の画像を一覧ページのサムネイルに設定するため
  • ネイルの講習等に行って撮影した画像を手順通りに並べられるようにするため

と、いった目的があるためです。

並び替えができるD&Dの機能はVueのライブラリvuedraggableを使用しました。

vuedraggableのサンプル

実装も簡単だしサンプルを見てもわかる通りとても便利なライブラリです。

vuedraggableの問題点

登録する時にはとても便利だし、何も問題はないものの、
編集時に画像の追加や削除をした上で、順番を入れ替えた場合にその状態を

どうやって、どこに記憶させるか

という問題にぶち当たりました。

それらしい記事を見つけたのですが、登録済のstringのカラムを編集時にD&Dで入れ替えた場合に、どう記憶させるかといった内容のもので、対策としてmodelに順番を記憶させるsortカラムを追加してRails側でAPIデータをsortカラムの順番で制御して、フロント側で表示させるといった内容でした。

良さそう!と思ったものの、私の自作サービスの場合、登録済の画像と新たに登録する画像が混在するケースがあります。
また新規で登録する画像にidは付与されていない状態です。
順番を記憶させるカラムを追加する場合、どこのmodelに追加すればいいのか。

  • Active Storageをattachしているmodelにするのか。
  • 新たにsort専用にmodelを作った方がいいのか。
  • Active Storageにカラムって追加できるのか。

プチパニックです。

どうしたか

結論からいうと、カラムの追加はせず、画像をattachする時に、filenameTime.zone.nowとしていたのをD&Dしてsortされたidの配列の添字を当て込めることにしました。

irb(main):022:0> Design.find(1).images[0].blob
=>
#<ActiveStorage::Blob:0x00007f847a2e4068
 id: 1, 
 key: "dk2e7ri3l7idgkwwc5p6e799dshp",
 filename: "0", ⇦ `Time.zone.now`からsortしたidの配列の添字にしました。
 content_type: "image/jpeg",
 metadata: {"identified"=>true, "analyzed"=>true},
 service_name: "local",
 byte_size: 193059,
 checksum: "yaoUEuiXo/Agsj1XOzhD5A==",
 created_at: Thu, 15 Dec 2022 21:34:41.036899000 JST +09:00>

なぜ、どこかしらにsort用のカラムを追加しなかったかというと、編集時にD&Dしないケースが想定されるためです。

そして、なぜ添字にしたのかは次に説明します。

流れ

まずVue側のフロントでは、画像のidの配列の値を送るようにセットします。
削除する画像、新たに追加する画像、そして順番を入れ替える場合、下記のようなidの配列となります。

[5, "", 6, 2, 1, ""]

この場合、""となっているところは親モデルのupdateの時に新規でattachされた画像のidが当て込まれることになります。
新たに付与されたidは、必ず前から順番に当て込まれるので、例えば付与されるidが仮に1112だった場合に""に左記の数値を当て込めれば、sortされたidの配列が出来上がります。

sortされた画像(ActiveStorage::Blob)のid

[5, 11, 6, 2, 1, 12]

上記のidをeach.with_index(1)で回して
ActiveStorage::Blob.find(画像のid)filename: indexにしてupdateすれば順番を保持できると考えました。

[sortされたActiveStorage::Blobのid].each.with_index(1) { |id, index| ActiveStorage::Blob.find(id).update(filename: index) }

ただ、その場合、新たに追加されたActiveStorage::Blobのidが必要になります。
そうなると、attachしている親のidが必要になり、controllerでないとメソッドを設置することが出来ません。

@design = designs.find(params[:id])⇦attachしている親

新たなidの取得が必要
image_ids = @design.images.map(&:id)
new_ids = image_ids.select { |image| sort_image_ids.max.to_i < image }

modelにメソッドを設置したい。
そこで考えたのが、@design.imagesから呼び出してfilenameをupdateする方法です。
その場合、必要な数値は新たに追加するidではなく添字の番号になる(@design.images[添字番号])ので、
""は送られてきたidの中で一番大きい数値に+1した数値をダミーで当て込めようと考えました。

[5, "", 6, 2, 1, ""]

↓

78はダミー番号
[5, 7, 6, 2, 1, 8]

この配列の順番を保持した状態で、添字に変換します。

[5, 7, 6, 2, 1, 8]

↓

[2, 4, 3, 1, 0, 5]

添字に変換した配列をeach.with_index(1)で回して、
@design.images[添字]filename: indexにしてupdateできるようにしました。

[添字に変換した配列].each.with_index(1) { |添字, index| @design.images[添字].blob.update(filename: index) }

そうすることでfilenameに順番の数値を当て込めることができます。

model側では、以下のようなメソッドを作成しました。(リファクタリング前)

def images_set(sort_image_ids)
  return unless sort_image_ids

  if sort_image_ids.include?('')
    blank_ids = sort_image_ids.each_index.select { |id| sort_image_ids[id] == '' }
    blank_ids.size.times { |i| sort_image_ids[blank_ids[i]] = sort_image_ids.map(&:to_i).max + 1 }
  end
  design_images_index = sort_image_ids.map(&:to_i).each_with_object(sort_image_ids.map(&:to_i).sort).map { |id, sorted| sorted.index(id) }
  design_images_index.map(&:to_i).each.with_index(1) { |id, index| images[id].blob.update(filename: index) }
end

以下抜粋
配列の中で""があった場合にif文で現状のidの中で一番大きい数値に+1した数値をダミーで当て込む。

sort_image_idsはVueから送られてきたsortしたidの番号の配列

  if sort_image_ids.include?('')
    blank_ids = sort_image_ids.each_index.select { |id| sort_image_ids[id] == '' }
    blank_ids.size.times { |i| sort_image_ids[blank_ids[i]] = sort_image_ids.map(&:to_i).max + 1 }
  end

そして添字に変換しfilenameをupdateさせています。
添字に変換する方法が思いつかず、悩みましたが、each_with_objectを使って変換する方法を教えていただきました🙇‍♀️
Enumerable#each_with_object (Ruby 3.1 リファレンスマニュアル)

  design_images_index = sort_image_ids.map(&:to_i).each_with_object(sort_image_ids.map(&:to_i).sort).map { |id, sorted| sorted.index(id) }
  design_images_index.map(&:to_i).each.with_index(1) { |id, index| images[id].blob.update(filename: index) }

上記のメソッドをcontrollerでupdateする時に、以下のようにセットしました。

@design.images_set(sort_image_ids) if @design.images.attached?

filenameがsort順に更新できるようになりました。

sort順に並んだ画像の取得方法

現状だと、APIデータの画像はid順で並ぶようになっているのでjbuilderでsort順に並ぶようにします。

画像のURLをrails_blob_urlで取得すると、URLの末尾にfilenameが表示されます。

画像のURL

"http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--604e0ac50b10ed8687b0f97bde916011758d3ec2/1"1がfilename

urlをsplitしてfilenameを取得して、新たにindexというkeyを作ってvalueとして設定します。

APIデータ

images: [
  {
    id: 1,
    url: "http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--604e0ac50b10ed8687b0f97bde916011758d3ec2/0",
    _destroy: "0",
    index: 0 ⇦追加
  },
  {
    id: 2,
    url: "http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--06a47b05efff72da1924ef77a49a80ffa80b96eb/1",
    _destroy: "0",
    index: 1 ⇦追加
  }
]

indexの順番通りに画像を並び替えれば、フロント側でsortした順番通りに表示されるようになります。

APIデータ

idの12を編集時に入れ替えた場合

images: [
  {
    id: 2,
    url: "http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--06a47b05efff72da1924ef77a49a80ffa80b96eb/0",
    _destroy: "0",
    index: 0
  },
  {
    id: 1,
    url: "http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--604e0ac50b10ed8687b0f97bde916011758d3ec2/1",
    _destroy: "0",
    index: 1
    }
  ]

並び替えする時も、なかなかうまくいかずに困りました。
sortメソッドと<=>演算子を使って並び替えをすることができました。

Array#sort (Ruby 3.1 リファレンスマニュアル)

jbuilder

image_urls = @design.images.map { |image| { 'id': image.id, 'url': rails_blob_url(image), '_destroy': '0', 'index': rails_blob_url(image).split('/')[8].to_i } }
sort_images = image_urls.sort {|x, y| x[:index] <=> y[:index] } if @design.images.attached?
json.images sort_images

苦肉の策って感じでもあるし、他にやり方があるのでは?とお思いかもしれませんが、自作サービスなのでちょっと探求してみました。
冒頭でもお伝えしましたが、レビュー前のため、お手柔らかに見ていただけると幸いです🙇‍♀️
また、端折った内容にもなっているので他の技術検証含め、今後まとめて書いていきたいと思っております。

終わりに

この前、胃腸炎にかかり、数日間、過去1レベルの激痛を味わいました。
そしてちょっと回復した時に近くのセブンに行ったら、
目の前に並んでいた人が、本能の赴くままに、ななチキを頼んでいたのを見た時、

「あぁ、この苦しみも含め、報われる日が来るといいなぁ…」

と、思った瞬間、セブンでガチ泣きしそうになったちょうど一ヶ月程前。

勉強するにしても、お金を稼ぐにしても、美味しいケーキを食すにしても、健康でなければ何も出来ないということを思い知らされた、2022年・初冬。

皆様もお身体には、十分ご留意ください。

最後までお読みいただきありがとうござました。

明日、最後となるフィヨルドブートキャンプ Advent Calendar 2022 25日目は、mhさん、ラスボスkomagataさんです🎄