Django REST Framework + jQuery + S3で画像ファイルアップローダーを作った時のメモ

Django REST Framework + jQuery + S3で画像ファイルアップローダーを作る機会がありました。

その中で色々と考えたことをメモに残します。

なお、実装の詳細は以下となります。
thinkAmi-sandbox/image_uploader_by_drf_jquery_s3

 
目次

 

環境

 

仕様など

画面イメージ

f:id:thinkAmi:20200730221937p:plain:w200

 

仕様
  • 作るものはメモアプリ
  • タイトル・内容・複数画像を指定して保存
    • 画像指定後、保存ボタンを押すことでストレージへ保存する
    • 複数画像を選択するボタンはアイコンだけにする
  • 保存後、再度開いたときは最新のメモを表示
    • メモ一覧があるとより良いが、今回のサンプルでは省略
    • 複数画像のうちの一部を削除可能
      • 保存前・保存後のどちらも削除可能
  • 画像や静的ファイルはAWS S3へと保存
  • フロントエンドはjQuery、バックエンドはDjango REST Framework (以降、DRFと表記)
    • jQuery.ajax() で通信
  • ファイルはDjangoのImageFieldを使って保存

 

Web APIでファイルアップロードする方法について

Web APIでファイルをアップロードする方法について、以下の記事を参考に考えてみました。

 

送信する時のContent-Typeについて

記事には

  • multipart/form-dataで送信
  • Base64化して、application/jsonで送信

の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" なタグの場合、以下のようにボタンと選択したファイルが表示されます。

f:id:thinkAmi:20200727222655p:plain:w300

 

ただ、今回は 複数画像を選択するボタンはアイコンだけにする という仕様でした。

つまり、こんな感じのアイコンをクリックすることで、ファイルを開くダイアログを表示するようにします。

f:id:thinkAmi:20200727222709p:plain:w100

(今回のメモアプリでは、Googleのマテリアルアイコン add_photo_alternate を借用:https://material.io/resources/icons/?icon=add_photo_alternate&style=round)

 
この場合、labelで囲んでinputは display:none にします。

 

画像のプレビューについて

選択した画像のプレビューについて

FileReaderを使ってプレビューを表示させます。

 

保存済の画像のプレビューについて

保存済の画像については、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

 
何か方法がないかを調べたところ、以下の情報がありました。

 

今回は 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イベントだけでは、

  1. ファイルをプレビュー表示
  2. プレビュー表示しているファイルを削除
  3. 同じファイルをプレビュー表示

とすると、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