Django REST Framework + jQuery + S3で画像ファイルアップローダーを作る機会がありました。
その中で色々と考えたことをメモに残します。
なお、実装の詳細は以下となります。
thinkAmi-sandbox/image_uploader_by_drf_jquery_s3
目次
- 環境
- 仕様など
- Web APIでファイルアップロードする方法について
- 複数ファイルの選択について
- ファイルのアップロードボタンをなくしてアイコンだけにする
- 画像のプレビューについて
- 選択した画像のうち、保存不要な画像を削除する方法について
- 動的にボタンを含むHTMLを生成した時のクリックイベント割り当て(jQuery)
- 同じ画像ファイルを選択できるようにする
- 静的ファイル・ユーザーがアップロードしたファイルを、ともにS3に置く
- テストコードで、S3にファイルをアップロードしないようにする方法について
- ソースコード (再掲)
環境
仕様など
画面イメージ
仕様
- 作るものはメモアプリ
- タイトル・内容・複数画像を指定して保存
- 画像指定後、保存ボタンを押すことでストレージへ保存する
- 複数画像を選択するボタンはアイコンだけにする
- 保存後、再度開いたときは最新のメモを表示
- メモ一覧があるとより良いが、今回のサンプルでは省略
- 複数画像のうちの一部を削除可能
- 保存前・保存後のどちらも削除可能
- 画像や静的ファイルはAWS S3へと保存
- フロントエンドはjQuery、バックエンドはDjango REST Framework (以降、DRFと表記)
jQuery.ajax()
で通信
- ファイルはDjangoのImageFieldを使って保存
Web APIでファイルアップロードする方法について
Web APIでファイルをアップロードする方法について、以下の記事を参考に考えてみました。
送信する時のContent-Typeについて
記事には
の2つがありました。
DRFではどちらの方が都合が良いかを見たところ、DRFではデフォルトでどちらの形式でも受け取れるため、どちらでも良さそうでした。
Django REST FrameworkのDEFAULT_PARSER_CLASSESの初期値について - メモ的な思考的な
次に仕様を考えると、今回はJSON縛りはありませんでした。
そこで、Base64化する必要のない multipart/form-data
を採用しました。
ファイル単体で送信するか、データとくっつけて送信するか
記事ではファイルの保存方法として
- リソース紐付けパターン
- 汎用ファイルアップロードパターン
が挙げられていました。
今回の仕様 ファイルはDjangoのImageFieldを使って保存
に加え、DjangoのImageFiledではPillowを使ったバリデーションも含まれていることから、前者のリソース紐付けパターンを採用しました。
- 参考
なお、今回は1つのメモで複数の画像を保存できるようにすることから、モデルを2つ用意しました。
まずは親となる Memo モデルです。
class Memo(models.Model): title = models.CharField('タイトル', max_length=30) content = models.TextField('内容', default='')
続いて、画像を保存するための Picture モデルです。
class Picture(models.Model): memo = models.ForeignKey('api.Memo', on_delete=models.CASCADE, related_name='pictures') file = models.ImageField() name = models.CharField('ファイル名', max_length=250, default='example.png')
複数ファイルの選択について
複数ファイルの選択を可能にする方法として、
input type="file"
でmultiple
を使う- ファイルを選択するごとに、
input type="file"
を複数動的に生成する
のどちらかがありました。
今回は特に制限はなかったため、実装が容易な multiple
を使いました。
ファイルのアップロードボタンをなくしてアイコンだけにする
通常、 input type="file"
なタグの場合、以下のようにボタンと選択したファイルが表示されます。
ただ、今回は 複数画像を選択するボタンはアイコンだけにする
という仕様でした。
つまり、こんな感じのアイコンをクリックすることで、ファイルを開くダイアログを表示するようにします。
(今回のメモアプリでは、Googleのマテリアルアイコン add_photo_alternate
を借用:https://material.io/resources/icons/?icon=add_photo_alternate&style=round)
この場合、labelで囲んでinputは display:none
にします。
- label 要素を使用して隠した file input 要素を起動 | ウェブアプリケーションからのファイルの使用 - Web API | MDN
- css - Replace input type=file by an image - Stack Overflow
画像のプレビューについて
選択した画像のプレビューについて
FileReaderを使ってプレビューを表示させます。
- 例: ユーザが選択した画像のサムネイルを表示 | ウェブアプリケーションからのファイルの使用 - Web API | MDN
- javascript - Preview an image before it is uploaded - Stack Overflow
保存済の画像のプレビューについて
保存済の画像については、DRF側で保持している画像データを元に表示するため、 input type="file"
が使えません。
そこで、モデルのImageFieldで保存されている画像パスを使ってプレビューを作ります。
まずはシリアライザでImageFiledをJS側へと渡せるようにします。
class PictureSerializer(serializers.ModelSerializer): class Meta: model = Picture fields = '__all__' class MemoSerializer(serializers.ModelSerializer): pictures = PictureSerializer(many=True, read_only=True) class Meta: model = Memo fields = '__all__'
あとは、JS側で受け取った画像のURLをimgタグのsrcへ設定します。
選択した画像のうち、保存不要な画像を削除する方法について
今回は画像の隣りにある削除ボタンにて、プレビューしたら不要になった画像ファイルを削除できるようにします。
選択したファイルのうち不要なファイルを削除するには
- HTML上からimgタグを削除
- 削除した画像を送信しないよう、ajax()での送信時に不要なファイルを送信しないようにする
の2つを行います。
今回、 input type="file"
には multiple
属性を付与しています。
しかし、multipleで選択したファイルは FileList
として保存されます。ただし、ReadOnlyなため、個別には削除できません。
FileList - Web API | MDN
何か方法がないかを調べたところ、以下の情報がありました。
- JavaScript - javascript:アップロード前のサムネイル表示をした後、任意の画像を削除したいです。|teratail
- javascript - Remove a FileList item from a multiple "input:file" - Stack Overflow
今回は input type="file"
には multiple
属性を使っているため、
- 削除ボタンをクリックした時に、不要なファイルのindexを取得
- 保存ボタンをクリックした時に、保存が必要な画像ファイルだけFormDataオブジェクトに追加
としました。
こんな感じでFormDataオブジェクトに追加します。
送信するかどうかを判定するため、ファイル以外の項目も一つずつ追加しています。
const formData = new FormData(); // ファイル以外の項目を手動でセット formData.append('id', $('#memo-id').val()); formData.append('title', $('#title').val()); formData.append('content', $('#content').val()); // 送信が必要なファイルのみFormDataへ追加 const files = $('#pictures').get(0).files; for (let i = 0; i < files.length; i++) { if (!this.excludePictureIndexes.includes(i)) { formData.append('additional_pictures', files[i]); } }
動的にボタンを含むHTMLを生成した時のクリックイベント割り当て(jQuery)
最初はこんな感じのHTMLだけがあります。
<div id="additions"></div>
ファイルを選択することで、選択したファイル分、上記のdivタグの中に
<div class="preview"> <p> <img src="${src}" alt="${alt}" style="width: 20%; max-width:300px;"> <button type="button" class="remove-picture" data-additional-picture-index="${additionalPictureIndex}" data-registered-picture-id="${registeredPictureId}"> <i class="material-icons">delete</i> </button> </p> </div>
というHTMLが動的に追加されます。
このとき、追加の都度イベントハンドラを追加するのは手間でした。
そのため、以下のようにすることで、イベントハンドラの追加は1回だけになりました。
$('#additions').on('click', '.remove-picture', (event) => { this.removePicture(event); })
同じ画像ファイルを選択できるようにする
onChangeイベントだけでは、
- ファイルをプレビュー表示
- プレビュー表示しているファイルを削除
- 同じファイルをプレビュー表示
とすると、3.の段階ではプレビューが表示できません。
そこで、onClickイベントとonChangeイベントを使うことで、再度同じファイルを選択できるようにします。
jquery - How to detect input type=file "change" for the same file? - Stack Overflow
const fileTag = $('#pictures'); fileTag.on('click', function() { $(this).prop('value', ''); }) fileTag.on('change', (event) => { this.createAdditionalPicturePreviews(event); });
静的ファイル・ユーザーがアップロードしたファイルを、ともにS3に置く
django-storages
を使います。
jschneier/django-storages: https://django-storages.readthedocs.io/
書籍「現場で使えるDjangoの教科書 実践編」に詳細があるため、そちらに従って実装します。
テストコードで、S3にファイルをアップロードしないようにする方法について
django-storages
を使っている環境でfacotry_boyで ImageFIeld
のフェイクデータを作成した場合、テストコードを走らせたときにS3へファイルが置かれてしまいます。
また、テストが終わっても自動で画像ファイルを削除してくれません。
# Factory class PictureFactory(factory.django.DjangoModelFactory): class Meta: model = Picture memo = factory.SubFactory(MemoFactory) name = factory.Faker('word') file = factory.django.ImageField(color='blue') # テスト def test_成功_GET_差し替え無し(self): PictureFactory() # ここでS3にファイルが置かれる url = reverse('api:memo-list') actual = APIClient().get(url) assert actual.status_code == HTTPStatus.OK
そこで、テストコードを走らせるときはS3にアップロードしないようにするには、以下の方法があります。
- インメモリなストレージを使う
- S3をモックしてくれるmotoを使う
インメモリなストレージを使う
dj-inmemorystorage
を使うことで、インメモリなストレージに切り替えられます。
zsiciarz/dj-inmemorystorage: A non-persistent in-memory data storage backend for Django.
なお、以下の例では @override_settings
デコレータを使って差し替えていますが、テスト用のsettings.pyを用意したほうが良いかもしれません。
@override_settings(DEFAULT_FILE_STORAGE='inmemorystorage.InMemoryStorage') def test_成功_GET_インメモリなストレージに差し替え(self): PictureFactory() # この時点でフェイクファイルが作られる url = reverse('api:memo-list') actual = APIClient().get(url) assert actual.status_code == HTTPStatus.OK
motoを使う
もう一つの方法として、AWSリソースをモックしてくれる moto
があります。
spulec/moto: A library that allows you to easily mock out tests based on AWS infrastructure.
こちらの場合は、 @mock_s3
デコレータを使うことで、S3をモックしてくれます。
注意点としては、mock_s3だけではバケットを作ってくれないので、バケットを作らないとエラーになります。
botocore.errorfactory.NoSuchBucket: An error occurred (NoSuchBucket) when calling the PutObject operation: The specified bucket does not exist
そのため、バケットを作った後に、実際にテストしたい内容を書きます。
@mock_s3 def test_成功_GET_motoに差し替える(self): s3_client = boto3.client('s3') s3_client.create_bucket(Bucket=settings.MEDIA_AWS_STORAGE_BUCKET_NAME) PictureFactory() url = reverse('api:memo-list') actual = APIClient().get(url) assert actual.status_code == HTTPStatus.OK
ソースコード (再掲)
GitHubに上げました。
thinkAmi-sandbox/image_uploader_by_drf_jquery_s3