画像の複数アップロードに奮闘した話
Tabimemoの開発を始めて以来、幾つか実装をしたい機能があったのですが、その中でも特に画像の複数アップロードは実装をしたいと思っていました。しかしながら、どうやっても上手く行かず作ってみてはやり直してを繰り返し前に進めずにいました。試行錯誤を重ねて先日ようやく実装できたのですが、その時の喜びは大きく、また、学びや成長も感じられた案件だったので振り返ってみたいと思います。
画像の複数アップロードの機能要件
具体的に実装してみたかった機能要件としては下記の通りのものでした。
- プランに紐づくスポットの写真を複数登録できる
- 画像のファイル形式やファイルサイズでバリデーションを掛ける
- ファイル入力フォームから画像を選択した際にプレビューが見れる
- 画像の削除も直感的な操作で行える(編集画面で削除を押すと画像が消える等)
ザクっとまとめているのでもう少し細かい要件はあったかとは思うのですが、主要な部分で行くとこんな感じです。一見すると1〜2日程度あれば実装できてしまうようにも見えるのですが、私はこの機能を自分の満足の行く形にするのに1ヶ月以上かかりました。(毎日やっているわけではないので、まるまる1ヶ月ではないと思いますが)
では、実際にどんなところでつまづき、最終的にどんな方法で実装をしたのかについて触れていきます。
画像の複数登録の落とし穴
今回の実装で一番躓いたのが画像の複数登録の実装です。ちなみに画像登録自体はCarrierwaveを使っていることを前提に話していきますが、Carrierwaveの話はほぼ出てきませんので事前にご理解くださいませ。
さてさて、実装するにあたって当初は下記の2パターンを考えていました。
- スポットにhas_manyで画像テーブルを持たせる方法
- スポットに配列を格納できるカラムを持たせる方法
しかしながら、上記の2パターンでは私が理想とする画像の複数登録の機能が実装できませんでした。
1の方法ではファイル入力フォームから画像を1つづつしか登録が出来ないためユーザービリティ的に問題があり、理想とするUIにはなりませんでした。
そして2の方法で実装を試みました。Carrierwaveの公式文書に従い配列(厳密にはjson)を格納できるカラムを用意し、実装を進めました。詳しい実装方法について知りたい方はコチラを参照ください。
これで一見実装できたように見えたのですが、ここで問題が起きました。画像の複数登録はできる。でも、スポットを編集する時に違う画像を追加したいと思って操作をすると、、、元あった画像郡に新しい画像郡が「上書き」されてしまうのです。今になって感がてみれば、そりゃupdateでデータが上書きされるのは当たり前なことなのですが、この理由を探るのに数日費やしてしまいました。
そして行き着いたのが、コチラのサイト。
ここで書いてある内容としては、画像の配列が格納されるカラムが保存される前に「既存の画像郡の配列+新しい画像画像郡の配列」を統合した配列を作ってやり保存してあげれば良いんだよ、ということでした。しかしながら、自分の作っているサービスではユーザーが複数のプランを持っており、かつ、プランが複数のスポットを持っており、かつスポットが複数の画像を持っている、というややこしい構造から、この書き方に合わせて実装をすることが自分の実力不足もあり及びませんでした。
ついでに言うと、カラムに配列を突っ込む設計ってDBの正規化的な話からいいのかなぁ、とちょっと気になったりもしていました。
最終的に行き着いた案
色々と頭がこんがらがりながらも試行錯誤を重ねて、最終的に行き着いたのが下記の記事で取り上げられていた実装方法でした。
この記事では、フォームから画像の配列のパラメータを受け取るものの、最終的には配列の中身を1つ1つ分割して保存を行うクラスメッソッドを実装する方法を取っていました。これであれば先程少し気になっていたテーブルに配列を格納するDBの非正規問題は解決します。
そして、コチラのサイトで扱っているコードの改良版として、親のテーブル(スポット)のidをパラメータとして受け取ってクラスメソッドの中で展開してあげれば、スポットに紐づく複数の画像が登録できる、そして画像を追加したいときにも新しい画像郡で上書きがされない、というような流れが実現できます。
この時は自分にプログラミングの神様が舞い降りて、瞬時に下記のコードを書き上げることに成功しました。(プログラミングの神様って本当にいるんですねw)
photo.model
def self.create_photos_by(photo_params) Photo.transaction do photo_params.each do |index| spot = Spot.find(photo_params[index][:id]) photo_params[index][:photos]&.each do |photo| return false unless spot.photos.create!(image: photo) end end end end
plan.controller
def update if @plan.update(edit_plan_params) && Photo.create_photos_by(photo_params[:spots_attributes]) redirect_to plan_path(@plan), notice: t(:update_success, scope: :flash) else render "edit" end end private def photo_params params.require(:plan).permit(spots_attributes: [:id, { photos: [] }]) end
新たに発生した問題
上記のようなコードで実際に複数の画像が登録できたのですが、改めて2つの問題が発生しました。1つは新たにスポットを追加した時の問題で、もう一つはバリデーションが正しく動かない問題です。
新たにスポットを追加した時の問題
ここで発生したのが、新たにスポットを追加した時にレコードが存在しないので、上記のコードの中で画像に紐付けるスポットIDが存在しないため、新しいスポットの追加時に画像を追加することが出来ない、という致命的なバグでした。
そこで、最近仕事でも勉強をしたAjaxを利用してスポット追加ボタンを押した時に、作成されたインスタンスをそのままレコードに保存するという実装を試みました。ボタンを押した際のAjaxの部分だけを実装したかったのですが、スポットの追加の挙動にはcocoonというgemを使っていました。(かつてcocoonを導入するという記事を書いていたので興味のある方は下記を御覧ください。)
結果的にこのgemが仇となり、ボタンを押した際のAjax処理だけの実装ができなかったので、仕方なくcocoonで実装をしていたスポットの追加ボタン、削除ボタンの挙動を生のJSで実装をすることにしました。実際のコードは下記の通りです。
spot_fields.coffee
class spotFields constructor: (@$root)-> @bind() createSpotField: (e)=> e.preventDefault() $target = $(e.target) data = $target.data() spot_field = @$root.find(".spot_field") $.ajax url: "/users/create_spot" type: "GET" dataType: "json" data: plan_id: data.planId .done (res) => regexp = new RegExp(data.id, "g") spot_field.append(data.fields.replace(regexp, res)) @$root.find(".spot_field_id").last().val(res) return destroySpotField: (e)=> e.preventDefault() $target = $(e.target) $spot_form = $target.closest(".spot_form") $spot_form.find(".destroy_spot").val(true) $spot_form.hide() return bind: => @$root.on "click", ".create_btn", @createSpotField .on "click", ".destroy_btn", @destroySpotField return $(document).on "turbolinks:load", -> new spotFields $(".spot_container")
application_helper.rb
module ApplicationHelper def new_spot_field(f) new_object = f.object.spots.new id = "new_spot" plan_id = f.object.id fields = f.fields_for(:spots, new_object, child_index: id) do |builder| render '/users/plans/spot_fields', f: builder end { fields: fields.gsub("\n", ""), id: id, plan_id: plan_id } end end
_form.html.haml
= simple_form_for @plan, url: url do |f| = f.input :name = f.input :description, input_html: { rows:5 } %h2= t("form.register_spot") .spot_container .spot_field = f.simple_fields_for :spots do |spot| = render "spot_fields", f: spot = link_to t("form.create_spot"), "javascript:void(0);", class: "btn btn-success create_btn", data: new_spot_field(f) = f.button :submit, t("form.published"), class: "btn-primary" = f.button :submit, t("form.draft"), class: "btn-primary", name: :draft
_spot_fields.html.haml
.spot_form.jumbotron = f.hidden_field :id = f.hidden_field :_destroy, class: "destroy_spot" = f.input :name = f.input :description, input_html: { rows: 5 } .image_field = f.file_field :photos, multiple: true = link_to "スポットを削除", "javascript:void(0);", class: "btn btn-danger destroy_btn"
これで、スポット追加時にスポットのインスタンスがレコードに保存され、hidden_fieldにもスポットのIDが反映されるようになりました。(JavaScriptに関する知識が少し上がった!)
画像を保存した時のバリデーションが上手く効かない問題
次に発生したのがバリデーションの問題です。理想はエラーが該当する画像フォームの箇所にバリデーションメッセージを表示したかったのですが、今の実装のままでは、エラーの時に例外を発生させる様になってしまっています。
色々と考えた末に、Photoモデルで実装をしていたクラスメソッドをSpotモデルのインスタンスメソッドとして実装することにしました。実際のコードは下記の通りです。
spot.rb
def create_photos_by(photo_params) Photo.transaction do photo_params.each do |index| next if photo_params[index][:_destroy] == true photo_params[index][:photos]&.each do |photo| next unless id == photo_params[index][:id].to_i return unless photos.create(image: photo) end end end end
このコードに行き着くまでに、クラスメソッドではエラーが該当するインスタンスを補足することが出来ない(クラスメソッドで生成したインスタンスに対して補足する方法がない)が、インスタンスメソッドであればインスタンスそのものが持つメソッドから、新たにそのインスタンスに関連するインスタンスが作れるため、エラーが該当するインスタンスを補足する事ができる、という気づきを得ることが出来ました。(クラスメソッドとインスタンスメソッドの違いがなんとなくわかった!)
念願の画像アップロードが完成!
このような感じで色々な壁にぶつかりながらも、念願の画像の複数アップロードを実装することが出来ました。この機能はTabimemoにとっても無くてはならない機能であったため、冒頭にも申し上げましたが、実装できた時の喜びはとても大きかったです。そして、今回書かせてい頂いた通り色々な学びを得ることが出来ました。
同時に難しいと思った案件に妥協をせずに食らいついて開発するということはとても大切なことだと理解しました。難しいと感じる=自分の知識が足りていない、ということなので、それをあらゆる情報を駆使し実装をすることにより、自分のスキルをワンアップさせることができるからです。
とはいえ、今回のこの実装も正しい方法なのか?もっとうまくまとまった実装方法もあるんじゃないか?という風に思ったりもしているので、引き続きいい実装方法は模索していく予定です。
っということで今回は少し長文となりましたが、画像複数アップロード奮闘記でした。