django で ファイルアップロード後の処理の進捗をプログレスバーで表示する

# この記事は何？
djangoでプログレスバーを表示しようとしたら結構苦労したので、調べた内容をメモしておきます。

# 実現したいこと
zipファイルを選択してアップロードを行うとバックエンドでzip内のファイルに対して処理が走り、処理の進捗が画面に表示される、みたいなことがしたいです。

# 実現方法
調べたところwebsocketを使用するなどいろいろな方法があるようですが、今回はpytonの非同期処理ライブラリである Celery と redis のタスクキューを使用した非同期処理を行い実現してみます。

## Celery
Pythonで非同期タスクキュー/ワーカーの分散タスクキューシステムです。主にバックグラウンドで長時間の実行を必要とするタスクやジョブを効率的に処理するために使用されます。

https://github.com/celery/celery

### タスクキュー
タスクキューとは、時間のかかる処理（ここではファイル処理）をタスクとしてためておく場所です。キュー内のタスクは、後述するブローカーによってワーカーに渡され、処理が実行されます。

### ワーカー
タスクを実行する役割を持ちます。ブローカーから受け取ったタスクを処理し、結果をブローカーに返します。

## redis
redisはオープンソースのインメモリデータベースで、高性能なデータキャッシュ、キーバリューストア、およびメッセージブローカーとして使用されることが一般的です。ディスクではなくメモリ上にデータを保持するため、高速なデータアクセスが可能であり、多くのリクエストを処理することができます。
ここでは redisを Celeryのメッセージブローカーとして使用します。djangoから処理を受け取り、Djangoと Celery worker との間でtaskの受け渡しや結果の受信を行います。

全体としては以下のようなイメージでしょうか。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/164843/d135fa49-e303-4c19-e4e0-91524754f06f.png)


## celery-progress
Celery を使用してdjangoでプログレスバーを表示することに特化した Celery Progress というライブラリを使用して、プログレスバーを作成してみます。

https://github.com/czue/celery-progress

# 環境のセットアップ

ソースコードを以下に公開しているので、簡単に内容を説明します。
https://github.com/ekity1002/django-progressbar-sample

python と使用した主なライブラリのバージョンは以下です。

* python 3.9.5
* django           4.2
* django-redis     5.2.0
* celery           5.2.7
* celery-progress  0.3


## Djangoプロジェクトの作成と設定
django_progressbar という名前でprojectを作成しています。特に変わった点はないので詳細は省略します。

## 必要ライブラリのインストール
以下のライブラリをインストールしています。
```
pip install django-redis celery celery-progress 
```

## Celeryの設定
djangoでceleryを使用するために、django_progressbar 以下に celery.pyファイルを作成し、celery の設定を行っています。
（ほぼ以下の公式ドキュメントと同じ設定です。）
https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html

異なるところはCeleryのbroker として redis を使用する設定を行っている以下の部分です。

```python
import os

from celery import Celery

# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_progressbar.settings")

# Celery app作成
# project名, brokerのホストを指定
app = Celery("django_progressbar", broker="redis://redis:6379/0")
app.conf.result_backend = "redis://redis:6379/0"

```
settings.pyにも以下を追記しています。
```
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    ...
    # Celery apps
    "celery", #追加
    "celery_progress", #追加
]
```

## アプリ設定
### view
upload_file というdjangoアプリを作成し、zipファイルをアップロードするviewを作成しています。
* views.py
```python
import time
import zipfile
from io import BytesIO

from celery import shared_task
from celery_progress.backend import ProgressRecorder
from django.core.files.storage import default_storage
from django.shortcuts import render

from .forms import UploadZipFileForm


def get_file_list(zip_file):
    # zipファイル内の全てのファイルを取得する
    buffer = BytesIO(zip_file.read())
    with zipfile.ZipFile(buffer, "r") as zip_ref:
        files = zip_ref.namelist()
    return files


@shared_task(bind=True)
def process_files(self, file_list):
    print(type(self))
    progress_recorder = ProgressRecorder(self)

    # 進行状況の初期化
    total_files = len(file_list)
    progress_recorder.set_progress(0, total_files)
    print("total files: ", total_files)

    # ファイルを読み込んで、内容を取得する
    result = 0
    for idx, file in enumerate(file_list):
        # 重い処理
        print(f"Processing {file}")
        time.sleep(0.4)
        # 進行状況を更新
        print(f"Done!")
        result += 1

        progress_recorder.set_progress(idx + 1, total_files, description=f"処理中...({idx+1}/{total_files})")
    # return "File upload success!"


def upload_zip_file(request):
    if request.method == "POST":
        form = UploadZipFileForm(request.POST, request.FILES)

        if form.is_valid():
            # クライアントから送信されたzipファイルを取得する
            print("zip uploaded.")
            zip_file = request.FILES["zip_file"]
            file_list = get_file_list(zip_file)

            # ファイルを処理する
            result = process_files.delay(file_list)

            # zip削除
            zip_file.close()
            default_storage.delete(zip_file.name)
            print(type(result), result)
            # 処理が完了したら、リダイレクトなど適切なレスポンスを返す
            return render(request, "upload_file/upload.html", context={"form": form, "task_id": result.task_id})
    else:
        form = UploadZipFileForm()
    # GETリクエストの場合は、ファイルアップロードのフォームを表示する
    return render(request, "upload_file/upload.html", {"form": form})

```
全体の流れは、zipファイルアップロード -> zipファイル内の
ここでは **shared_task** デコレータをつけているprocess_filesで行っている処理をCelery のワーカーで非同期処理させています。

process_file を呼び出す際に **delay()** をつけて呼び出すことで、非同期処理になります。

更にこの関数内で **ProgressRecorder** を作成し、celery-progress でプログレスバーを表示するための進捗を記録しています。

### template
upload.html にファイルアップロードの設定の他、celery-progress でプログレスバーを表示するために以下の記述をしています。

```html
<div class='progress-wrapper'>
  <div id='progress-bar' class='progress-bar' style="background-color: #68a9ef; width: 0%;">&nbsp;</div>
</div>
<div id="progress-bar-message">Waiting for progress to start...</div>
<div id="celery-result">
</div>

{% if task_id %}
<script type="text/javascript">
  function processProgress(progressBarElement, progressBarMessageElement, progress) {
    console.log(`@@@@@ ${progress.percent} processProgress @@@@@`)
    console.log(progress)
    progressBarElement.style.width = progress.percent + "%";
    var description = progress.description || "アップロード中...";
    progressBarMessageElement.innerHTML = description;
  }

  // Progress Bar (JQuery)
  $(function () {
    var progressUrl = "{% url 'celery_progress:task_status' task_id %}";
    CeleryProgressBar.initProgressBar(progressUrl, {
      onProgress: processProgress,
    })
  });
</script>
{% endif %}
```
こちらは celery-progress のドキュメントの設定をほぼそのまま使用しています。

https://eeinte.ch/stream/progress-bar-django-using-celery/

zipファイルがアップロードされると バックエンドで process_file 関数が非同期実行されtask_id がレスポンスとして返却されます。するとプログレスバー表示が開始されます。

## dockerの設定
dockerをで redis と djangoのコンテナを作成して使用します。docker-compose に以下のように設定しています。

```yml
version: '3'

services:
  web:
    container_name: web
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    volumes:
      - ./app:/app/app
    command: bash -c "poetry run celery -A django_progressbar worker -l INFO & poetry run python manage.py runserver 0:8000"
　
　redis:
    image: "redis:latest"
    container_name: redis
    ports:
      - "6379:6379"
    volumes:
      - "./data/redis:/data"
```
注意点として、django コンテナの起動時に以下の２つのコマンドを実行しています。

```sh
poetry run celery -A django_progressbar worker -l INFO  #celery worker 起動
poetry run python manage.py runserver 0:8000 #djangoサーバー起動
```

最初のコマンドで celery に設定した worker プロセスを起動し、その後 djangoサーバーを起動しています。

# 起動
`docker-compose up -d` で起動し、http://localhost:8000/upload/ にアクセスしてzipをアップロードすると以下のようになります。

[Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/164843/7b3b34f2-00e3-d869-5d67-3187240c0b6b.gif)

また、celery-progress は見た目や挙動のカスタマイズも色々できるようなので、興味のある方は公式ドキュメントを参照してみてください。



# まとめ
ざっくりとですがdjangoでファイルアップロード時にプログレスバー表示する方法を記述しました。
一見すると簡単そうですが、思ったより大変だったので勉強になりました。
websocket など他の実現方法との比較したメリットデメリットがあまりわかっていないので、そのあたりも調べたいです。